Make right-arrow skip links work

Unfortunately, this is a fairly significant rework, since the
<FixView> needed access to the data required to determine the next
step, which often was already available in the relevant page.tsx,
so to be able to pass that data into the <FixView>, I had to move
it from the layout into every individual page.

In addition to the right-arrow skip links, this also sets the
correct target for the skip link in the manual broker resolution.
This commit is contained in:
Vincent 2023-10-03 17:53:30 +02:00 коммит произвёл Vincent
Родитель 1a6e860133
Коммит 3ee2ae07d7
46 изменённых файлов: 1875 добавлений и 1132 удалений

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

@ -80,6 +80,13 @@ const preview: Preview = {
)();
}
if (
path ===
"/redesign/user/dashboard/fix/data-broker-profiles/start-free-scan"
) {
linkTo("Pages/Guided resolution/1a. Free scan")();
}
if (
path ===
"/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers"
@ -96,6 +103,101 @@ const preview: Preview = {
) {
linkTo("Pages/Guided resolution/1c. Manually resolve brokers")();
}
if (
path ===
"/redesign/user/dashboard/fix/data-broker-profiles/automatic-remove"
) {
linkTo(
"Pages/Guided resolution/1d. Automatically resolve brokers"
)();
}
if (
path === "/redesign/user/dashboard/fix/high-risk-data-breaches/ssn"
) {
linkTo(
"Pages/Guided resolution/2. High-risk data breaches",
"2a. Social Security Number"
)();
}
if (
path ===
"/redesign/user/dashboard/fix/high-risk-data-breaches/credit-card"
) {
linkTo(
"Pages/Guided resolution/2. High-risk data breaches",
"2b. Credit card"
)();
}
if (
path ===
"/redesign/user/dashboard/fix/high-risk-data-breaches/bank-account"
) {
linkTo(
"Pages/Guided resolution/2. High-risk data breaches",
"2c. Bank account"
)();
}
if (
path === "/redesign/user/dashboard/fix/high-risk-data-breaches/pin"
) {
linkTo(
"Pages/Guided resolution/2. High-risk data breaches",
"2d. PIN"
)();
}
if (
path === "/redesign/user/dashboard/fix/leaked-passwords/password"
) {
linkTo(
"Pages/Guided resolution/3. Leaked passwords",
"3a. Passwords"
)();
}
if (
path ===
"/redesign/user/dashboard/fix/leaked-passwords/security-question"
) {
linkTo(
"Pages/Guided resolution/3. Leaked passwords",
"3b. Security questions"
)();
}
if (
path ===
"/redesign/user/dashboard/fix/security-recommendations/phone"
) {
linkTo(
"Pages/Guided resolution/4. Security recommendations",
"4a. Phone number"
)();
}
if (
path ===
"/redesign/user/dashboard/fix/security-recommendations/email"
) {
linkTo(
"Pages/Guided resolution/4. Security recommendations",
"4b. Email address"
)();
}
if (
path === "/redesign/user/dashboard/fix/security-recommendations/ip"
) {
linkTo(
"Pages/Guided resolution/4. Security recommendations",
"4c. IP address"
)();
}
},
},
},

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

