Skip to content

Commit

Permalink
Merge pull request #3791 from mozilla/guided-resolution-fix-action
Browse files Browse the repository at this point in the history
Guided resolution fix actions
  • Loading branch information
flozia authored Nov 24, 2023
2 parents 3274cb6 + fd8634a commit d71e05a
Show file tree
Hide file tree
Showing 10 changed files with 365 additions and 122 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

"use client";

import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ResolutionContainer } from "../ResolutionContainer";
import { ResolutionContent } from "../ResolutionContent";
import { Button } from "../../../../../../../components/server/Button";
Expand All @@ -22,6 +24,8 @@ import {
} from "../../../../../../../functions/server/getRelevantGuidedSteps";
import { getGuidedExperienceBreaches } from "../../../../../../../functions/universal/guidedExperienceBreaches";
import { hasPremium } from "../../../../../../../functions/universal/user";
import { HighRiskDataTypes } from "../../../../../../../functions/universal/breach";
import { BreachBulkResolutionRequest } from "../../../../../../../(nextjs_migration)/(authenticated)/user/breaches/breaches";

export type HighRiskBreachLayoutProps = {
type: HighRiskBreachTypes;
Expand All @@ -31,6 +35,8 @@ export type HighRiskBreachLayoutProps = {

export function HighRiskBreachLayout(props: HighRiskBreachLayoutProps) {
const l10n = useL10n();
const router = useRouter();
const [isResolving, setIsResolving] = useState(false);

const stepMap: Record<HighRiskBreachTypes, StepLink["id"]> = {
ssn: "HighRiskSsn",
Expand All @@ -57,8 +63,63 @@ export function HighRiskBreachLayout(props: HighRiskBreachLayoutProps) {
// 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";
const isHighRiskBreachesStep = type !== "none";
const isStepDone = type === "done";
const hasExposedData = exposedData.length > 0;

// TODO: Write unit tests MNTOR-2560
/* c8 ignore start */
const handlePrimaryButtonPress = async () => {
const highRiskBreachClasses: Record<
HighRiskBreachTypes,
(typeof HighRiskDataTypes)[keyof typeof HighRiskDataTypes] | null
> = {
ssn: HighRiskDataTypes.SSN,
"credit-card": HighRiskDataTypes.CreditCard,
"bank-account": HighRiskDataTypes.BankAccount,
pin: HighRiskDataTypes.PIN,
none: null,
done: null,
};

const dataType = highRiskBreachClasses[type];
// Only attempt to resolve the breaches if the following conditions are true:
// - There is a matching data class type in this step
// - The current step has unresolved exposed data
// - There is no pending breach resolution request
if (!dataType || !hasExposedData || isResolving) {
return;
}

setIsResolving(true);
try {
const body: BreachBulkResolutionRequest = { dataType };
const response = await fetch("/api/v1/user/breaches/bulk-resolve", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

const result = await response.json();
if (!result?.success) {
throw new Error(
`Could not resolve breach data class of type: ${props.type}`,
);
}

const isCurrentStepSection = Object.values(stepMap).includes(nextStep.id);
const nextRoute = isCurrentStepSection
? nextStep.href
: "/redesign/user/dashboard/fix/high-risk-data-breaches/done";
router.push(nextRoute);
} catch (_error) {
// TODO: MNTOR-2563: Capture client error with @next/sentry
setIsResolving(false);
}
};
/* c8 ignore stop */

return (
<FixView
Expand All @@ -80,23 +141,22 @@ export function HighRiskBreachLayout(props: HighRiskBreachLayoutProps) {
<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
}}
autoFocus={true}
/* c8 ignore next */
onPress={() => void handlePrimaryButtonPress()}
disabled={isResolving}
>
{
// 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
isHighRiskBreachesStep
? l10n.getString("high-risk-breach-mark-as-fixed")
: l10n.getString("high-risk-breach-none-continue")
}
</Button>
{hasBreaches && (
{isHighRiskBreachesStep && (
<Link href={nextStep.href}>
{l10n.getString("high-risk-breach-skip")}
</Link>
Expand All @@ -107,7 +167,7 @@ export function HighRiskBreachLayout(props: HighRiskBreachLayoutProps) {
// 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:
estimatedTime={!isStepDone && hasBreaches ? 15 : undefined}
estimatedTime={!isStepDone && isHighRiskBreachesStep ? 15 : undefined}
isStepDone={isStepDone}
data={props.data}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import Meta, {
HighRiskBreachDoneStory,
} from "./HighRiskDataBreach.stories";

jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
usePathname: jest.fn(),
}));

beforeEach(() => {
setupJestCanvasMock();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getGuidedExperienceBreaches } from "../../../../../../../functions/univ
import { hasPremium } from "../../../../../../../functions/universal/user";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { LeakedPasswordsDataTypes } from "../../../../../../../functions/universal/breach";

export interface LeakedPasswordsLayoutProps {
type: LeakedPasswordsTypes;
Expand All @@ -37,6 +38,7 @@ export interface LeakedPasswordsLayoutProps {
export function LeakedPasswordsLayout(props: LeakedPasswordsLayoutProps) {
const l10n = useL10n();
const router = useRouter();
const [isResolving, setIsResolving] = useState(false);
const [subscriberBreaches, setSubscriberBreaches] = useState(
props.data.subscriberBreaches,
);
Expand All @@ -48,12 +50,13 @@ export function LeakedPasswordsLayout(props: LeakedPasswordsLayoutProps) {
"security-questions-done": "LeakedPasswordsSecurityQuestion",
none: "LeakedPasswordsSecurityQuestion",
};
const isStepDone =
props.type === "passwords-done" || props.type === "security-questions-done";

const guidedExperienceBreaches = getGuidedExperienceBreaches(
subscriberBreaches,
props.subscriberEmails,
);

const unresolvedPasswordBreach = findFirstUnresolvedBreach(
guidedExperienceBreaches,
props.type,
Expand All @@ -67,29 +70,39 @@ export function LeakedPasswordsLayout(props: LeakedPasswordsLayoutProps) {
) ?? "";

const nextStep = getNextGuidedStep(props.data, stepMap[props.type]);

// Data class string to push to resolutionsChecked array
const resolvedDataClassName =
props.type === "passwords" ? "passwords" : "security-questions-and-answers";

const isStepDone =
props.type === "passwords-done" || props.type === "security-questions-done";

const unresolvedPasswordBreachContent = getLeakedPasswords({
const pageData = getLeakedPasswords({
dataType: props.type,
breaches: guidedExperienceBreaches,
l10n,
emailAffected,
nextStep,
});

// The non-null assertion here should be safe since we already did this check
// in `./[type]/page.tsx`:
const { title, illustration, content } = pageData!;

const handleUpdateBreachStatus = async () => {
if (!unresolvedPasswordBreach || !emailAffected) {
const leakedPasswordsBreachClasses: Record<
LeakedPasswordsTypes,
| (typeof LeakedPasswordsDataTypes)[keyof typeof LeakedPasswordsDataTypes]
| null
> = {
passwords: LeakedPasswordsDataTypes.Passwords,
"passwords-done": null,
"security-questions": LeakedPasswordsDataTypes.SecurityQuestions,
"security-questions-done": null,
none: null,
};

const dataType = leakedPasswordsBreachClasses[props.type];
if (!dataType || !unresolvedPasswordBreach || !emailAffected) {
return;
}

setIsResolving(true);
try {
unresolvedPasswordBreach.resolvedDataClasses.push(resolvedDataClassName);
unresolvedPasswordBreach.resolvedDataClasses.push(dataType);
// FIXME/BUG: MNTOR-2562 Remove empty [""] string
const formattedDataClasses =
unresolvedPasswordBreach.resolvedDataClasses.filter(Boolean);
Expand All @@ -100,11 +113,11 @@ export function LeakedPasswordsLayout(props: LeakedPasswordsLayoutProps) {
formattedDataClasses,
);

// Manually move to the next step when mark is fixed is selected
// Manually move to the next step when breach has been marked as fixed.
const updatedSubscriberBreaches = subscriberBreaches.map(
(subscriberBreach) => {
if (subscriberBreach.id === unresolvedPasswordBreach.id) {
subscriberBreach.resolvedDataClasses.push(resolvedDataClassName);
subscriberBreach.resolvedDataClasses.push(dataType);
}
return subscriberBreach;
},
Expand All @@ -117,63 +130,64 @@ export function LeakedPasswordsLayout(props: LeakedPasswordsLayoutProps) {

if (!isComplete) {
setSubscriberBreaches(updatedSubscriberBreaches);
setIsResolving(false);
return;
}
// If all breaches in the step is fully resolved, take users to the next step
else {
router.push(nextStep.href);
}
} catch (error) {
console.error("Error updating breach status", error);
// If all breaches in the step are fully resolved,
// take users to the celebration view.
const doneSlug: LeakedPasswordsTypes =
props.type === "passwords"
? "passwords-done"
: "security-questions-done";
router.push(`/redesign/user/dashboard/fix/leaked-passwords/${doneSlug}`);
} catch (_error) {
// TODO: MNTOR-2563: Capture client error with @next/sentry
setIsResolving(false);
}
};
/* c8 ignore stop */

return (
unresolvedPasswordBreachContent &&
unresolvedPasswordBreach && (
<FixView
subscriberEmails={props.subscriberEmails}
<FixView
subscriberEmails={props.subscriberEmails}
data={props.data}
nextStep={nextStep}
currentSection="leaked-passwords"
hideProgressIndicator={isStepDone}
showConfetti={isStepDone}
>
<ResolutionContainer
type="leakedPasswords"
title={title}
illustration={illustration}
isPremiumUser={hasPremium(props.data.user)}
cta={
!isStepDone && (
<>
<Button
variant="primary"
small
/* c8 ignore next 3 */
onPress={() => {
void handleUpdateBreachStatus();
}}
autoFocus={true}
disabled={isResolving}
>
{l10n.getString("leaked-passwords-mark-as-fixed")}
</Button>
<Link href={nextStep.href}>
{l10n.getString("leaked-passwords-skip")}
</Link>
</>
)
}
estimatedTime={!isStepDone ? 4 : undefined}
isStepDone={isStepDone}
data={props.data}
nextStep={nextStep}
currentSection="leaked-passwords"
hideProgressIndicator={isStepDone}
showConfetti={isStepDone}
>
<ResolutionContainer
type="leakedPasswords"
title={unresolvedPasswordBreachContent.title}
illustration={unresolvedPasswordBreachContent.illustration}
isPremiumUser={hasPremium(props.data.user)}
cta={
!isStepDone && (
<>
<Button
variant="primary"
small
/* c8 ignore next 3 */
onPress={() => {
void handleUpdateBreachStatus();
}}
autoFocus={true}
>
{l10n.getString("leaked-passwords-mark-as-fixed")}
</Button>
<Link href={nextStep.href}>
{l10n.getString("leaked-passwords-skip")}
</Link>
</>
)
}
estimatedTime={!isStepDone ? 4 : undefined}
isStepDone={isStepDone}
data={props.data}
>
<ResolutionContent
content={unresolvedPasswordBreachContent.content}
locale={getLocale(l10n)}
/>
</ResolutionContainer>
</FixView>
)
<ResolutionContent content={content} locale={getLocale(l10n)} />
</ResolutionContainer>
</FixView>
);
}
Loading

0 comments on commit d71e05a

Please sign in to comment.