@ -81,7 +81,7 @@ export function createRandomScanResult(
}
export type RandomBreachOptions = Partial<{
dataClasses: Array<(typeof BreachDataTypes)[keyof typeof BreachDataTypes]>;
dataClasses: SubscriberBreach["dataClasses"];
addedDate: Date;
isResolved: boolean;
dataClassesEffected: DataClassEffected[];

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

@ -7,7 +7,7 @@ import { TabType } from "../View";
import { useL10n } from "../../../../../../hooks/l10n";
import { DoughnutChart as Chart } from "../../../../../../components/client/Chart";
import { DashboardSummary } from "../../../../../../functions/server/dashboard";
import { InputData as StepDeterminationData } from "../../../../../../functions/server/getRelevantGuidedSteps";
import { StepDeterminationData } from "../../../../../../functions/server/getRelevantGuidedSteps";
import { DashboardTopBannerContent } from "./DashboardTopBannerContent";
export type DashboardTopBannerProps = {

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

@ -7,83 +7,32 @@
import Link from "next/link";
import Image from "next/image";
import { ReactNode } from "react";
import { OnerepScanResultRow } from "knex/types/tables";
import { FixNavigation } from "../../../../../../components/client/FixNavigation";
import styles from "./fix.module.scss";
import ImageArrowLeft from "./images/icon-arrow-left.svg";
import ImageArrowRight from "./images/icon-arrow-right.svg";
import ImageClose from "./images/icon-close.svg";
import stepDataBrokerProfilesIcon from "./images/step-counter-data-broker-profiles.svg";
import stepHighRiskDataBreachesIcon from "./images/step-counter-high-risk.svg";
import stepLeakedPasswordsIcon from "./images/step-counter-leaked-passwords.svg";
import stepSecurityRecommendationsIcon from "./images/step-counter-security-recommendations.svg";
import { usePathname } from "next/navigation";
import { GuidedExperienceBreaches } from "../../../../../../functions/server/getUserBreaches";
import { useL10n } from "../../../../../../hooks/l10n";
import { stepLinks } from "../../../../../../functions/server/getRelevantGuidedSteps";
import { StepDeterminationData } from "../../../../../../functions/server/getRelevantGuidedSteps";
export type FixViewProps = {
children: ReactNode;
breaches: GuidedExperienceBreaches;
userScannedResults: OnerepScanResultRow[];
subscriberEmails: string[];
data: StepDeterminationData;
nextStepHref: string;
currentSection:
| "data-broker-profiles"
| "high-risk-data-breach"
| "leaked-passwords"
| "security-recommendations";
};
export const FixView = (props: FixViewProps) => {
const pathname = usePathname();
const l10n = useL10n();
const isResolutionLayout = [
"high-risk-data-breach",
"leaked-passwords",
"security-recommendations",
].some((substring) => pathname.includes(substring));
const totalHighRiskBreaches = Object.values(props.breaches.highRisk).reduce(
(acc, array) => acc + array.length,
0
);
const totalDataBrokerProfiles = props.userScannedResults.length;
const totalPasswordBreaches = Object.values(
props.breaches.passwordBreaches
).reduce((acc, array) => acc + array.length, 0);
const totalSecurityRecommendations = Object.values(
props.breaches.securityRecommendations
).filter((value) => {
return value.length > 0;
}).length;
const navigationItemsContent = [
{
key: "data-broker-profiles",
labelStringId: "fix-flow-nav-data-broker-profiles",
href: "/redesign/user/dashboard/fix/data-broker-profiles",
status: totalDataBrokerProfiles,
currentStepId: "dataBrokerProfiles",
imageId: stepDataBrokerProfilesIcon,
},
{
key: "high-risk-data-breaches",
labelStringId: "fix-flow-nav-high-risk-data-breaches",
href: "/redesign/user/dashboard/fix/high-risk-data-breaches",
status: totalHighRiskBreaches,
currentStepId: "highRiskDataBreaches",
imageId: stepHighRiskDataBreachesIcon,
},
{
key: "leaked-passwords",
labelStringId: "fix-flow-nav-leaked-passwords",
href: "/redesign/user/dashboard/fix/leaked-passwords",
status: totalPasswordBreaches,
currentStepId: "leakedPasswords",
imageId: stepLeakedPasswordsIcon,
},
{
key: "security-recommendations",
labelStringId: "fix-flow-nav-security-recommendations",
href: "/redesign/user/dashboard/fix/security-recommendations",
status: totalSecurityRecommendations,
currentStepId: "securityRecommendations",
imageId: stepSecurityRecommendationsIcon,
},
];
].includes(props.currentSection);
const navigationClose = () => {
return (
@ -97,15 +46,6 @@ export const FixView = (props: FixViewProps) => {
);
};
const currentStepIndex = stepLinks.findIndex((stepLink) =>
stepLink.href.startsWith(pathname)
);
const prevStepHref =
currentStepIndex <= 0
? "/redesign/user/dashboard"
: stepLinks[currentStepIndex - 1].href;
const nextStepHref = stepLinks[currentStepIndex + 1].href;
return (
<div className={styles.fixContainer}>
<div
@ -114,22 +54,16 @@ export const FixView = (props: FixViewProps) => {
}`}
>
<FixNavigation
navigationItems={navigationItemsContent}
pathname={pathname}
currentSection={props.currentSection}
data={props.data}
subscriberEmails={props.subscriberEmails}
/>
{navigationClose()}
<section className={styles.fixSection}>
<Link
className={`${styles.navArrow} ${styles.navArrowBack}`}
href={prevStepHref}
aria-label={l10n.getString("guided-resolution-flow-back-arrow")}
>
<Image alt="" src={ImageArrowLeft} />
</Link>
<div className={styles.viewWrapper}>{props.children}</div>
<Link
className={`${styles.navArrow} ${styles.navArrowNext}`}
href={nextStepHref}
href={props.nextStepHref}
aria-label={l10n.getString("guided-resolution-flow-next-arrow")}
>
<Image alt="" src={ImageArrowRight} />

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

@ -0,0 +1,76 @@
/* 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 { OnerepScanRow } from "knex/types/tables";
import { AutomaticRemoveView } from "./AutomaticRemoveView";
import {
createRandomBreach,
createRandomScanResult,
createUserWithPremiumSubscription,
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { LatestOnerepScanData } from "../../../../../../../../../db/tables/onerep_scans";
const mockedScan: OnerepScanRow = {
created_at: new Date(1998, 2, 31),
updated_at: new Date(1998, 2, 31),
id: 0,
onerep_profile_id: 0,
onerep_scan_id: 0,
onerep_scan_reason: "initial",
onerep_scan_status: "finished",
};
const mockedScanData: LatestOnerepScanData = {
scan: mockedScan,
results: [...Array(5)].map(() =>
createRandomScanResult({ status: "new", manually_resolved: false })
),
};
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
const user = createUserWithPremiumSubscription();
const mockedSession = {
expires: new Date().toISOString(),
user: user,
};
const meta: Meta<typeof AutomaticRemoveView> = {
title: "Pages/Guided resolution/1d. Automatically resolve brokers",
component: AutomaticRemoveView,
};
export default meta;
type Story = StoryObj<typeof AutomaticRemoveView>;
export const ManualRemoveViewStory: Story = {
name: "1d. Automatically resolve brokers",
render: () => {
return (
<Shell
l10n={getEnL10nSync()}
session={mockedSession}
nonce=""
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<AutomaticRemoveView
data={{
countryCode: "us",
latestScanData: mockedScanData,
subscriberBreaches: mockedBreaches,
user: mockedSession.user,
}}
subscriberEmails={[]}
nextStepHref="/redesign/user/dashboard/fix/high-risk-data-breaches/social-security-number"
currentSection="data-broker-profiles"
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
/>
</Shell>
);
},
};

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

@ -0,0 +1,166 @@
/* 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 React, { ComponentProps, useState } from "react";
import styles from "../dataBrokerProfiles.module.scss";
import { Button } from "../../../../../../../../components/server/Button";
import { useL10n } from "../../../../../../../../hooks/l10n";
import { FixView } from "../../FixView";
export type Props = Omit<ComponentProps<typeof FixView>, "children"> & {
monthlySubscriptionUrl: string;
yearlySubscriptionUrl: string;
};
export function AutomaticRemoveView(props: Props) {
const l10n = useL10n();
const [selectedPlanIsYearly, setSelectedPlanIsYearly] = useState(true);
const dataBrokerCount = parseInt(
process.env.NEXT_PUBLIC_ONEREP_DATA_BROKER_COUNT as string,
10
);
const { monthlySubscriptionUrl, yearlySubscriptionUrl, ...fixViewProps } =
props;
return (
<FixView {...fixViewProps}>
<div>
<div className={`${styles.content} ${styles.contentAutomaticRemove}`}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-headline"
)}
</h3>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-subheadline",
{
data_broker_count: dataBrokerCount,
}
)}
</p>
</div>
<div className={styles.content}>
<div className={styles.upgradeToggleWrapper}>
<div className={styles.upgradeToggle}>
<button
onClick={() => setSelectedPlanIsYearly(!selectedPlanIsYearly)}
className={`${selectedPlanIsYearly ? styles.isActive : ""}`}
>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-toggle-yearly"
)}
</button>
<button
onClick={() => setSelectedPlanIsYearly(!selectedPlanIsYearly)}
className={`${selectedPlanIsYearly ? "" : styles.isActive}`}
>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-toggle-monthly"
)}
</button>
</div>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-save-percent",
{ percent: 10 }
)}
</span>
</div>
<div className={styles.upgradeContentWrapper}>
{/* Feature List */}
<div className={styles.featuresList}>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-headline"
)}
</strong>
<ul>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-monthly-scan",
{
data_broker_count: dataBrokerCount,
}
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-remove-personal-info"
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-guided-experience"
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-continuous-monitoring"
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-breach-alerts"
)}
</li>
</ul>
</div>
{/* Plan select */}
<div className={styles.selectedPlan}>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-headline"
)}
<small>
{selectedPlanIsYearly
? l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-yearly-frequency"
)
: l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-monthly-frequency"
)}
</small>
</strong>
{/* Price */}
<span>
{selectedPlanIsYearly
? l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-price",
{ price: "X.XX" }
)
: l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-price",
{ price: "X.XX" }
)}
</span>
<Button
variant="primary"
href={
selectedPlanIsYearly
? yearlySubscriptionUrl
: monthlySubscriptionUrl
}
onPress={() => (window.location.href = "../../subscribed")} // TODO replace with final UI
>
{selectedPlanIsYearly
? l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-yearly-button"
)
: l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-monthly-button"
)}
</Button>
</div>
</div>
</div>
</div>
</FixView>
);
}

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

@ -1,162 +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 React, { useState } from "react";
import styles from "../dataBrokerProfiles.module.scss";
import { Button } from "../../../../../../../../components/server/Button";
import { useL10n } from "../../../../../../../../hooks/l10n";
interface AutomaticRemoveProps {
monthlySubscriptionUrl: string;
yearlySubscriptionUrl: string;
}
export default function AutomaticRemove({
monthlySubscriptionUrl,
yearlySubscriptionUrl,
}: AutomaticRemoveProps) {
const l10n = useL10n();
const [selectedPlanIsYearly, setSelectedPlanIsYearly] = useState(true);
const dataBrokerCount = parseInt(
process.env.NEXT_PUBLIC_ONEREP_DATA_BROKER_COUNT as string,
10
);
return (
<div>
<div className={`${styles.content} ${styles.contentAutomaticRemove}`}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-headline"
)}
</h3>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-subheadline",
{
data_broker_count: dataBrokerCount,
}
)}
</p>
</div>
<div className={styles.content}>
<div className={styles.upgradeToggleWrapper}>
<div className={styles.upgradeToggle}>
<button
onClick={() => setSelectedPlanIsYearly(!selectedPlanIsYearly)}
className={`${selectedPlanIsYearly ? styles.isActive : ""}`}
>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-toggle-yearly"
)}
</button>
<button
onClick={() => setSelectedPlanIsYearly(!selectedPlanIsYearly)}
className={`${selectedPlanIsYearly ? "" : styles.isActive}`}
>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-toggle-monthly"
)}
</button>
</div>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-save-percent",
{ percent: 10 }
)}
</span>
</div>
<div className={styles.upgradeContentWrapper}>
{/* Feature List */}
<div className={styles.featuresList}>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-headline"
)}
</strong>
<ul>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-monthly-scan",
{
data_broker_count: dataBrokerCount,
}
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-remove-personal-info"
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-guided-experience"
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-continuous-monitoring"
)}
</li>
<li>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-breach-alerts"
)}
</li>
</ul>
</div>
{/* Plan select */}
<div className={styles.selectedPlan}>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-headline"
)}
<small>
{selectedPlanIsYearly
? l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-yearly-frequency"
)
: l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-monthly-frequency"
)}
</small>
</strong>
{/* Price */}
<span>
{selectedPlanIsYearly
? l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-price",
{ price: "X.XX" }
)
: l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-price",
{ price: "X.XX" }
)}
</span>
<Button
variant="primary"
href={
selectedPlanIsYearly
? yearlySubscriptionUrl
: monthlySubscriptionUrl
}
>
{selectedPlanIsYearly
? l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-yearly-button"
)
: l10n.getString(
"fix-flow-data-broker-profiles-automatic-remove-features-select-plan-monthly-button"
)}
</Button>
</div>
</div>
</div>
</div>
);
}

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

@ -2,17 +2,56 @@
* 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 React from "react";
import { AutomaticRemoveView } from "./AutomaticRemoveView";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { redirect } from "next/navigation";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { getSubscriberBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import {
StepDeterminationData,
getNextGuidedStep,
} from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import getPremiumSubscriptionUrl from "../../../../../../../../functions/server/getPremiumSubscriptionUrl";
import AutomaticRemove from "./View";
const monthlySubscriptionUrl = getPremiumSubscriptionUrl({ type: "monthly" });
const yearlySubscriptionUrl = getPremiumSubscriptionUrl({ type: "yearly" });
export default function Layout() {
export default async function AutomaticRemovePage() {
const session = await getServerSession(authOptions);
if (!session?.user?.subscriber?.id) {
redirect("/redesign/user/dashboard/");
}
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
const subBreaches = await getSubscriberBreaches(session.user);
const subscriberEmails = await getSubscriberEmails(session.user);
const data: StepDeterminationData = {
countryCode: getCountryCode(headers()),
latestScanData: scanData,
subscriberBreaches: subBreaches,
user: session.user,
};
const nextStep = getNextGuidedStep(data, "Scan");
return (
<AutomaticRemove
<AutomaticRemoveView
data={data}
subscriberEmails={subscriberEmails}
nextStepHref={nextStep?.href ?? ""}
currentSection="data-broker-profiles"
monthlySubscriptionUrl={monthlySubscriptionUrl}
yearlySubscriptionUrl={yearlySubscriptionUrl}
></AutomaticRemove>
/>
);
}

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

@ -12,9 +12,7 @@ import {
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { FixView } from "../../FixView";
import { LatestOnerepScanData } from "../../../../../../../../../db/tables/onerep_scans";
import { GuidedExperienceBreaches } from "../../../../../../../../functions/server/getUserBreaches";
const mockedScan: OnerepScanRow = {
created_at: new Date(1998, 2, 31),
@ -34,25 +32,6 @@ const mockedScanData: LatestOnerepScanData = {
};
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
const mockedBreachSummary: GuidedExperienceBreaches = {
emails: [],
highRisk: {
bankBreaches: [],
creditCardBreaches: [],
pinBreaches: [],
ssnBreaches: [],
},
passwordBreaches: {
passwords: [],
securityQuestions: [],
},
securityRecommendations: {
emailAddress: [],
IPAddress: [],
phoneNumber: [],
},
};
const user = createUserWithPremiumSubscription();
const mockedSession = {
@ -78,17 +57,13 @@ export const ManualRemoveViewStory: Story = {
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<FixView
breaches={mockedBreachSummary}
userScannedResults={mockedScanData.results}
>
<ManualRemoveView
scanData={mockedScanData}
breaches={mockedBreaches}
countryCode="us"
user={mockedSession.user}
/>
</FixView>
<ManualRemoveView
scanData={mockedScanData}
breaches={mockedBreaches}
countryCode="us"
user={mockedSession.user}
subscriberEmails={[]}
/>
</Shell>
);
},

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

@ -0,0 +1,59 @@
/* 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 { it, expect } from "@jest/globals";
import { render, screen } from "@testing-library/react";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import { userEvent } from "@testing-library/user-event";
jest.mock("../../../../../../../../functions/server/l10n");
import Meta, { ManualRemoveViewStory } from "./ManualRemove.stories";
it("passes the axe accessibility test suite", async () => {
const ComposedManualRemoveView = composeStory(ManualRemoveViewStory, Meta);
const { container } = render(<ComposedManualRemoveView />);
expect(await axe(container)).toHaveNoViolations();
});
it("removes the manual resolution button once a profile has been resolved", async () => {
const user = userEvent.setup();
global.fetch = jest.fn().mockResolvedValueOnce({ ok: true });
const ComposedManualRemoveView = composeStory(ManualRemoveViewStory, Meta);
render(<ComposedManualRemoveView />);
const resolveButtonsBeforeResolving = screen.getAllByRole("button", {
name: "Mark as fixed",
});
await user.click(resolveButtonsBeforeResolving[0]);
const resolveButtonsAfterResolving = screen.getAllByRole("button", {
name: "Mark as fixed",
});
expect(resolveButtonsAfterResolving.length).toBeLessThan(
resolveButtonsBeforeResolving.length
);
});
it("keeps the manual resolution button if resolving a profile failed", async () => {
const user = userEvent.setup();
global.fetch = jest.fn().mockResolvedValueOnce({ ok: false });
const ComposedManualRemoveView = composeStory(ManualRemoveViewStory, Meta);
render(<ComposedManualRemoveView />);
const resolveButtonsBeforeResolving = screen.getAllByRole("button", {
name: "Mark as fixed",
});
await user.click(resolveButtonsBeforeResolving[0]);
const resolveButtonsAfterResolving = screen.getAllByRole("button", {
name: "Mark as fixed",
});
expect(resolveButtonsAfterResolving.length).toBe(
resolveButtonsBeforeResolving.length
);
});

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

@ -17,13 +17,18 @@ import {
} from "../../../../../../../../functions/server/dashboard";
import { SubscriberBreach } from "../../../../../../../../../utils/subscriberBreaches";
import { RemovalCard } from "./RemovalCard";
import { getNextGuidedStep } from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import {
StepDeterminationData,
getNextGuidedStep,
} from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import { FixView } from "../../FixView";
export type Props = {
scanData: LatestOnerepScanData;
breaches: SubscriberBreach[];
user: Session["user"];
countryCode: string;
subscriberEmails: string[];
};
export function ManualRemoveView(props: Props) {
@ -35,122 +40,130 @@ export function ManualRemoveView(props: Props) {
const estimatedTime = countOfDataBrokerProfiles * 10; // 10 minutes per data broker site.
const exposureReduction = getExposureReduction(summary);
const stepAfterSkip = getNextGuidedStep(
{
countryCode: props.countryCode,
latestScanData: props.scanData,
subscriberBreaches: props.breaches,
user: props.user,
},
"Scan"
);
const data: StepDeterminationData = {
countryCode: props.countryCode,
latestScanData: props.scanData,
subscriberBreaches: props.breaches,
user: props.user,
};
const stepAfterSkip = getNextGuidedStep(data, "Scan");
return (
<div className={styles.main}>
<div className={styles.content}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-headline"
)}
</h3>
<ol className={styles.removalStepsList}>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-1-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-1-content"
)}
</span>
</li>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-2-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-2-content"
)}
</span>
</li>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-3-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-3-content"
)}
</span>
</li>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-4-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-4-content"
)}
</span>
</li>
</ol>
</div>
<div className={styles.exposureListing}>
<h3 className={styles.questionTooltipWrapper}>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-review-profiles-headline"
)}
</h3>
<div className={styles.exposureList}>
{props.scanData.results.map((scanResult, index) => {
return (
<RemovalCard
key={scanResult.onerep_scan_result_id}
scanResult={scanResult}
isExpanded={index === 0}
/>
);
})}
<FixView
data={data}
subscriberEmails={props.subscriberEmails}
// In practice, there should always be a next step (at least "Done")
/* c8 ignore next */
nextStepHref={stepAfterSkip?.href ?? ""}
currentSection="data-broker-profiles"
>
<div className={styles.main}>
<div className={styles.content}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-headline"
)}
</h3>
<ol className={styles.removalStepsList}>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-1-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-1-content"
)}
</span>
</li>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-2-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-2-content"
)}
</span>
</li>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-3-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-3-content"
)}
</span>
</li>
<li>
<strong>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-4-title"
)}
</strong>
<span>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-how-to-remove-step-4-content"
)}
</span>
</li>
</ol>
</div>
<div className={styles.exposureListing}>
<h3 className={styles.questionTooltipWrapper}>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-review-profiles-headline"
)}
</h3>
<div className={styles.exposureList}>
{props.scanData.results.map((scanResult, index) => {
return (
<RemovalCard
key={scanResult.onerep_scan_result_id}
scanResult={scanResult}
isExpanded={index === 0}
/>
);
})}
</div>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href="/redesign/user/dashboard/fix/data-broker-profiles/automatic-remove"
>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-button-remove-for-me"
)}
</Button>
<Button variant="secondary" href={stepAfterSkip?.href}>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-button-skip"
)}
</Button>
</div>
<div className={styles.dataBrokerResolutionStats}>
<div>
<ClockIcon width="18" height="18" alt="" />
{l10n.getString("data-broker-profiles-estimated-time", {
estimated_time: estimatedTime,
})}
</div>
<div>
<AvatarIcon width="18" height="18" alt="" />
{l10n.getString("data-broker-profiles-exposure-reduction", {
exposure_reduction: exposureReduction,
})}
</div>
</div>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href="/redesign/user/dashboard/fix/data-broker-profiles/automatic-remove"
>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-button-remove-for-me"
)}
</Button>
<Button variant="secondary" href={stepAfterSkip?.href}>
{l10n.getString(
"fix-flow-data-broker-profiles-manual-remove-button-skip"
)}
</Button>
</div>
<div className={styles.dataBrokerResolutionStats}>
<div>
<ClockIcon width="18" height="18" alt="" />
{l10n.getString("data-broker-profiles-estimated-time", {
estimated_time: estimatedTime,
})}
</div>
<div>
<AvatarIcon width="18" height="18" alt="" />
{l10n.getString("data-broker-profiles-exposure-reduction", {
exposure_reduction: exposureReduction,
})}
</div>
</div>
</div>
</FixView>
);
}

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

@ -11,8 +11,9 @@ import { getSubscriberBreaches } from "../../../../../../../../functions/server/
import { ManualRemoveView } from "./ManualRemoveView";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
export default async function ManualRemove() {
export default async function ManualRemovePage() {
const session = await getServerSession(authOptions);
if (!session?.user?.subscriber?.id) {
@ -23,12 +24,15 @@ export default async function ManualRemove() {
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
const subBreaches = await getSubscriberBreaches(session.user);
const subscriberEmails = await getSubscriberEmails(session.user);
return (
<ManualRemoveView
breaches={subBreaches}
scanData={scanData}
user={session.user}
countryCode={getCountryCode(headers())}
subscriberEmails={subscriberEmails}
/>
);
}

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

@ -0,0 +1,72 @@
/* 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 { OnerepScanRow } from "knex/types/tables";
import { StartFreeScanView } from "./StartFreeScanView";
import {
createRandomBreach,
createRandomScanResult,
createUserWithPremiumSubscription,
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { LatestOnerepScanData } from "../../../../../../../../../db/tables/onerep_scans";
const mockedScan: OnerepScanRow = {
created_at: new Date(1998, 2, 31),
updated_at: new Date(1998, 2, 31),
id: 0,
onerep_profile_id: 0,
onerep_scan_id: 0,
onerep_scan_reason: "initial",
onerep_scan_status: "finished",
};
const mockedScanData: LatestOnerepScanData = {
scan: mockedScan,
results: [...Array(5)].map(() =>
createRandomScanResult({ status: "new", manually_resolved: false })
),
};
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
const user = createUserWithPremiumSubscription();
const mockedSession = {
expires: new Date().toISOString(),
user: user,
};
const meta: Meta<typeof StartFreeScanView> = {
title: "Pages/Guided resolution/1a. Free scan",
component: StartFreeScanView,
};
export default meta;
type Story = StoryObj<typeof StartFreeScanView>;
export const ManualRemoveViewStory: Story = {
name: "1a. Free scan",
render: () => {
return (
<Shell
l10n={getEnL10nSync()}
session={mockedSession}
nonce=""
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<StartFreeScanView
data={{
countryCode: "us",
latestScanData: mockedScanData,
subscriberBreaches: mockedBreaches,
user: mockedSession.user,
}}
subscriberEmails={[]}
/>
</Shell>
);
},
};

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

@ -0,0 +1,84 @@
/* 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 Image from "next/image";
import ImageCityScape from "./images/city-scape.svg";
import styles from "../dataBrokerProfiles.module.scss";
import { Button } from "../../../../../../../../components/server/Button";
import { getL10n } from "../../../../../../../../functions/server/l10n";
import { FixView } from "../../FixView";
import {
StepDeterminationData,
getNextGuidedStep,
} from "../../../../../../../../functions/server/getRelevantGuidedSteps";
export type Props = {
data: StepDeterminationData;
subscriberEmails: string[];
};
export function StartFreeScanView(props: Props) {
const l10n = getL10n();
return (
<FixView
data={props.data}
subscriberEmails={props.subscriberEmails}
nextStepHref={getNextGuidedStep(props.data, "Scan")?.href ?? ""}
currentSection="data-broker-profiles"
>
<div className={styles.contentWrapper}>
<Image className={styles.cityScape} src={ImageCityScape} alt="" />
<div className={styles.content}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-headline"
)}
</h3>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-content-p1",
{
data_broker_count: parseInt(
process.env.NEXT_PUBLIC_ONEREP_DATA_BROKER_COUNT as string,
10
),
}
)}
</p>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-content-p2"
)}
<a href="#">
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-link-learn-more"
)}
</a>
</p>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href={
"/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers"
}
>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-button-start-scan"
)}
</Button>
<Button
variant="secondary"
href={"/redesign/user/dashboard/fix/high-risk-data-breaches"}
>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-button-skip"
)}
</Button>
</div>
</div>
</FixView>
);
}

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

@ -3,21 +3,18 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { headers } from "next/headers";
import Image from "next/image";
import ImageCityScape from "./images/city-scape.svg";
import styles from "../dataBrokerProfiles.module.scss";
import { Button } from "../../../../../../../../components/server/Button";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { getL10n } from "../../../../../../../../functions/server/l10n";
export default async function StartFreeScan() {
const l10n = getL10n();
import { StepDeterminationData } from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import { getSubscriberBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
import { StartFreeScanView } from "./StartFreeScanView";
export default async function StartFreeScanPage() {
const countryCode = getCountryCode(headers());
if (countryCode !== "us") {
redirect("/redesign/user/dashboard");
@ -31,67 +28,24 @@ export default async function StartFreeScan() {
const result = await getOnerepProfileId(session.user.subscriber.id);
const onerepProfileId = result[0]["onerep_profile_id"];
if (typeof onerepProfileId === "number") {
const scanData = await getLatestOnerepScanResults(onerepProfileId);
if (scanData.scan !== null) {
// If the user already has done a scan, let them view their results:
return redirect(
"/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers"
);
}
const latestScanData =
typeof onerepProfileId === "number"
? await getLatestOnerepScanResults(onerepProfileId)
: undefined;
if (latestScanData?.scan) {
// If the user already has done a scan, let them view their results:
return redirect(
"/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers"
);
}
return (
<div className={styles.contentWrapper}>
<Image className={styles.cityScape} src={ImageCityScape} alt="" />
<div className={styles.content}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-headline"
)}
</h3>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-content-p1",
{
data_broker_count: parseInt(
process.env.NEXT_PUBLIC_ONEREP_DATA_BROKER_COUNT as string,
10
),
}
)}
</p>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-content-p2"
)}
<a href="#">
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-link-learn-more"
)}
</a>
</p>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href={
"/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers"
}
>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-button-start-scan"
)}
</Button>
<Button
variant="secondary"
href={"/redesign/user/dashboard/fix/high-risk-data-breaches"}
>
{l10n.getString(
"fix-flow-data-broker-profiles-start-free-scan-button-skip"
)}
</Button>
</div>
</div>
);
const data: StepDeterminationData = {
countryCode: countryCode,
user: session.user,
latestScanData: latestScanData ?? null,
subscriberBreaches: await getSubscriberBreaches(session.user),
};
const subscriberEmails = await getSubscriberEmails(session.user);
return <StartFreeScanView data={data} subscriberEmails={subscriberEmails} />;
}

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

@ -5,63 +5,78 @@
import styles from "../dataBrokerProfiles.module.scss";
import { getL10n } from "../../../../../../../../functions/server/l10n";
import { DataBrokerProfiles } from "../../../../../../../../components/client/DataBrokerProfiles";
import { LatestOnerepScanData } from "../../../../../../../../../db/tables/onerep_scans";
import { AboutBrokersIcon } from "./AboutBrokersIcon";
import { Button } from "../../../../../../../../components/server/Button";
import { FixView } from "../../FixView";
import {
StepDeterminationData,
getNextGuidedStep,
} from "../../../../../../../../functions/server/getRelevantGuidedSteps";
export type Props = {
scanData: LatestOnerepScanData;
data: StepDeterminationData;
subscriberEmails: string[];
};
export const ViewDataBrokersView = (props: Props) => {
const l10n = getL10n();
const countOfDataBrokerProfiles = props.scanData.results.length;
const countOfDataBrokerProfiles =
props.data.latestScanData?.results.length ?? 0;
const stepAfterSkip = getNextGuidedStep(props.data, "Scan");
return (
<div>
<div className={styles.content}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-headline",
{ data_broker_sites_results_num: countOfDataBrokerProfiles }
)}
</h3>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-content"
)}
</p>
<FixView
data={props.data}
subscriberEmails={props.subscriberEmails}
nextStepHref={stepAfterSkip?.href ?? ""}
currentSection="data-broker-profiles"
>
<div>
<div className={styles.content}>
<h3>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-headline",
{ data_broker_sites_results_num: countOfDataBrokerProfiles }
)}
</h3>
<p>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-content"
)}
</p>
</div>
<div className={styles.content}>
<h4 className={styles.questionTooltipWrapper}>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-view-info-on-sites"
)}
<AboutBrokersIcon />
</h4>
<DataBrokerProfiles data={props.data.latestScanData?.results ?? []} />
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href="/redesign/user/dashboard/fix/data-broker-profiles/automatic-remove"
>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-button-remove-for-me"
)}
</Button>
<Button
variant="secondary"
href={
"/redesign/user/dashboard/fix/data-broker-profiles/manual-remove"
}
>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-button-remove-manually"
)}
</Button>
</div>
</div>
<div className={styles.content}>
<h4 className={styles.questionTooltipWrapper}>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-view-info-on-sites"
)}
<AboutBrokersIcon />
</h4>
<DataBrokerProfiles data={props.scanData.results} />
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href="/redesign/user/dashboard/fix/data-broker-profiles/automatic-remove"
>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-button-remove-for-me"
)}
</Button>
<Button
variant="secondary"
href={
"/redesign/user/dashboard/fix/data-broker-profiles/manual-remove"
}
>
{l10n.getString(
"fix-flow-data-broker-profiles-view-data-broker-profiles-button-remove-manually"
)}
</Button>
</div>
</div>
</FixView>
);
};

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

@ -11,29 +11,8 @@ import {
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { FixView } from "../../FixView";
import { GuidedExperienceBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import { LatestOnerepScanData } from "../../../../../../../../../db/tables/onerep_scans";
const mockedBreachesEmpty: GuidedExperienceBreaches = {
emails: [],
highRisk: {
bankBreaches: [],
creditCardBreaches: [],
pinBreaches: [],
ssnBreaches: [],
},
passwordBreaches: {
passwords: [],
securityQuestions: [],
},
securityRecommendations: {
emailAddress: [],
IPAddress: [],
phoneNumber: [],
},
};
const brokerOptions = {
"no-scan": "No scan started",
empty: "No scan results",
@ -121,12 +100,15 @@ const ViewWrapper = (props: ViewWrapperProps) => {
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<FixView
breaches={mockedBreachesEmpty}
userScannedResults={scanData.results}
>
<ViewDataBrokersView scanData={scanData} />
</FixView>
<ViewDataBrokersView
data={{
latestScanData: scanData,
countryCode: "us",
subscriberBreaches: [],
user: mockedSession.user,
}}
subscriberEmails={[]}
/>
</Shell>
);
};

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

@ -4,10 +4,15 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { ViewDataBrokersView } from "./View";
import { StepDeterminationData } from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { getSubscriberBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
export default async function ViewDataBrokers() {
const session = await getServerSession(authOptions);
@ -19,6 +24,15 @@ export default async function ViewDataBrokers() {
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] as number;
const latestScan = await getLatestOnerepScanResults(profileId);
const data: StepDeterminationData = {
countryCode: getCountryCode(headers()),
user: session.user,
latestScanData: latestScan ?? null,
subscriberBreaches: await getSubscriberBreaches(session.user),
};
const subscriberEmails = await getSubscriberEmails(session.user);
return <ViewDataBrokersView scanData={latestScan} />;
return (
<ViewDataBrokersView data={data} subscriberEmails={subscriberEmails} />
);
}

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

@ -0,0 +1,72 @@
/* 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 { OnerepScanRow } from "knex/types/tables";
import { WelcomeToPremiumView } from "./WelcomeToPremiumView";
import {
createRandomBreach,
createRandomScanResult,
createUserWithPremiumSubscription,
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { LatestOnerepScanData } from "../../../../../../../../../db/tables/onerep_scans";
const mockedScan: OnerepScanRow = {
created_at: new Date(1998, 2, 31),
updated_at: new Date(1998, 2, 31),
id: 0,
onerep_profile_id: 0,
onerep_scan_id: 0,
onerep_scan_reason: "initial",
onerep_scan_status: "finished",
};
const mockedScanData: LatestOnerepScanData = {
scan: mockedScan,
results: [...Array(5)].map(() =>
createRandomScanResult({ status: "new", manually_resolved: false })
),
};
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
const user = createUserWithPremiumSubscription();
const mockedSession = {
expires: new Date().toISOString(),
user: user,
};
const meta: Meta<typeof WelcomeToPremiumView> = {
title: "Pages/Guided resolution/1e. Welcome to Premium",
component: WelcomeToPremiumView,
};
export default meta;
type Story = StoryObj<typeof WelcomeToPremiumView>;
export const ManualRemoveViewStory: Story = {
name: "1e. Welcome to Premium",
render: () => {
return (
<Shell
l10n={getEnL10nSync()}
session={mockedSession}
nonce=""
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<WelcomeToPremiumView
data={{
countryCode: "us",
latestScanData: mockedScanData,
subscriberBreaches: mockedBreaches,
user: mockedSession.user,
}}
subscriberEmails={[]}
/>
</Shell>
);
},
};

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

@ -0,0 +1,91 @@
/* 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 styles from "./welcomeToPremium.module.scss";
import { getL10n } from "../../../../../../../../functions/server/l10n";
import { PercentageChart } from "../../../../../../../../components/client/PercentageChart";
import {
getDashboardSummary,
getExposureReduction,
} from "../../../../../../../../functions/server/dashboard";
import { Button } from "../../../../../../../../components/server/Button";
import {
StepDeterminationData,
getNextGuidedStep,
} from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import { FixView } from "../../FixView";
export type Props = {
data: StepDeterminationData;
subscriberEmails: string[];
};
export function WelcomeToPremiumView(props: Props) {
const l10n = getL10n();
const countOfDataBrokerProfiles =
props.data.latestScanData?.results.length ?? 0;
const summary = getDashboardSummary(
props.data.latestScanData?.results ?? [],
props.data.subscriberBreaches
);
const exposureReduction = getExposureReduction(summary);
return (
<FixView
data={props.data}
subscriberEmails={props.subscriberEmails}
nextStepHref={getNextGuidedStep(props.data, "Scan")?.href ?? ""}
currentSection="data-broker-profiles"
>
<div className={styles.contentWrapper}>
<div className={styles.content}>
<h3>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-title-part-one"
)}
<br />
{l10n.getString(
"welcome-to-premium-data-broker-profiles-title-part-two"
)}
</h3>
<p>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-description-part-one",
{
profile_total_num: countOfDataBrokerProfiles,
exposure_reduction_percentage: exposureReduction,
}
)}
</p>
<p>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-description-part-two"
)}
</p>
<p>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-description-part-three"
)}
</p>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href="/redesign/user/dashboard/fix/high-risk-data-breaches"
disabled
wide
>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-cta-label"
)}
</Button>
</div>
</div>
<div className={styles.chart}>
<PercentageChart exposureReduction={exposureReduction} />
</div>
</div>
</FixView>
);
}

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

@ -2,24 +2,20 @@
* 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 styles from "./welcomeToPremium.module.scss";
import { getL10n } from "../../../../../../../../functions/server/l10n";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { redirect } from "next/navigation";
import { PercentageChart } from "../../../../../../../../components/client/PercentageChart";
import { getSubscriberBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import {
getDashboardSummary,
getExposureReduction,
} from "../../../../../../../../functions/server/dashboard";
import { Button } from "../../../../../../../../components/server/Button";
import { WelcomeToPremiumView } from "./WelcomeToPremiumView";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
import { StepDeterminationData } from "../../../../../../../../functions/server/getRelevantGuidedSteps";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { hasPremium } from "../../../../../../../../functions/universal/user";
export default async function WelcomeToPremium() {
const l10n = getL10n();
export default async function WelcomeToPremiumPage() {
const session = await getServerSession(authOptions);
if (!session?.user?.subscriber?.id) {
@ -32,61 +28,19 @@ export default async function WelcomeToPremium() {
}
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] ?? -1;
const scanResultItems =
(await getLatestOnerepScanResults(profileId))?.results ?? [];
const countOfDataBrokerProfiles = scanResultItems.length;
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
const subBreaches = await getSubscriberBreaches(session.user);
const summary = getDashboardSummary(scanResultItems, subBreaches);
const exposureReduction = getExposureReduction(summary);
const subscriberEmails = await getSubscriberEmails(session.user);
const data: StepDeterminationData = {
countryCode: getCountryCode(headers()),
latestScanData: scanData,
subscriberBreaches: subBreaches,
user: session.user,
};
return (
<div className={styles.contentWrapper}>
<div className={styles.content}>
<h3>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-title-part-one"
)}
<br />
{l10n.getString(
"welcome-to-premium-data-broker-profiles-title-part-two"
)}
</h3>
<p>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-description-part-one",
{
profile_total_num: countOfDataBrokerProfiles,
exposure_reduction_percentage: exposureReduction,
}
)}
</p>
<p>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-description-part-two"
)}
</p>
<p>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-description-part-three"
)}
</p>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
href="/redesign/user/dashboard/fix/high-risk-data-breaches"
disabled
wide
>
{l10n.getString(
"welcome-to-premium-data-broker-profiles-cta-label"
)}
</Button>
</div>
</div>
<div className={styles.chart}>
<PercentageChart exposureReduction={exposureReduction} />
</div>
</div>
<WelcomeToPremiumView data={data} subscriberEmails={subscriberEmails} />
);
}

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

@ -121,6 +121,8 @@ export const FraudAlertModal = () => {
<div className={styles.confirmButtonWrapper}>
<Button
variant="primary"
// TODO: Test dialog closing
/* c8 ignore next */
onPress={() => overlayTriggerState.close()}
autoFocus={true}
>

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

@ -1,110 +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 { SubscriberBreach } from "../../../../../../../../utils/subscriberBreaches";
import { createRandomBreach } from "../../../../../../../../apiMocks/mockData";
import { getGuidedExperienceBreaches } from "../../../../../../../functions/universal/guidedExperienceBreaches";
import { HighRiskBreachLayout } from "./HighRiskBreachLayout";
import creditCardIllustration from "../images/high-risk-data-breach-credit-card.svg";
import socialSecurityNumberIllustration from "../images/high-risk-data-breach-ssn.svg";
import bankAccountIllustration from "../images/high-risk-data-breach-bank-account.svg";
import pinIllustration from "../images/high-risk-data-breach-pin.svg";
import noBreachesIllustration from "../images/high-risk-breaches-none.svg";
const meta: Meta<typeof HighRiskBreachLayout> = {
title: "ResolutionLayout",
component: HighRiskBreachLayout,
};
export default meta;
type Story = StoryObj<typeof HighRiskBreachLayout>;
const scannedResultsArraySample: SubscriberBreach[] = Array.from(
{ length: 5 },
() => createRandomBreach({ isHighRiskOnly: true })
);
const summaryString = "It appeared in 2 data breaches:";
const recommendations = {
title: "Heres what to do",
steps: (
<ol>
<li>Recommendation one</li>
<li>Recommendation two</li>
<li>Recommendation three</li>
</ol>
),
};
const content = {
summary: summaryString,
description: <p>Security recommendation description text.</p>,
recommendations,
};
const guidedExperienceBreaches = getGuidedExperienceBreaches(
scannedResultsArraySample,
["test@mozilla.com"]
);
export const CreditCard: Story = {
args: {
pageData: {
type: "credit-card",
title: "Credit card",
illustration: creditCardIllustration,
exposedData: guidedExperienceBreaches.highRisk.ssnBreaches,
content,
},
},
};
export const BankAccount: Story = {
args: {
pageData: {
type: "bank-account",
title: "Bank account",
illustration: bankAccountIllustration,
exposedData: guidedExperienceBreaches.highRisk.ssnBreaches,
content,
},
},
};
export const SSN: Story = {
args: {
pageData: {
type: "ssn",
title: "SNN",
illustration: socialSecurityNumberIllustration,
exposedData: guidedExperienceBreaches.highRisk.ssnBreaches,
content,
},
},
};
export const PIN: Story = {
args: {
pageData: {
type: "pin",
title: "PIN",
illustration: pinIllustration,
exposedData: guidedExperienceBreaches.highRisk.ssnBreaches,
content,
},
},
};
export const None: Story = {
args: {
pageData: {
type: "none",
title: "No breaches",
illustration: noBreachesIllustration,
exposedData: [],
content,
},
},
};

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

@ -1,45 +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 { render } from "@testing-library/react";
import { it, expect } from "@jest/globals";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import Meta, {
CreditCard,
BankAccount,
SSN,
PIN,
None,
} from "./HighRiskBreachLayout.stories";
it("high risk data breach component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(CreditCard, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("bank account data breach component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(BankAccount, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("SSN data breach component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(SSN, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("PIN data breach component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(PIN, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("Zero state breach component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(None, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});

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

@ -9,55 +9,110 @@ import { ResolutionContainer } from "../ResolutionContainer";
import { ResolutionContent } from "../ResolutionContent";
import { Button } from "../../../../../../../components/server/Button";
import { useL10n } from "../../../../../../../hooks/l10n";
import { HighRiskBreach } from "./highRiskBreachData";
import { getLocale } from "../../../../../../../functions/universal/getLocale";
import { FixView } from "../FixView";
import {
HighRiskBreachTypes,
getHighRiskBreachesByType,
} from "./highRiskBreachData";
import {
StepDeterminationData,
StepLink,
getNextGuidedStep,
} from "../../../../../../../functions/server/getRelevantGuidedSteps";
import { getGuidedExperienceBreaches } from "../../../../../../../functions/universal/guidedExperienceBreaches";
export type HighRiskBreachLayoutProps = {
pageData: HighRiskBreach;
type: HighRiskBreachTypes;
subscriberEmails: string[];
data: StepDeterminationData;
};
export function HighRiskBreachLayout({ pageData }: HighRiskBreachLayoutProps) {
export function HighRiskBreachLayout(props: HighRiskBreachLayoutProps) {
const l10n = useL10n();
const { title, illustration, content, exposedData, type } = pageData;
const stepMap: Record<HighRiskBreachTypes, StepLink["id"]> = {
ssn: "HighRiskSsn",
"credit-card": "HighRiskCreditCard",
"bank-account": "HighRiskBankAccount",
pin: "HighRiskPin",
none: "HighRiskPin",
};
const guidedExperienceBreaches = getGuidedExperienceBreaches(
props.data.subscriberBreaches,
props.subscriberEmails
);
const pageData = getHighRiskBreachesByType({
dataType: props.type,
breaches: guidedExperienceBreaches,
l10n: l10n,
});
// The non-null assertion here should be safe since we already did this check
// in `./[type]/page.tsx`:
const { title, illustration, content, exposedData, type } = pageData!;
const hasBreaches = type !== "none";
return (
<ResolutionContainer
type="securityRecommendations"
title={title}
illustration={illustration}
cta={
<>
<Button
variant="primary"
small
// TODO: Add test once MNTOR-1700 logic is added
/* c8 ignore next 3 */
onPress={() => {
// TODO: MNTOR-1700 Add routing logic + fix event here
}}
>
{hasBreaches
? l10n.getString("high-risk-breach-mark-as-fixed")
: l10n.getString("high-risk-breach-none-continue")}
</Button>
{hasBreaches && (
<Link
// TODO: Add test once MNTOR-1700 logic is added
href="/"
>
{l10n.getString("high-risk-breach-skip")}
</Link>
)}
</>
<FixView
subscriberEmails={props.subscriberEmails}
data={props.data}
nextStepHref={
// We *should* always match a next step:
/* c8 ignore next */
getNextGuidedStep(props.data, stepMap[props.type])?.href ?? ""
}
estimatedTime={hasBreaches ? 15 : undefined}
currentSection="high-risk-data-breach"
>
<ResolutionContent
content={content}
exposedData={exposedData}
locale={getLocale(l10n)}
/>
</ResolutionContainer>
<ResolutionContainer
type="securityRecommendations"
title={title}
illustration={illustration}
cta={
<>
<Button
variant="primary"
small
// TODO: Add test once MNTOR-1700 logic is added
/* c8 ignore next 3 */
onPress={() => {
// TODO: MNTOR-1700 Add routing logic + fix event here
}}
>
{
// Theoretically, this page should never be shown if the user
// has no breaches, unless the user directly visits its URL, so
// no tests represents it either:
/* c8 ignore next 3 */
hasBreaches
? l10n.getString("high-risk-breach-mark-as-fixed")
: l10n.getString("high-risk-breach-none-continue")
}
</Button>
{hasBreaches && (
<Link
// TODO: Add test once MNTOR-1700 logic is added
href="/"
>
{l10n.getString("high-risk-breach-skip")}
</Link>
)}
</>
}
// Theoretically, this page should never be shown if the user has no
// breaches, unless the user directly visits its URL, so no tests
// represents it either:
/* c8 ignore next */
estimatedTime={hasBreaches ? 15 : undefined}
>
<ResolutionContent
content={content}
exposedData={exposedData}
locale={getLocale(l10n)}
/>
</ResolutionContainer>
</FixView>
);
}

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

@ -0,0 +1,45 @@
/* 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 { it, expect } from "@jest/globals";
import { render } from "@testing-library/react";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import Meta, {
BankAccountStory,
CreditCardStory,
PinStory,
SsnStory,
} from "./HighRiskDataBreach.stories";
it("passes the axe accessibility test suite for credit card breaches", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(
CreditCardStory,
Meta
);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("passes the axe accessibility test suite for bank account breaches", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(
BankAccountStory,
Meta
);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("passes the axe accessibility test suite for SSN breaches", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(SsnStory, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("passes the axe accessibility test suite for PIN breaches", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(PinStory, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});

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

@ -0,0 +1,92 @@
/* 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 {
createRandomBreach,
createUserWithPremiumSubscription,
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { HighRiskBreachLayout } from "../HighRiskBreachLayout";
import { HighRiskBreachTypes } from "../highRiskBreachData";
import { BreachDataTypes } from "../../../../../../../../functions/universal/breach";
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
// Ensure all high-risk data breaches are present in at least one breach:
mockedBreaches.push(
createRandomBreach({
dataClasses: [
BreachDataTypes.SSN,
BreachDataTypes.CreditCard,
BreachDataTypes.BankAccount,
BreachDataTypes.PIN,
],
})
);
const user = createUserWithPremiumSubscription();
const mockedSession = {
expires: new Date().toISOString(),
user: user,
};
const HighRiskBreachWrapper = (props: { type: HighRiskBreachTypes }) => {
return (
<Shell
l10n={getEnL10nSync()}
session={mockedSession}
nonce=""
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<HighRiskBreachLayout
subscriberEmails={[]}
type={props.type}
data={{
countryCode: "nl",
latestScanData: { results: [], scan: null },
subscriberBreaches: mockedBreaches,
user: mockedSession.user,
}}
/>
</Shell>
);
};
const meta: Meta<typeof HighRiskBreachWrapper> = {
title: "Pages/Guided resolution/2. High-risk data breaches",
component: HighRiskBreachWrapper,
};
export default meta;
type Story = StoryObj<typeof HighRiskBreachWrapper>;
export const SsnStory: Story = {
name: "2a. Social Security Number",
args: {
type: "ssn",
},
};
export const CreditCardStory: Story = {
name: "2b. Credit card",
args: {
type: "credit-card",
},
};
export const BankAccountStory: Story = {
name: "2c. Bank account",
args: {
type: "bank-account",
},
};
export const PinStory: Story = {
name: "2d. PIN",
args: {
type: "pin",
},
};

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

@ -4,6 +4,7 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
import { HighRiskBreachLayout } from "../HighRiskBreachLayout";
import { authOptions } from "../../../../../../../../api/utils/auth";
@ -13,6 +14,10 @@ import {
HighRiskBreachTypes,
getHighRiskBreachesByType,
} from "../highRiskBreachData";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { getL10n } from "../../../../../../../../functions/server/l10n";
interface SecurityRecommendationsProps {
params: {
@ -27,6 +32,7 @@ export default async function SecurityRecommendations({
if (!session?.user?.subscriber?.id) {
return redirect("/");
}
const l10n = getL10n();
const breaches = await getSubscriberBreaches(session.user);
const subscriberEmails = await getSubscriberEmails(session.user);
const guidedExperienceBreaches = getGuidedExperienceBreaches(
@ -38,11 +44,27 @@ export default async function SecurityRecommendations({
const pageData = getHighRiskBreachesByType({
dataType: type,
breaches: guidedExperienceBreaches,
l10n: l10n,
});
if (!pageData) {
redirect("/redesign/user/dashboard");
}
return <HighRiskBreachLayout pageData={pageData} />;
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
return (
<HighRiskBreachLayout
subscriberEmails={subscriberEmails}
type={type}
data={{
countryCode: getCountryCode(headers()),
subscriberBreaches: breaches,
user: session.user,
latestScanData: scanData,
}}
/>
);
}

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

@ -3,7 +3,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import type { ReactNode } from "react";
import { getL10n } from "../../../../../../../functions/server/l10n";
import { SubscriberBreach } from "../../../../../../../../utils/subscriberBreaches";
import creditCardIllustration from "../images/high-risk-data-breach-credit-card.svg";
import socialSecurityNumberIllustration from "../images/high-risk-data-breach-ssn.svg";
@ -13,6 +12,7 @@ import noBreachesIllustration from "../images/high-risk-breaches-none.svg";
import { GuidedExperienceBreaches } from "../../../../../../../functions/server/getUserBreaches";
import { FraudAlertModal } from "./FraudAlertModal";
import { getLocale } from "../../../../../../../functions/universal/getLocale";
import { ExtendedReactLocalization } from "../../../../../../../hooks/l10n";
export type HighRiskBreachContent = {
summary: string;
@ -42,12 +42,12 @@ export type HighRiskBreach = {
function getHighRiskBreachesByType({
dataType,
breaches,
l10n,
}: {
dataType: HighRiskBreachTypes;
breaches: GuidedExperienceBreaches;
l10n: ExtendedReactLocalization;
}) {
const l10n = getL10n();
// TODO: Expose email list & count here https://mozilla-hub.atlassian.net/browse/MNTOR-2112
const emailsFormatter = new Intl.ListFormat(getLocale(l10n), {
style: "long",

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

@ -4,18 +4,24 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { HighRiskBreachLayout } from "./HighRiskBreachLayout";
import { authOptions } from "../../../../../../../api/utils/auth";
import { getSubscriberEmails } from "../../../../../../../functions/server/getSubscriberEmails";
import { getGuidedExperienceBreaches } from "../../../../../../../functions/universal/guidedExperienceBreaches";
import { getSubscriberBreaches } from "../../../../../../../functions/server/getUserBreaches";
import { getHighRiskBreachesByType } from "./highRiskBreachData";
import { getL10n } from "../../../../../../../functions/server/l10n";
import { getOnerepProfileId } from "../../../../../../../../db/tables/subscribers";
import { getLatestOnerepScanResults } from "../../../../../../../../db/tables/onerep_scans";
import { getCountryCode } from "../../../../../../../functions/server/getCountryCode";
export default async function HighRiskDataBreaches() {
const session = await getServerSession(authOptions);
if (!session?.user?.subscriber?.id) {
return redirect("/");
}
const l10n = getL10n();
const breaches = await getSubscriberBreaches(session.user);
const subscriberEmails = await getSubscriberEmails(session.user);
const guidedExperienceBreaches = getGuidedExperienceBreaches(
@ -26,16 +32,30 @@ export default async function HighRiskDataBreaches() {
const pageData = getHighRiskBreachesByType({
dataType: "none",
breaches: guidedExperienceBreaches,
l10n: l10n,
});
if (!pageData) {
redirect("/redesign/user/dashboard/fix/high-risk-data-breaches");
}
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
return (
<div>
{/* TODO: MNTOR-1700 Add routing logic here, currently default to no high risk breach data */}
<HighRiskBreachLayout pageData={pageData} />
<HighRiskBreachLayout
subscriberEmails={subscriberEmails}
type="none"
data={{
countryCode: getCountryCode(headers()),
subscriberBreaches: breaches,
user: session.user,
latestScanData: scanData,
}}
/>
</div>
);
}

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

@ -4,13 +4,8 @@
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../../../api/utils/auth";
import { getSubscriberBreaches } from "../../../../../../functions/server/getUserBreaches";
import { getSubscriberEmails } from "../../../../../../functions/server/getSubscriberEmails";
import { getGuidedExperienceBreaches } from "../../../../../../functions/universal/guidedExperienceBreaches";
import { redirect } from "next/navigation";
import { ReactNode } from "react";
import { FixView } from "./FixView";
import { getLatestOnerepScanResults } from "../../../../../../../db/tables/onerep_scans";
import { getOnerepProfileId } from "../../../../../../../db/tables/subscribers";
import { canSubscribeToPremium } from "../../../../../../functions/universal/user";
import { getCountryCode } from "../../../../../../functions/server/getCountryCode";
@ -21,12 +16,6 @@ export default async function Layout({ children }: { children: ReactNode }) {
if (!session?.user?.subscriber?.id) {
return redirect("/");
}
const breaches = await getSubscriberBreaches(session.user);
const subscriberEmails = await getSubscriberEmails(session.user);
const guidedExperience = getGuidedExperienceBreaches(
breaches,
subscriberEmails
);
const headersList = headers();
const countryCode = getCountryCode(headersList);
@ -39,12 +28,5 @@ export default async function Layout({ children }: { children: ReactNode }) {
return redirect("/redesign/user/welcome/");
}
const scanResult = await getLatestOnerepScanResults(profileId);
const scanResultItems = scanResult.results;
return (
<FixView breaches={guidedExperience} userScannedResults={scanResultItems}>
{children}
</FixView>
);
return <>{children}</>;
}

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

@ -1,60 +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 passwordIllustration from "../images/leaked-passwords.svg";
import securityQuestionsIllustration from "../images/security-questions.svg";
import { LeakedPasswordsLayout } from "./LeakedPasswordsLayout";
const meta: Meta<typeof LeakedPasswordsLayout> = {
title: "ResolutionLayout",
component: LeakedPasswordsLayout,
};
export default meta;
type Story = StoryObj<typeof LeakedPasswordsLayout>;
const summaryString = "It appeared in 2 data breaches:";
const recommendations = {
title: "Heres what to do",
steps: (
<ol>
<li>Recommendation one</li>
<li>Recommendation two</li>
<li>Recommendation three</li>
</ol>
),
};
const content = {
summary: summaryString,
description: (
<p>
Leaked passwords / Security questions leaked recommendation description
text.
</p>
),
recommendations,
};
export const Passwords: Story = {
args: {
pageData: {
type: "password",
title: "Passwords",
illustration: passwordIllustration,
content,
},
},
};
export const SecurityQuestions: Story = {
args: {
pageData: {
type: "security-question",
title: "Security questions",
illustration: securityQuestionsIllustration,
content,
},
},
};

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

@ -8,51 +8,90 @@ import { ResolutionContainer } from "../ResolutionContainer";
import { ResolutionContent } from "../ResolutionContent";
import { Button } from "../../../../../../../components/server/Button";
import { useL10n } from "../../../../../../../hooks/l10n";
import { LeakedPassword } from "./leakedPasswordsData";
import {
LeakedPasswordsTypes,
getLeakedPasswords,
} from "./leakedPasswordsData";
import Link from "next/link";
import { getLocale } from "../../../../../../../functions/universal/getLocale";
import {
StepDeterminationData,
StepLink,
getNextGuidedStep,
} from "../../../../../../../functions/server/getRelevantGuidedSteps";
import { FixView } from "../FixView";
import { getGuidedExperienceBreaches } from "../../../../../../../functions/universal/guidedExperienceBreaches";
export interface LeakedPasswordsLayoutProps {
label: string;
pageData: LeakedPassword;
type: LeakedPasswordsTypes;
subscriberEmails: string[];
data: StepDeterminationData;
}
export function LeakedPasswordsLayout({
pageData,
}: LeakedPasswordsLayoutProps) {
export function LeakedPasswordsLayout(props: LeakedPasswordsLayoutProps) {
const l10n = useL10n();
const { title, illustration, content } = pageData;
const stepMap: Record<LeakedPasswordsTypes, StepLink["id"]> = {
password: "LeakedPasswordsPassword",
"security-question": "LeakedPasswordsSecurityQuestion",
};
const guidedExperienceBreaches = getGuidedExperienceBreaches(
props.data.subscriberBreaches,
props.subscriberEmails
);
const pageData = getLeakedPasswords({
dataType: props.type,
breaches: guidedExperienceBreaches,
l10n: l10n,
});
// The non-null assertion here should be safe since we already did this check
// in `./[type]/page.tsx`:
const { title, illustration, content } = pageData!;
return (
<ResolutionContainer
type="leakedPasswords"
title={title}
illustration={illustration}
cta={
<>
<Button
variant="primary"
small
// TODO: Add test once MNTOR-1700 logic is added
/* c8 ignore next 3 */
onPress={() => {
// TODO: MNTOR-1700 Add routing logic
}}
autoFocus={true}
>
{l10n.getString("leaked-passwords-mark-as-fixed")}
</Button>
<Link
// TODO: Add test once MNTOR-1700 logic is added
href="/"
>
{l10n.getString("leaked-passwords-skip")}
</Link>
</>
<FixView
subscriberEmails={props.subscriberEmails}
data={props.data}
nextStepHref={
// In practice, there should always be a next step (at least "Done")
/* c8 ignore next */
getNextGuidedStep(props.data, stepMap[props.type])?.href ?? ""
}
estimatedTime={4}
currentSection="leaked-passwords"
>
<ResolutionContent content={content} locale={getLocale(l10n)} />
</ResolutionContainer>
<ResolutionContainer
type="leakedPasswords"
title={title}
illustration={illustration}
cta={
<>
<Button
variant="primary"
small
// TODO: Add test once MNTOR-1700 logic is added
/* c8 ignore next 3 */
onPress={() => {
// TODO: MNTOR-1700 Add routing logic
}}
autoFocus={true}
>
{l10n.getString("leaked-passwords-mark-as-fixed")}
</Button>
<Link
// TODO: Add test once MNTOR-1700 logic is added
href="/"
>
{l10n.getString("leaked-passwords-skip")}
</Link>
</>
}
estimatedTime={4}
>
<ResolutionContent content={content} locale={getLocale(l10n)} />
</ResolutionContainer>
</FixView>
);
}

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

@ -0,0 +1,73 @@
/* 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 {
createRandomBreach,
createUserWithPremiumSubscription,
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { LeakedPasswordsLayout } from "../LeakedPasswordsLayout";
import { LeakedPasswordsTypes } from "../leakedPasswordsData";
import { BreachDataTypes } from "../../../../../../../../functions/universal/breach";
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
// Ensure all leaked passwords data breaches are present in at least one breach:
mockedBreaches.push(
createRandomBreach({
dataClasses: [BreachDataTypes.Passwords, BreachDataTypes.SecurityQuestions],
})
);
const user = createUserWithPremiumSubscription();
const mockedSession = {
expires: new Date().toISOString(),
user: user,
};
const LeakedPasswordsWrapper = (props: { type: LeakedPasswordsTypes }) => {
return (
<Shell
l10n={getEnL10nSync()}
session={mockedSession}
nonce=""
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<LeakedPasswordsLayout
subscriberEmails={[]}
type={props.type}
data={{
countryCode: "nl",
latestScanData: { results: [], scan: null },
subscriberBreaches: mockedBreaches,
user: mockedSession.user,
}}
/>
</Shell>
);
};
const meta: Meta<typeof LeakedPasswordsWrapper> = {
title: "Pages/Guided resolution/3. Leaked passwords",
component: LeakedPasswordsWrapper,
};
export default meta;
type Story = StoryObj<typeof LeakedPasswordsWrapper>;
export const PasswordsStory: Story = {
name: "3a. Passwords",
args: {
type: "password",
},
};
export const SecurityQuestionsStory: Story = {
name: "3b. Security questions",
args: {
type: "security-question",
},
};

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

@ -7,19 +7,22 @@ import { it, expect } from "@jest/globals";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import Meta, {
Passwords,
SecurityQuestions,
} from "./LeakedPasswordsLayout.stories";
PasswordsStory,
SecurityQuestionsStory,
} from "./LeakedPasswords.stories";
it("leaked passwords component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(Passwords, Meta);
const ComposedHighRiskDataBreachComponent = composeStory(
PasswordsStory,
Meta
);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("security questions component passes the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(
SecurityQuestions,
SecurityQuestionsStory,
Meta
);
const { container } = render(<ComposedHighRiskDataBreachComponent />);

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

@ -4,17 +4,24 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { getSubscriberBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import { getGuidedExperienceBreaches } from "../../../../../../../../functions/universal/guidedExperienceBreaches";
import { getL10n } from "../../../../../../../../functions/server/l10n";
import { LeakedPasswordsLayout } from "../LeakedPasswordsLayout";
import { getLeakedPasswords } from "../leakedPasswordsData";
import {
LeakedPasswordsTypes,
getLeakedPasswords,
} from "../leakedPasswordsData";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { getL10n } from "../../../../../../../../functions/server/l10n";
interface LeakedPasswordsProps {
params: {
type: string;
type: LeakedPasswordsTypes;
};
}
@ -37,16 +44,27 @@ export default async function LeakedPasswords({
const pageData = getLeakedPasswords({
dataType: type,
breaches: guidedExperienceBreaches,
l10n: l10n,
});
if (!pageData) {
redirect("/redesign/user/dashboard");
}
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
return (
<LeakedPasswordsLayout
label={l10n.getString("security-recommendation-steps-label")}
pageData={pageData}
subscriberEmails={subscriberEmails}
type={type}
data={{
countryCode: getCountryCode(headers()),
subscriberBreaches: breaches,
user: session.user,
latestScanData: scanData,
}}
/>
);
}

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

@ -7,7 +7,7 @@ import passwordIllustration from "../images/leaked-passwords.svg";
import securityQuestionsIllustration from "../images/security-questions.svg";
import { SubscriberBreach } from "../../../../../../../../utils/subscriberBreaches";
import { GuidedExperienceBreaches } from "../../../../../../../functions/server/getUserBreaches";
import { getL10n } from "../../../../../../../functions/server/l10n";
import { ExtendedReactLocalization } from "../../../../../../../hooks/l10n";
export type LeakedPasswordsContent = {
summary: string;
@ -31,12 +31,12 @@ export type LeakedPassword = {
function getLeakedPasswords({
dataType,
breaches,
l10n,
}: {
dataType: string;
breaches: GuidedExperienceBreaches;
l10n: ExtendedReactLocalization;
}) {
const l10n = getL10n();
const findFirstUnresolvedBreach = (breachClassType: LeakedPasswordsTypes) => {
const leakedPasswordType =
breachClassType === "password" ? "passwords" : "securityQuestions";
@ -48,9 +48,14 @@ function getLeakedPasswords({
const unresolvedPasswordBreach = findFirstUnresolvedBreach("password");
const unresolvedSecurityQuestionsBreach =
findFirstUnresolvedBreach("security-question");
// This env var is always defined in test, so the other branch can't be covered:
/* c8 ignore next */
const blockList = (process.env.HIBP_BREACH_DOMAIN_BLOCKLIST ?? "").split(",");
const getBreachInfo = (breach?: SubscriberBreach) => ({
// Old code without tests for the case where `breach` is `undefined`
// (is that even possible?)
/* c8 ignore next 6 */
name: breach ? breach.name : "",
breachDate: breach ? breach.breachDate : "",
breachSite:

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

@ -1,72 +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 type { SecurityRecommendation } from "./securityRecommendationsData";
import { SubscriberBreach } from "../../../../../../../../utils/subscriberBreaches";
import { createRandomBreach } from "../../../../../../../../apiMocks/mockData";
import { SecurityRecommendationsLayout } from "./SecurityRecommendationsLayout";
import phoneIllustration from "../images/security-recommendations-phone.svg";
const meta: Meta<typeof SecurityRecommendationsLayout> = {
title: "SecurityRecommendationsLayout",
component: SecurityRecommendationsLayout,
};
export default meta;
type Story = StoryObj<typeof SecurityRecommendationsLayout>;
const scannedResultsArraySample: SubscriberBreach[] = Array.from(
{ length: 5 },
() => createRandomBreach({ isHighRiskOnly: true })
);
const pageDummyData: SecurityRecommendation = {
type: "phone",
title: "Dummy title",
illustration: phoneIllustration,
exposedData: scannedResultsArraySample,
content: {
summary: "It appeared in 2 data breaches:",
description: <p>Security recommendatino description text.</p>,
recommendations: {
title: "Here",
steps: (
<ol>
<li>Recommendation one</li>
<li>Recommendation two</li>
<li>Recommendation three</li>
</ol>
),
},
},
};
export const Layout: Story = {
args: {
label: "Security recommendations",
pageData: pageDummyData,
},
};
export const Phone: Story = {
args: {
label: "Security recommendations",
pageData: pageDummyData,
},
};
export const Email: Story = {
args: {
label: "Security recommendations",
pageData: { ...pageDummyData, type: "email" },
},
};
export const Ip: Story = {
args: {
label: "Security recommendations",
pageData: { ...pageDummyData, type: "ip" },
},
};

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

@ -4,51 +4,92 @@
"use client";
import type { SecurityRecommendation } from "./securityRecommendationsData";
import {
SecurityRecommendationTypes,
getSecurityRecommendationsByType,
} from "./securityRecommendationsData";
import { ResolutionContainer } from "../ResolutionContainer";
import { ResolutionContent } from "../ResolutionContent";
import { Button } from "../../../../../../../components/server/Button";
import { useL10n } from "../../../../../../../hooks/l10n";
import { getLocale } from "../../../../../../../functions/universal/getLocale";
import { FixView } from "../FixView";
import {
StepDeterminationData,
StepLink,
getNextGuidedStep,
} from "../../../../../../../functions/server/getRelevantGuidedSteps";
import { getGuidedExperienceBreaches } from "../../../../../../../functions/universal/guidedExperienceBreaches";
export interface SecurityRecommendationsLayoutProps {
label: string;
pageData: SecurityRecommendation;
type: SecurityRecommendationTypes;
subscriberEmails: string[];
data: StepDeterminationData;
}
export function SecurityRecommendationsLayout({
label,
pageData,
}: SecurityRecommendationsLayoutProps) {
export function SecurityRecommendationsLayout(
props: SecurityRecommendationsLayoutProps
) {
const l10n = useL10n();
const { title, illustration, content, exposedData } = pageData;
const stepMap: Record<SecurityRecommendationTypes, StepLink["id"]> = {
email: "SecurityTipsEmail",
ip: "SecurityTipsIp",
phone: "SecurityTipsPhone",
};
const guidedExperienceBreaches = getGuidedExperienceBreaches(
props.data.subscriberBreaches,
props.subscriberEmails
);
const pageData = getSecurityRecommendationsByType({
dataType: props.type,
breaches: guidedExperienceBreaches,
l10n: l10n,
});
// The non-null assertion here should be safe since we already did this check
// in `./[type]/page.tsx`:
const { title, illustration, content, exposedData } = pageData!;
return (
<ResolutionContainer
label={label}
type="securityRecommendations"
title={title}
illustration={illustration}
cta={
<Button
variant="primary"
small
// TODO: Add test once MNTOR-1700 logic is added
/* c8 ignore next 3 */
onPress={() => {
// TODO: MNTOR-1700 Add routing logic
}}
autoFocus={true}
>
{l10n.getString("security-recommendation-steps-cta-label")}
</Button>
<FixView
subscriberEmails={props.subscriberEmails}
data={props.data}
nextStepHref={
// In practice, there should always be a next step (at least "Done")
/* c8 ignore next */
getNextGuidedStep(props.data, stepMap[props.type])?.href ?? ""
}
currentSection="security-recommendations"
>
<ResolutionContent
content={content}
exposedData={exposedData}
locale={getLocale(l10n)}
/>
</ResolutionContainer>
<ResolutionContainer
label={l10n.getString("security-recommendation-steps-label")}
type="securityRecommendations"
title={title}
illustration={illustration}
cta={
<Button
variant="primary"
small
// TODO: Add test once MNTOR-1700 logic is added
/* c8 ignore next 3 */
onPress={() => {
// TODO: MNTOR-1700 Add routing logic
}}
autoFocus={true}
>
{l10n.getString("security-recommendation-steps-cta-label")}
</Button>
}
>
<ResolutionContent
content={content}
exposedData={exposedData}
locale={getLocale(l10n)}
/>
</ResolutionContainer>
</FixView>
);
}

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

@ -0,0 +1,86 @@
/* 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 {
createRandomBreach,
createUserWithPremiumSubscription,
} from "../../../../../../../../../apiMocks/mockData";
import { Shell } from "../../../../../../Shell";
import { getEnL10nSync } from "../../../../../../../../functions/server/mockL10n";
import { SecurityRecommendationsLayout } from "../SecurityRecommendationsLayout";
import { SecurityRecommendationTypes } from "../securityRecommendationsData";
import { BreachDataTypes } from "../../../../../../../../functions/universal/breach";
const mockedBreaches = [...Array(5)].map(() => createRandomBreach());
// Ensure all security recommendation data breaches are present in at least one breach:
mockedBreaches.push(
createRandomBreach({
dataClasses: [
BreachDataTypes.Phone,
BreachDataTypes.Email,
BreachDataTypes.IP,
],
})
);
const user = createUserWithPremiumSubscription();
const mockedSession = {
expires: new Date().toISOString(),
user: user,
};
const SecurityRecommendationsWrapper = (props: {
type: SecurityRecommendationTypes;
}) => {
return (
<Shell
l10n={getEnL10nSync()}
session={mockedSession}
nonce=""
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
>
<SecurityRecommendationsLayout
subscriberEmails={[]}
type={props.type}
data={{
countryCode: "nl",
latestScanData: { results: [], scan: null },
subscriberBreaches: mockedBreaches,
user: mockedSession.user,
}}
/>
</Shell>
);
};
const meta: Meta<typeof SecurityRecommendationsWrapper> = {
title: "Pages/Guided resolution/4. Security recommendations",
component: SecurityRecommendationsWrapper,
};
export default meta;
type Story = StoryObj<typeof SecurityRecommendationsWrapper>;
export const PhoneStory: Story = {
name: "4a. Phone number",
args: {
type: "phone",
},
};
export const EmailStory: Story = {
name: "4b. Email address",
args: {
type: "email",
},
};
export const IpStory: Story = {
name: "4c. IP address",
args: {
type: "ip",
},
};

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

@ -6,22 +6,26 @@ import { render } from "@testing-library/react";
import { it, expect } from "@jest/globals";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import Meta, { Phone, Email, Ip } from "./SecurityRecommendations.stories";
import Meta, {
EmailStory,
IpStory,
PhoneStory,
} from "./SecurityRecommendations.stories";
it("Phone security recommendations the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(Phone, Meta);
const ComposedHighRiskDataBreachComponent = composeStory(PhoneStory, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("Email security recommendations the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(Email, Meta);
const ComposedHighRiskDataBreachComponent = composeStory(EmailStory, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});
it("IP security recommendations the axe accessibility test suite", async () => {
const ComposedHighRiskDataBreachComponent = composeStory(Ip, Meta);
const ComposedHighRiskDataBreachComponent = composeStory(IpStory, Meta);
const { container } = render(<ComposedHighRiskDataBreachComponent />);
expect(await axe(container)).toHaveNoViolations();
});

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

@ -4,17 +4,24 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { SecurityRecommendationsLayout } from "../SecurityRecommendationsLayout";
import { getSecurityRecommendationsByType } from "../securityRecommendationsData";
import {
SecurityRecommendationTypes,
getSecurityRecommendationsByType,
} from "../securityRecommendationsData";
import { authOptions } from "../../../../../../../../api/utils/auth";
import { getSubscriberBreaches } from "../../../../../../../../functions/server/getUserBreaches";
import { getSubscriberEmails } from "../../../../../../../../functions/server/getSubscriberEmails";
import { getGuidedExperienceBreaches } from "../../../../../../../../functions/universal/guidedExperienceBreaches";
import { getCountryCode } from "../../../../../../../../functions/server/getCountryCode";
import { getOnerepProfileId } from "../../../../../../../../../db/tables/subscribers";
import { getLatestOnerepScanResults } from "../../../../../../../../../db/tables/onerep_scans";
import { getL10n } from "../../../../../../../../functions/server/l10n";
interface SecurityRecommendationsProps {
params: {
type: string;
type: SecurityRecommendationTypes;
};
}
@ -37,16 +44,27 @@ export default async function SecurityRecommendations({
const pageData = getSecurityRecommendationsByType({
dataType: type,
breaches: guidedExperienceBreaches,
l10n: l10n,
});
if (!pageData) {
redirect("/redesign/user/dashboard");
}
const result = await getOnerepProfileId(session.user.subscriber.id);
const profileId = result[0]["onerep_profile_id"] as number;
const scanData = await getLatestOnerepScanResults(profileId);
return (
<SecurityRecommendationsLayout
label={l10n.getString("security-recommendation-steps-label")}
pageData={pageData}
subscriberEmails={subscriberEmails}
type={type}
data={{
countryCode: getCountryCode(headers()),
subscriberBreaches: breaches,
user: session.user,
latestScanData: scanData,
}}
/>
);
}

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

@ -6,9 +6,9 @@ import type { ReactNode } from "react";
import emailIllustration from "../images/security-recommendations-email.svg";
import phoneIllustration from "../images/security-recommendations-phone.svg";
import ipIllustration from "../images/security-recommendations-ip.svg";
import { getL10n } from "../../../../../../../functions/server/l10n";
import { GuidedExperienceBreaches } from "../../../../../../../functions/server/getUserBreaches";
import { SubscriberBreach } from "../../../../../../../../utils/subscriberBreaches";
import { ExtendedReactLocalization } from "../../../../../../../hooks/l10n";
export type SecurityRecommendationContent = {
summary: string;
@ -33,12 +33,12 @@ export type SecurityRecommendation = {
function getSecurityRecommendationsByType({
dataType,
breaches,
l10n,
}: {
dataType: string;
breaches: GuidedExperienceBreaches;
l10n: ExtendedReactLocalization;
}) {
const l10n = getL10n();
const securityRecommendationsData: SecurityRecommendation[] = [
{
type: "phone",

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

@ -6,114 +6,181 @@
import Image from "next/image";
import styles from "./FixNavigation.module.scss";
import { useState } from "react";
import stepDataBrokerProfilesIcon from "../../(proper_react)/redesign/(authenticated)/user/dashboard/fix/images/step-counter-data-broker-profiles.svg";
import stepHighRiskDataBreachesIcon from "../../(proper_react)/redesign/(authenticated)/user/dashboard/fix/images/step-counter-high-risk.svg";
import stepLeakedPasswordsIcon from "../../(proper_react)/redesign/(authenticated)/user/dashboard/fix/images/step-counter-leaked-passwords.svg";
import stepSecurityRecommendationsIcon from "../../(proper_react)/redesign/(authenticated)/user/dashboard/fix/images/step-counter-security-recommendations.svg";
import { useL10n } from "../../hooks/l10n";
type StepId =
| "dataBrokerProfiles"
| "highRiskDataBreaches"
| "leakedPasswords"
| "securityRecommendations";
import { StepDeterminationData } from "../../functions/server/getRelevantGuidedSteps";
import { getGuidedExperienceBreaches } from "../../functions/universal/guidedExperienceBreaches";
export type Props = {
navigationItems: Array<NavigationItem>;
pathname: string;
currentSection:
| "data-broker-profiles"
| "high-risk-data-breach"
| "leaked-passwords"
| "security-recommendations";
subscriberEmails: string[];
data: StepDeterminationData;
};
export const FixNavigation = (props: Props) => {
// TODO: Add logic to abstract away from hard-coded dataBrokerProfiles section
const [showDataBrokerProfiles] = useState(true);
const [currentStep, setCurrentStep] = useState<StepId>("dataBrokerProfiles");
if (!showDataBrokerProfiles) {
setCurrentStep("highRiskDataBreaches");
}
return (
<nav className={styles.stepsWrapper}>
<Steps
navigationItems={props.navigationItems}
showDataBrokerProfiles={showDataBrokerProfiles}
currentStep={currentStep}
pathname={props.pathname}
currentSection={props.currentSection}
subscriberEmails={props.subscriberEmails}
data={props.data}
/>
</nav>
);
};
export interface NavigationItem {
key: string;
labelStringId: string;
href: string;
status: string | number;
currentStepId: string;
imageId: string;
}
export const Steps = (props: {
showDataBrokerProfiles: boolean;
currentStep: StepId;
navigationItems: Array<NavigationItem>;
pathname: string;
currentSection: Props["currentSection"];
subscriberEmails: string[];
data: StepDeterminationData;
}) => {
const l10n = useL10n();
function calculateActiveProgressBarPosition(pathname: string) {
if (pathname === "/redesign/user/dashboard/fix/high-risk-data-breaches") {
return styles.beginHighRiskDataBreaches;
} else if (
pathname.startsWith(
"/redesign/user/dashboard/fix/high-risk-data-breaches"
)
) {
return styles.duringHighRiskDataBreaches;
} else if (pathname === "/redesign/user/dashboard/fix/leaked-passwords") {
return styles.beginLeakedPasswords;
} else if (
pathname.startsWith("/redesign/user/dashboard/fix/leaked-passwords")
) {
return styles.duringLeakedPasswords;
} else if (
pathname === "/redesign/user/dashboard/fix/security-recommendations"
) {
return styles.beginSecurityRecommendations;
} else {
return "";
}
}
const breachesByClassification = getGuidedExperienceBreaches(
props.data.subscriberBreaches,
props.subscriberEmails
);
const totalHighRiskBreaches = Object.values(
breachesByClassification.highRisk
).reduce((acc, array) => acc + array.length, 0);
const totalDataBrokerProfiles =
// No tests simulate the absence of scan data yet:
/* c8 ignore next */
props.data.latestScanData?.results.length ?? 0;
const totalPasswordBreaches = Object.values(
breachesByClassification.passwordBreaches
).reduce((acc, array) => acc + array.length, 0);
const totalSecurityRecommendations = Object.values(
breachesByClassification.securityRecommendations
).filter((value) => {
return value.length > 0;
}).length;
return (
<ul className={styles.steps}>
{props.navigationItems.map(
({ key, labelStringId, href, imageId, status }) => (
<li
key={key}
aria-current={props.pathname.includes(href) ? "step" : undefined}
className={`${styles.navigationItem} ${
props.pathname.includes(href) ? styles.active : ""
}`}
>
<div className={styles.stepIcon}>
<Image src={imageId} alt="" width={22} height={22} />
{/* // TODO: Add logic to mark icon as checked when step is complete */}
{/* <CheckIcon className={styles.checkIcon} alt="" width={22} height={22} /> */}
</div>
<li
aria-current={
props.currentSection === "data-broker-profiles" ? "step" : undefined
}
className={`${styles.navigationItem} ${
props.currentSection === "data-broker-profiles" ? styles.active : ""
}`}
>
<div className={styles.stepIcon}>
<Image
src={stepDataBrokerProfilesIcon}
alt=""
width={22}
height={22}
/>
{/* // TODO: Add logic to mark icon as checked when step is complete */}
{/* <CheckIcon className={styles.checkIcon} alt="" width={22} height={22} /> */}
</div>
<div className={styles.stepLabel}>
{l10n.getString(labelStringId)} ({status})
</div>
</li>
)
)}
<div className={styles.stepLabel}>
{l10n.getString("fix-flow-nav-data-broker-profiles")} (
{totalDataBrokerProfiles})
</div>
</li>
<li
aria-current={
props.currentSection === "high-risk-data-breach" ? "step" : undefined
}
className={`${styles.navigationItem} ${
props.currentSection === "high-risk-data-breach" ? styles.active : ""
}`}
>
<div className={styles.stepIcon}>
<Image
src={stepHighRiskDataBreachesIcon}
alt=""
width={22}
height={22}
/>
{/* // TODO: Add logic to mark icon as checked when step is complete */}
{/* <CheckIcon className={styles.checkIcon} alt="" width={22} height={22} /> */}
</div>
<div className={styles.stepLabel}>
{l10n.getString("fix-flow-nav-high-risk-data-breaches")} (
{totalHighRiskBreaches})
</div>
</li>
<li
aria-current={
props.currentSection === "leaked-passwords" ? "step" : undefined
}
className={`${styles.navigationItem} ${
props.currentSection === "leaked-passwords" ? styles.active : ""
}`}
>
<div className={styles.stepIcon}>
<Image src={stepLeakedPasswordsIcon} alt="" width={22} height={22} />
{/* // TODO: Add logic to mark icon as checked when step is complete */}
{/* <CheckIcon className={styles.checkIcon} alt="" width={22} height={22} /> */}
</div>
<div className={styles.stepLabel}>
{l10n.getString("fix-flow-nav-leaked-passwords")} (
{totalPasswordBreaches})
</div>
</li>
<li
aria-current={
props.currentSection === "security-recommendations"
? "step"
: undefined
}
className={`${styles.navigationItem} ${
props.currentSection === "security-recommendations"
? styles.active
: ""
}`}
>
<div className={styles.stepIcon}>
<Image
src={stepSecurityRecommendationsIcon}
alt=""
width={22}
height={22}
/>
{/* // TODO: Add logic to mark icon as checked when step is complete */}
{/* <CheckIcon className={styles.checkIcon} alt="" width={22} height={22} /> */}
</div>
<div className={styles.stepLabel}>
{l10n.getString("fix-flow-nav-security-recommendations")} (
{totalSecurityRecommendations})
</div>
</li>
<li className={styles.progressBarLineContainer}>
<div className={styles.progressBarLineWrapper}>
<div
className={`${
styles.activeProgressBarLine
} ${calculateActiveProgressBarPosition(props.pathname)}`}
} ${calculateActiveProgressBarPosition(props.currentSection)}`}
></div>
</div>
</li>
</ul>
);
};
function calculateActiveProgressBarPosition(section: Props["currentSection"]) {
if (section === "high-risk-data-breach") {
return styles.beginHighRiskDataBreaches;
} else if (section === "leaked-passwords") {
return styles.beginLeakedPasswords;
} else if (section === "security-recommendations") {
return styles.beginSecurityRecommendations;
} else {
return "";
}
}

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

@ -0,0 +1,9 @@
/* 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 { getEnL10nBundlesInNodeContext, getEnL10nSync } from "../mockL10n";
export const getL10nBundles = getEnL10nBundlesInNodeContext;
export const getL10n = getEnL10nSync;

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

@ -7,7 +7,7 @@ import { LatestOnerepScanData } from "../../../db/tables/onerep_scans";
import { SubscriberBreach } from "../../../utils/subscriberBreaches";
import { BreachDataTypes, HighRiskDataTypes } from "../universal/breach";
export type InputData = {
export type StepDeterminationData = {
user: Session["user"];
countryCode: string;
latestScanData: LatestOnerepScanData | null;
@ -34,7 +34,7 @@ export const stepLinks = [
id: "HighRiskBankAccount",
},
{
href: "/redesign/user/dashboard/fix/high-risk-data-breaches/pin-number",
href: "/redesign/user/dashboard/fix/high-risk-data-breaches/pin",
id: "HighRiskPin",
},
{
@ -69,11 +69,6 @@ export type StepLinkWithStatus = (typeof stepLinks)[number] & {
completed: boolean;
};
export type OutputData = {
current: StepLink | null;
skipTarget: StepLink | null;
};
export function isGuidedResolutionInProgress(stepId: StepLink["id"]) {
const inProgressStepIds = stepLinks
.filter((step) => step.id !== "Scan" && step.id !== "Done")
@ -82,7 +77,7 @@ export function isGuidedResolutionInProgress(stepId: StepLink["id"]) {
}
export function getNextGuidedStep(
data: InputData,
data: StepDeterminationData,
afterStep?: StepLink["id"]
): StepLink | null {
// Resisting the urge to add a state machine... ^.^
@ -93,15 +88,19 @@ export function getNextGuidedStep(
return stepLink.eligible && !stepLink.completed;
});
// In practice, there should always be a next step (at least "Done")
/* c8 ignore next */
return nextStep ?? null;
}
export function getGuidedStepStatuses(data: InputData): StepLinkWithStatus[] {
export function getGuidedStepStatuses(
data: StepDeterminationData
): StepLinkWithStatus[] {
return stepLinks.map((stepLink) => getStepWithStatus(data, stepLink));
}
function getStepWithStatus(
data: InputData,
data: StepDeterminationData,
stepLink: StepLink
): StepLinkWithStatus {
return {
@ -111,7 +110,10 @@ function getStepWithStatus(
};
}
function isEligibleFor(data: InputData, stepId: StepLink["id"]): boolean {
function isEligibleFor(
data: StepDeterminationData,
stepId: StepLink["id"]
): boolean {
if (stepId === "Scan") {
return data.countryCode === "us";
}
@ -157,7 +159,10 @@ function isEligibleFor(data: InputData, stepId: StepLink["id"]): boolean {
return false as never;
}
function hasCompleted(data: InputData, stepId: StepLink["id"]): boolean {
function hasCompleted(
data: StepDeterminationData,
stepId: StepLink["id"]
): boolean {
if (stepId === "Scan") {
const hasRunScan =
typeof data.latestScanData?.scan === "object" &&