Port settings page to proper React
This commit is contained in:
Родитель
fdaad49e3f
Коммит
faeb16be71
|
@ -8,12 +8,9 @@ import type { Preview } from "@storybook/react";
|
|||
import { action } from "@storybook/addon-actions";
|
||||
import { linkTo } from "@storybook/addon-links";
|
||||
import "../src/app/globals.css";
|
||||
import { SessionProvider } from "../src/contextProviders/session";
|
||||
import { L10nProvider } from "../src/contextProviders/localization";
|
||||
import { metropolis } from "../src/app/fonts/Metropolis/metropolis";
|
||||
import { ReactAriaI18nProvider } from "../src/contextProviders/react-aria";
|
||||
import { getEnL10nBundlesSync } from "../src/app/functions/server/mockL10n";
|
||||
import { PublicEnvProvider } from "../src/contextProviders/public-env";
|
||||
import { TestComponentWrapper } from "../src/TestComponentWrapper";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
|
@ -30,19 +27,7 @@ const AppDecorator: Preview["decorators"] = (storyFn) => {
|
|||
document.body.classList.add(metropolis.variable);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<L10nProvider bundleSources={l10nBundles}>
|
||||
<PublicEnvProvider
|
||||
publicEnvs={{
|
||||
PUBLIC_APP_ENV: "storybook",
|
||||
}}
|
||||
>
|
||||
<SessionProvider session={null}>
|
||||
<ReactAriaI18nProvider locale="en">{storyFn()}</ReactAriaI18nProvider>
|
||||
</SessionProvider>
|
||||
</PublicEnvProvider>
|
||||
</L10nProvider>
|
||||
);
|
||||
return <TestComponentWrapper>{storyFn()}</TestComponentWrapper>;
|
||||
};
|
||||
|
||||
// Arguments to the `storySort` callback, left as documentation.
|
||||
|
|
|
@ -14,6 +14,10 @@ For tests concerning the UI in particular, you can get the most bang for your bu
|
|||
|
||||
Both of those measures ensure we won't have to update a whole bunch of unit tests unless we actually meaningfully change the behaviour for users, while still verifying the behaviour of a whole slew of components in one go.
|
||||
|
||||
The easiest way to do so is usually by mock rendering a Storybook story in your test, and simulating user actions that you'd also run inside Storybook. To find examples of tests that do so, search the codebase for `composeStory`.
|
||||
|
||||
If a page cannot be wrapped in a Storybook story (for example, because it calls [server actions](https://react.dev/reference/react/use-server), which Storybook [cannot ergonomically mock](https://storybook.js.org/blog/storybook-react-server-components/#mocked-and-loaded) at the time of writing), then you might need to render it directly inside your test. To ensure it doesn't trip over missing React Context variables while rendering, you'll probably want to wrap it in a `<TestComponentWrapper>` — again, search the codebase for it to find existing tests as examples.
|
||||
|
||||
## When is it OK not to add a test?
|
||||
|
||||
What follows is a list of some reasons not to write a unit test - your ignore comments can refer to these reasons. However, this list is not exhaustive! If you come across another valid reason not to test a bit of code, feel free to add that to this list and then reference it in your code.
|
||||
|
|
|
@ -19,4 +19,8 @@ add-email-address-input-label = Email address
|
|||
add-email-send-verification-button = Send verification link
|
||||
|
||||
# $email is the newly added email address. $settings-href is the URL for the Settings page. HTML tags should not be translated, e.g. `<a>`
|
||||
# This string will be deprecated when the new Plus plan is live.
|
||||
add-email-verify-the-link = Verify the link sent to { $email } to add it to { -brand-fx-monitor }. Manage all email addresses in <a { $settings-href }>Settings</a>.
|
||||
# Variables:
|
||||
# $email (string) - An email address submitted by the user for monitoring, e.g. `example@example.com`
|
||||
add-email-verify-the-link-2 = Verify the link sent to <b>{ $email }</b> to add it to { -brand-mozilla-monitor }.
|
||||
|
|
|
@ -47,6 +47,8 @@ user-add-duplicate-email = This email has already been added to { -product-name
|
|||
# $preferencesLink (String) - Link to preferences
|
||||
# $userEmail (String) - User email address
|
||||
user-add-duplicate-email-part-2 = Visit your { $preferencesLink } to check the status of { $userEmail }.
|
||||
user-add-verification-email-just-sent = Another verification email can’t be sent this quickly. Please try again later.
|
||||
user-add-unknown-error = Something went wrong adding another email address. Please try again later.
|
||||
|
||||
error-headline = Error
|
||||
user-verify-token-error = Verification token is required.
|
||||
|
|
|
@ -30,12 +30,17 @@ settings-email-limit-info =
|
|||
settings-email-verification-callout = Email verification required
|
||||
settings-resend-email-verification-link = Resend verification email
|
||||
settings-add-email-button = Add email address
|
||||
# Deprecated
|
||||
settings-delete-email-button = Delete email address
|
||||
settings-remove-email-button-label = Remove
|
||||
# Variables:
|
||||
# $emailAddress (string) - The email address to remove, e.g. `billnye@example.com`
|
||||
settings-remove-email-button-tooltip = Stop monitoring { $emailAddress }
|
||||
|
||||
# This string is shown beneath each of the user’s email addresses to indicate
|
||||
# how many known breaches that email address was found in.
|
||||
# Variables:
|
||||
# $breachCount (numer) - Number of breaches
|
||||
# $breachCount (number) - Number of breaches
|
||||
settings-email-number-of-breaches-info =
|
||||
{
|
||||
$breachCount ->
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/* 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 { ReactNode } from "react";
|
||||
import { L10nProvider } from "./contextProviders/localization";
|
||||
import { PublicEnvProvider } from "./contextProviders/public-env";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ReactAriaI18nProvider } from "./contextProviders/react-aria";
|
||||
import { getEnL10nBundlesSync } from "./app/functions/server/mockL10n";
|
||||
|
||||
const l10nBundles = getEnL10nBundlesSync();
|
||||
|
||||
export const TestComponentWrapper = (props: { children: ReactNode }) => {
|
||||
return (
|
||||
<L10nProvider bundleSources={l10nBundles}>
|
||||
<PublicEnvProvider
|
||||
publicEnvs={{
|
||||
PUBLIC_APP_ENV:
|
||||
/* c8 ignore next */
|
||||
process.env.STORYBOOK === "true" ? "storybook" : "test",
|
||||
}}
|
||||
>
|
||||
<SessionProvider session={null}>
|
||||
<ReactAriaI18nProvider locale="en">
|
||||
{props.children}
|
||||
</ReactAriaI18nProvider>
|
||||
</SessionProvider>
|
||||
</PublicEnvProvider>
|
||||
</L10nProvider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
@import "../../../../../../tokens";
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
h3 {
|
||||
font: $text-title-3xs;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.radioButton {
|
||||
stroke: $color-black;
|
||||
}
|
||||
|
||||
.focusRing {
|
||||
stroke: $color-blue-50;
|
||||
}
|
||||
|
||||
.selectedFill {
|
||||
fill: $color-black;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
/* 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 {
|
||||
AriaRadioProps,
|
||||
useFocusRing,
|
||||
useRadio,
|
||||
useRadioGroup,
|
||||
} from "react-aria";
|
||||
import { RadioGroupState, useRadioGroupState } from "react-stately";
|
||||
import styles from "./AlertAddressForm.module.scss";
|
||||
import { useL10n } from "../../../../../../hooks/l10n";
|
||||
import { createContext, useContext, useRef } from "react";
|
||||
import type { EmailUpdateCommOptionRequest } from "../../../../../../api/v1/user/update-comm-option/route";
|
||||
import { VisuallyHidden } from "../../../../../../components/server/VisuallyHidden";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export type AlertAddress = "primary" | "affected";
|
||||
|
||||
export type Props = {
|
||||
defaultSelected: AlertAddress;
|
||||
};
|
||||
|
||||
const AlertAddressContext = createContext<RadioGroupState | null>(null);
|
||||
|
||||
export const AlertAddressForm = (props: Props) => {
|
||||
const l10n = useL10n();
|
||||
const session = useSession();
|
||||
const state = useRadioGroupState({
|
||||
defaultValue: props.defaultSelected,
|
||||
onChange: (newValue) => {
|
||||
const chosenOption = newValue as AlertAddress;
|
||||
const body: EmailUpdateCommOptionRequest = {
|
||||
communicationOption: chosenOption === "primary" ? "1" : "0",
|
||||
};
|
||||
void fetch("/api/v1/user/update-comm-option", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}).then(() => {
|
||||
// Fetch a new token with up-to-date subscriber info - specifically,
|
||||
// with this setting updated.
|
||||
void session.update();
|
||||
});
|
||||
},
|
||||
});
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||
{ label: l10n.getString("settings-alert-preferences-title") },
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...radioGroupProps} className={styles.wrapper}>
|
||||
<h3 {...labelProps}>
|
||||
{l10n.getString("settings-alert-preferences-title")}
|
||||
</h3>
|
||||
<div className={styles.radioGroup}>
|
||||
<AlertAddressContext.Provider value={state}>
|
||||
<AlertAddressRadio value="affected">
|
||||
{l10n.getString("settings-alert-preferences-option-one")}
|
||||
</AlertAddressRadio>
|
||||
<AlertAddressRadio value="primary">
|
||||
{l10n.getString("settings-alert-preferences-option-two")}
|
||||
</AlertAddressRadio>
|
||||
</AlertAddressContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AlertAddressRadio = (props: AriaRadioProps & { value: AlertAddress }) => {
|
||||
const { children } = props;
|
||||
const state = useContext(AlertAddressContext);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { inputProps, isSelected } = useRadio(props, state!, inputRef);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
const strokeWidth = 2;
|
||||
|
||||
return (
|
||||
<label>
|
||||
<VisuallyHidden>
|
||||
<input {...inputProps} {...focusProps} ref={inputRef} />
|
||||
</VisuallyHidden>
|
||||
<svg width={24} height={24} aria-hidden="true" style={{ marginRight: 4 }}>
|
||||
<circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={8 - strokeWidth / 2}
|
||||
fill="none"
|
||||
className={`${styles.radioButton} ${
|
||||
isSelected ? styles.isSelected : styles.isUnselected
|
||||
}`}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{isSelected && (
|
||||
<circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={4}
|
||||
className={styles.selectedFill}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
{isFocusVisible && (
|
||||
<circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={11}
|
||||
fill="none"
|
||||
className={styles.focusRing}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
@import "../../../../../../tokens";
|
||||
|
||||
.dialogContents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.newEmailAddressForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid $color-grey-30;
|
||||
border-radius: $border-radius-sm;
|
||||
color: $color-black;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -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/. */
|
||||
|
||||
"use client";
|
||||
|
||||
import { useFormState } from "react-dom";
|
||||
import { useOverlayTriggerState } from "react-stately";
|
||||
import { useOverlayTrigger } from "react-aria";
|
||||
import Image from "next/image";
|
||||
import styles from "./EmailAddressAdder.module.scss";
|
||||
import Illustration from "./images/AddEmailDialogIllustration.svg";
|
||||
import { Button } from "../../../../../../components/client/Button";
|
||||
import { useL10n } from "../../../../../../hooks/l10n";
|
||||
import { ModalOverlay } from "../../../../../../components/client/dialog/ModalOverlay";
|
||||
import { Dialog } from "../../../../../../components/client/dialog/Dialog";
|
||||
import { onAddEmail } from "./actions";
|
||||
|
||||
export const EmailAddressAdder = () => {
|
||||
const l10n = useL10n();
|
||||
const dialogState = useOverlayTriggerState({ defaultOpen: false });
|
||||
const { triggerProps, overlayProps } = useOverlayTrigger(
|
||||
{ type: "dialog" },
|
||||
dialogState,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button {...triggerProps} variant="primary">
|
||||
{l10n.getString("settings-add-email-button")}
|
||||
</Button>
|
||||
{dialogState.isOpen && (
|
||||
<ModalOverlay
|
||||
state={dialogState}
|
||||
{...overlayProps}
|
||||
isDismissable={true}
|
||||
>
|
||||
<Dialog
|
||||
title={l10n.getString("add-email-add-another-heading")}
|
||||
illustration={<Image src={Illustration} alt="" />}
|
||||
// Unfortunately we're currently running into a bug testing code
|
||||
// that hits `useFormState`. See the comment for the test "calls the
|
||||
// 'add' action when adding another email address":
|
||||
/* c8 ignore next */
|
||||
onDismiss={() => dialogState.close()}
|
||||
>
|
||||
<div className={styles.dialogContents}>
|
||||
<EmailAddressAddForm />
|
||||
</div>
|
||||
</Dialog>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Unfortunately we're currently running into a bug testing code that hits
|
||||
// `useFormState`. See the comment for the test
|
||||
// "calls the 'add' action when adding another email address":
|
||||
/* c8 ignore start */
|
||||
const EmailAddressAddForm = () => {
|
||||
const l10n = useL10n();
|
||||
const [formState, formAction] = useFormState(onAddEmail, {});
|
||||
|
||||
return !formState.success ? (
|
||||
<>
|
||||
<p>
|
||||
{l10n.getString("add-email-your-account-includes", {
|
||||
total: process.env.NEXT_PUBLIC_MAX_NUM_ADDRESSES!,
|
||||
})}
|
||||
</p>
|
||||
<form action={formAction} className={styles.newEmailAddressForm}>
|
||||
<label htmlFor="newEmailAddress">
|
||||
{l10n.getString("add-email-address-input-label")}
|
||||
</label>
|
||||
<input type="email" name="newEmailAddress" id="newEmailAddress" />
|
||||
<Button type="submit" variant="primary">
|
||||
{l10n.getString("add-email-send-verification-button")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{l10n.getFragment("add-email-verify-the-link-2", {
|
||||
vars: { email: formState.submittedAddress },
|
||||
elems: { b: <b /> },
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
/* c8 ignore stop */
|
|
@ -0,0 +1,61 @@
|
|||
@import "../../../../../../tokens";
|
||||
|
||||
.emailListing {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
small {
|
||||
font: $text-body-sm;
|
||||
color: $color-grey-40;
|
||||
}
|
||||
|
||||
.verificationCallout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-xs;
|
||||
color: $color-red-60;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reverifyButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
color: $color-blue-50;
|
||||
|
||||
&:hover {
|
||||
color: $color-blue-70;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $color-grey-50;
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
background-color: transparent;
|
||||
border-style: none;
|
||||
border-radius: $border-radius-sm;
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $color-blue-50;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
/* 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 { useButton } from "react-aria";
|
||||
import { useRef, useState } from "react";
|
||||
import styles from "./EmailListing.module.scss";
|
||||
import { useL10n } from "../../../../../../hooks/l10n";
|
||||
import { onRemoveEmail } from "./actions";
|
||||
import { EmailRow } from "../../../../../../../db/tables/emailAddresses";
|
||||
import {
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
ErrorIcon,
|
||||
} from "../../../../../../components/server/Icons";
|
||||
|
||||
export const EmailListing = (props: {
|
||||
email: EmailRow | string;
|
||||
breachCount: number;
|
||||
}) => {
|
||||
const l10n = useL10n();
|
||||
const email = props.email;
|
||||
const emailAddress = isSecondaryEmail(email) ? email.email : email;
|
||||
const [isVerificationEmailResent, setIsVerificationEmailResent] =
|
||||
useState(false);
|
||||
const reverifyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const reverifyButtonProps = useButton(
|
||||
{
|
||||
isDisabled: isVerificationEmailResent,
|
||||
onPress: () => {
|
||||
// This should never get called in practice, since the button with this
|
||||
// event handler is only shown for secondary email addresses:
|
||||
/* c8 ignore next 3 */
|
||||
if (!isSecondaryEmail(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetch("/api/v1/user/resend-email", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/html", // set to request localized HTML email
|
||||
},
|
||||
mode: "same-origin",
|
||||
method: "POST",
|
||||
body: JSON.stringify({ emailId: email.id }),
|
||||
}).then((response) => {
|
||||
if (response.ok) {
|
||||
setIsVerificationEmailResent(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
reverifyButtonRef,
|
||||
).buttonProps;
|
||||
|
||||
return (
|
||||
<div className={styles.emailListing}>
|
||||
<div>
|
||||
<b>{emailAddress}</b>
|
||||
{isSecondaryEmail(email) && !email.verified ? (
|
||||
<>
|
||||
<span className={styles.verificationCallout}>
|
||||
<ErrorIcon alt="" />
|
||||
{l10n.getString("settings-email-verification-callout")}
|
||||
</span>
|
||||
<button
|
||||
{...reverifyButtonProps}
|
||||
ref={reverifyButtonRef}
|
||||
className={styles.reverifyButton}
|
||||
>
|
||||
{isVerificationEmailResent && <CheckIcon alt="" />}
|
||||
{l10n.getString("settings-resend-email-verification-link")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<small>
|
||||
{l10n.getString("settings-email-number-of-breaches-info", {
|
||||
breachCount: props.breachCount,
|
||||
})}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{isSecondaryEmail(email) && (
|
||||
<button
|
||||
title={l10n.getString("settings-remove-email-button-tooltip", {
|
||||
emailAddress: emailAddress,
|
||||
})}
|
||||
onClick={() => void onRemoveEmail(email)}
|
||||
>
|
||||
<DeleteIcon
|
||||
alt={l10n.getString("settings-remove-email-button-label")}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function isSecondaryEmail(email: string | EmailRow): email is EmailRow {
|
||||
return typeof email !== "string";
|
||||
}
|
|
@ -0,0 +1,370 @@
|
|||
/* 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 { axe } from "jest-axe";
|
||||
import { Session } from "next-auth";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { getEnL10nSync } from "../../../../../../functions/server/mockL10n";
|
||||
import { TestComponentWrapper } from "../../../../../../../TestComponentWrapper";
|
||||
import { EmailRow } from "../../../../../../../db/tables/emailAddresses";
|
||||
import { SerializedSubscriber } from "../../../../../../../next-auth";
|
||||
import { onAddEmail, onRemoveEmail } from "./actions";
|
||||
|
||||
const mockedSessionUpdate = jest.fn();
|
||||
jest.mock("next-auth/react", () => {
|
||||
return {
|
||||
useSession: () => {
|
||||
return {
|
||||
update: mockedSessionUpdate,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("../../../../../../hooks/useTelemetry");
|
||||
jest.mock("./actions", () => {
|
||||
return {
|
||||
onRemoveEmail: jest.fn(),
|
||||
onAddEmail: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { SettingsView } from "./View";
|
||||
|
||||
const subscriberId = 7;
|
||||
const mockedSubscriber: SerializedSubscriber = {
|
||||
id: subscriberId,
|
||||
all_emails_to_primary: true,
|
||||
} as SerializedSubscriber;
|
||||
const mockedUser: Session["user"] = {
|
||||
email: "primary@example.com",
|
||||
subscriber: mockedSubscriber,
|
||||
};
|
||||
const mockedSecondaryVerifiedEmail: EmailRow = {
|
||||
id: 1337,
|
||||
email: "secondary_verified@example.com",
|
||||
sha1: "arbitrary string",
|
||||
subscriber_id: subscriberId,
|
||||
verified: true,
|
||||
};
|
||||
const mockedSecondaryUnverifiedEmail: EmailRow = {
|
||||
id: 1337,
|
||||
email: "secondary_unverified@example.com",
|
||||
sha1: "arbitrary string",
|
||||
subscriber_id: subscriberId,
|
||||
verified: false,
|
||||
};
|
||||
|
||||
it("passes the axe accessibility audit", async () => {
|
||||
const { container } = render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={mockedUser}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
[mockedSecondaryUnverifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[
|
||||
mockedSecondaryVerifiedEmail,
|
||||
mockedSecondaryUnverifiedEmail,
|
||||
]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("preselects 'Send all breach alerts to the primary email address' if that's the user's current preference", () => {
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={{
|
||||
...mockedUser,
|
||||
subscriber: {
|
||||
...mockedUser.subscriber!,
|
||||
all_emails_to_primary: true,
|
||||
},
|
||||
}}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[mockedSecondaryVerifiedEmail]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const affectedRadioButton = screen.getByLabelText(
|
||||
"Send breach alerts to the affected email address",
|
||||
);
|
||||
const primaryRadioButton = screen.getByLabelText(
|
||||
"Send all breach alerts to the primary email address",
|
||||
);
|
||||
|
||||
expect(affectedRadioButton).not.toHaveAttribute("checked");
|
||||
expect(primaryRadioButton).toHaveAttribute("checked");
|
||||
});
|
||||
|
||||
it("preselects 'Send breach alerts to the affected email address' if that's the user's current preference", () => {
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={{
|
||||
...mockedUser,
|
||||
subscriber: {
|
||||
...mockedUser.subscriber!,
|
||||
all_emails_to_primary: false,
|
||||
},
|
||||
}}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[mockedSecondaryVerifiedEmail]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const affectedRadioButton = screen.getByLabelText(
|
||||
"Send breach alerts to the affected email address",
|
||||
);
|
||||
const primaryRadioButton = screen.getByLabelText(
|
||||
"Send all breach alerts to the primary email address",
|
||||
);
|
||||
|
||||
expect(affectedRadioButton).toHaveAttribute("checked");
|
||||
expect(primaryRadioButton).not.toHaveAttribute("checked");
|
||||
});
|
||||
|
||||
it("sends a call to the API to change the email alert preferences when changing the radio button values", async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={{
|
||||
...mockedUser,
|
||||
subscriber: {
|
||||
...mockedUser.subscriber!,
|
||||
all_emails_to_primary: true,
|
||||
},
|
||||
}}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[mockedSecondaryVerifiedEmail]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const affectedRadioButton = screen.getByLabelText(
|
||||
"Send breach alerts to the affected email address",
|
||||
);
|
||||
await user.click(affectedRadioButton);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/v1/user/update-comm-option", {
|
||||
body: JSON.stringify({ communicationOption: "0" }),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const primaryRadioButton = screen.getByLabelText(
|
||||
"Send all breach alerts to the primary email address",
|
||||
);
|
||||
await user.click(primaryRadioButton);
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/v1/user/update-comm-option", {
|
||||
body: JSON.stringify({ communicationOption: "1" }),
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the session token after changing email alert preferences, to ensure the latest pref is available in it", async () => {
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={{
|
||||
...mockedUser,
|
||||
subscriber: {
|
||||
...mockedUser.subscriber!,
|
||||
all_emails_to_primary: true,
|
||||
},
|
||||
}}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[mockedSecondaryVerifiedEmail]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const affectedRadioButton = screen.getByLabelText(
|
||||
"Send breach alerts to the affected email address",
|
||||
);
|
||||
await user.click(affectedRadioButton);
|
||||
|
||||
expect(mockedSessionUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks unverified email addresses as such", () => {
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={mockedUser}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
[mockedSecondaryUnverifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[
|
||||
mockedSecondaryVerifiedEmail,
|
||||
mockedSecondaryUnverifiedEmail,
|
||||
]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const verificationNotification = screen.getAllByText(
|
||||
"Email verification required",
|
||||
);
|
||||
|
||||
expect(verificationNotification).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls the API to resend a verification email if requested to", async () => {
|
||||
const user = userEvent.setup();
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: true });
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={mockedUser}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
[mockedSecondaryUnverifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[
|
||||
mockedSecondaryVerifiedEmail,
|
||||
mockedSecondaryUnverifiedEmail,
|
||||
]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const resendButton = screen.getByRole("button", {
|
||||
name: "Resend verification email",
|
||||
});
|
||||
await user.click(resendButton);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/v1/user/resend-email", {
|
||||
body: expect.stringContaining(
|
||||
`"emailId":${mockedSecondaryUnverifiedEmail.id}`,
|
||||
),
|
||||
headers: {
|
||||
Accept: "text/html",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
mode: "same-origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls the 'remove' action when clicking the rubbish bin icon", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={mockedUser}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
[mockedSecondaryUnverifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[
|
||||
mockedSecondaryVerifiedEmail,
|
||||
mockedSecondaryUnverifiedEmail,
|
||||
]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const removeButtons = screen.getAllByRole("button", { name: "Remove" });
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(onRemoveEmail).toHaveBeenCalledWith(mockedSecondaryVerifiedEmail);
|
||||
});
|
||||
|
||||
// This test doesn't currently work because, as soon as we click `addButton`,
|
||||
// Jest complains that `useFormState` "is not a function or its return value is
|
||||
// not iterable". It's unclear why that is, but as Server Actions get more
|
||||
// widely used, hopefully the community/Vercel comes up with a way to resolve:
|
||||
// https://stackoverflow.com/q/77705420
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip("calls the 'add' action when adding another email address", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TestComponentWrapper>
|
||||
<SettingsView
|
||||
l10n={getEnL10nSync()}
|
||||
user={mockedUser}
|
||||
breachCountByEmailAddress={{
|
||||
[mockedUser.email]: 42,
|
||||
[mockedSecondaryVerifiedEmail.email]: 42,
|
||||
[mockedSecondaryUnverifiedEmail.email]: 42,
|
||||
}}
|
||||
emailAddresses={[
|
||||
mockedSecondaryVerifiedEmail,
|
||||
mockedSecondaryUnverifiedEmail,
|
||||
]}
|
||||
fxaSettingsUrl=""
|
||||
yearlySubscriptionUrl=""
|
||||
monthlySubscriptionUrl=""
|
||||
/>
|
||||
</TestComponentWrapper>,
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole("button", { name: "Add email address" });
|
||||
await user.click(addButton);
|
||||
|
||||
const emailAddressInput = screen.getByLabelText("Email address");
|
||||
await user.type(emailAddressInput, "new_address@example.com[Enter]");
|
||||
|
||||
expect(onAddEmail).toHaveBeenCalledWith({}, "TODO");
|
||||
});
|
|
@ -0,0 +1,74 @@
|
|||
@import "../../../../../../tokens";
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $color-grey-05;
|
||||
}
|
||||
|
||||
.wrapper main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-2xl;
|
||||
padding: $spacing-2xl;
|
||||
|
||||
.title {
|
||||
flex: 0 0 $content-xs;
|
||||
|
||||
h2 {
|
||||
font: $text-title-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
|
||||
h3 {
|
||||
font: $text-title-3xs;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $color-grey-40;
|
||||
}
|
||||
|
||||
hr {
|
||||
align-self: stretch;
|
||||
border-style: none;
|
||||
border-top: 1px solid $color-grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emailList {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.deactivateSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
color: $color-blue-50;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
color: $color-blue-70;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Session } from "next-auth";
|
||||
import styles from "./View.module.scss";
|
||||
import { Toolbar } from "../../../../../../components/client/toolbar/Toolbar";
|
||||
import { ExtendedReactLocalization } from "../../../../../../hooks/l10n";
|
||||
import { EmailRow } from "../../../../../../../db/tables/emailAddresses";
|
||||
import { OpenInNew } from "../../../../../../components/server/Icons";
|
||||
import { EmailListing } from "./EmailListing";
|
||||
import { EmailAddressAdder } from "./EmailAddressAdder";
|
||||
import { AlertAddressForm } from "./AlertAddressForm";
|
||||
|
||||
export type Props = {
|
||||
l10n: ExtendedReactLocalization;
|
||||
user: Session["user"];
|
||||
monthlySubscriptionUrl: string;
|
||||
yearlySubscriptionUrl: string;
|
||||
fxaSettingsUrl: string;
|
||||
emailAddresses: EmailRow[];
|
||||
breachCountByEmailAddress: Record<string, number>;
|
||||
};
|
||||
|
||||
export const SettingsView = (props: Props) => {
|
||||
const l10n = props.l10n;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Toolbar
|
||||
user={props.user}
|
||||
monthlySubscriptionUrl={props.monthlySubscriptionUrl}
|
||||
yearlySubscriptionUrl={props.yearlySubscriptionUrl}
|
||||
fxaSettingsUrl={props.fxaSettingsUrl}
|
||||
/>
|
||||
<main>
|
||||
<header className={styles.title}>
|
||||
<h2>{l10n.getString("settings-page-title")}</h2>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<div>
|
||||
<h3>{l10n.getString("settings-email-list-title")}</h3>
|
||||
<p className={styles.description}>
|
||||
{l10n.getString("settings-email-limit-info", {
|
||||
limit: process.env.NEXT_PUBLIC_MAX_NUM_ADDRESSES!,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<ul className={styles.emailList}>
|
||||
<li key="primary">
|
||||
<EmailListing
|
||||
email={props.user.email}
|
||||
breachCount={props.breachCountByEmailAddress[props.user.email]}
|
||||
/>
|
||||
</li>
|
||||
{props.emailAddresses.map((emailAddress) => {
|
||||
return (
|
||||
<li key={emailAddress.email}>
|
||||
<EmailListing
|
||||
email={emailAddress}
|
||||
breachCount={
|
||||
props.breachCountByEmailAddress[emailAddress.email]
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<EmailAddressAdder />
|
||||
<hr />
|
||||
<AlertAddressForm
|
||||
defaultSelected={
|
||||
props.user.subscriber?.all_emails_to_primary
|
||||
? "primary"
|
||||
: "affected"
|
||||
}
|
||||
/>
|
||||
<hr />
|
||||
<div className={styles.deactivateSection}>
|
||||
<h3>{l10n.getString("settings-deactivate-account-title")}</h3>
|
||||
<p>{l10n.getString("settings-deactivate-account-info-2")}</p>
|
||||
<a
|
||||
href={props.fxaSettingsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{l10n.getString("settings-fxa-link-label-3")}
|
||||
<OpenInNew
|
||||
alt={l10n.getString("open-in-new-tab-alt")}
|
||||
width="13"
|
||||
height="13"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,153 @@
|
|||
/* 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 server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { SubscriberRow } from "knex/types/tables";
|
||||
import {
|
||||
EmailRow,
|
||||
addSubscriberUnverifiedEmailHash,
|
||||
removeOneSecondaryEmail,
|
||||
} from "../../../../../../../db/tables/emailAddresses";
|
||||
import {
|
||||
deleteResolutionsWithEmail,
|
||||
getSubscriberByEmail,
|
||||
} from "../../../../../../../db/tables/subscribers";
|
||||
import { validateEmailAddress } from "../../../../../../../utils/emailAddress";
|
||||
import { initEmail } from "../../../../../../../utils/email";
|
||||
import { sendVerificationEmail } from "../../../../../../api/utils/email";
|
||||
import { getL10n } from "../../../../../../functions/server/l10n";
|
||||
import { logger } from "../../../../../../functions/server/logging";
|
||||
|
||||
export type AddEmailFormState =
|
||||
| { success?: never }
|
||||
| { success: true; submittedAddress: string }
|
||||
| {
|
||||
success: false;
|
||||
error?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export async function onAddEmail(
|
||||
_prevState: AddEmailFormState,
|
||||
formData: FormData,
|
||||
): Promise<AddEmailFormState> {
|
||||
const l10n = getL10n();
|
||||
const session = await getServerSession();
|
||||
if (!session?.user.email) {
|
||||
return {
|
||||
success: false,
|
||||
error: "add-email-without-active-session",
|
||||
errorMessage: l10n.getString("user-add-invalid-email"),
|
||||
};
|
||||
}
|
||||
const subscriber = (await getSubscriberByEmail(
|
||||
session.user.email,
|
||||
)) as SubscriberRow | null;
|
||||
if (!subscriber) {
|
||||
return {
|
||||
success: false,
|
||||
error: "no-subscriber-data-found",
|
||||
errorMessage: l10n.getString("user-add-invalid-email"),
|
||||
};
|
||||
}
|
||||
const submittedAddress = formData.get("newEmailAddress");
|
||||
const validatedEmailAddress =
|
||||
typeof submittedAddress === "string"
|
||||
? validateEmailAddress(submittedAddress)
|
||||
: null;
|
||||
|
||||
if (validatedEmailAddress === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: "invalid-email",
|
||||
errorMessage: l10n.getString("user-add-invalid-email"),
|
||||
};
|
||||
}
|
||||
|
||||
const existingAddresses = [session.user.email]
|
||||
.concat(subscriber.email_addresses.map((emailRow) => emailRow.email))
|
||||
.map((address) => address.toLowerCase());
|
||||
if (
|
||||
existingAddresses.length >=
|
||||
Number.parseInt(process.env.NEXT_PUBLIC_MAX_NUM_ADDRESSES!, 10)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: "too-many-emails",
|
||||
errorMessage: l10n.getString("user-add-too-many-emails"),
|
||||
};
|
||||
}
|
||||
|
||||
if (existingAddresses.includes(validatedEmailAddress.email.toLowerCase())) {
|
||||
return {
|
||||
success: false,
|
||||
error: "address-already-added",
|
||||
errorMessage: l10n.getString("user-add-duplicate-email"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const unverifiedSubscriber = await addSubscriberUnverifiedEmailHash(
|
||||
subscriber,
|
||||
validatedEmailAddress.email,
|
||||
);
|
||||
|
||||
await initEmail();
|
||||
await sendVerificationEmail(
|
||||
// This first parameter is unused, so the type assertion is safe.
|
||||
// When non-TS invocations of this function are removed, we should remove
|
||||
// the unused parameter:
|
||||
session.user.subscriber as unknown as Parameters<
|
||||
typeof sendVerificationEmail
|
||||
>[0],
|
||||
unverifiedSubscriber.id,
|
||||
getL10n(),
|
||||
);
|
||||
revalidatePath("/redesign/user/settings/");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
submittedAddress: validatedEmailAddress.email,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "error-email-validation-pending") {
|
||||
return {
|
||||
success: false,
|
||||
error: "verification-email-just-sent",
|
||||
errorMessage: l10n.getString("user-add-verification-email-just-sent"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "unknown-error",
|
||||
errorMessage: l10n.getString("user-add-unknown-error"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRemoveEmail(email: EmailRow) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user.email) {
|
||||
logger.error(
|
||||
`Tried to delete email [${email.id}] without an active session.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const subscriber = (await getSubscriberByEmail(
|
||||
session.user.email,
|
||||
)) as SubscriberRow | null;
|
||||
if (email.subscriber_id !== subscriber?.id) {
|
||||
logger.error(
|
||||
`Subscriber [${subscriber?.id}] tried to delete email [${email.id}], which belongs to another subscriber.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await removeOneSecondaryEmail(email.id);
|
||||
await deleteResolutionsWithEmail(email.subscriber_id, email.email);
|
||||
revalidatePath("/redesign/user/settings/");
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<svg width="583" height="129" viewBox="0 0 583 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.99998 128C0.702732 126.061 -0.491329 123.733 0.999985 122C1.88131 120.976 2.93071 121.269 3.99998 122C5.06943 122.731 6.28807 123.805 6.99998 125C5.85746 122.239 4.08111 119.073 3.99998 116C3.91886 112.927 9.38583 101.184 26 114C29.6011 116.778 32.1978 125.497 45 128C44.6043 128 45.3956 129 45 129L1.99998 128Z" fill="#C9EFFD"/>
|
||||
<g opacity="0.5">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M440 88C442.335 84.3981 444.684 79.218 442 76C440.414 74.0981 437.925 73.6427 436 75C434.075 76.3573 432.281 78.7812 431 81C433.056 75.8732 434.854 70.7072 435 65C435.146 59.2928 426.905 37.1981 397 61C390.518 66.159 385.044 83.3517 362 88C362.712 88 361.288 89 362 89L440 88Z" fill="#C9EFFD"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M157 107C154.665 103.398 152.316 98.218 155 95C156.586 93.0981 159.075 92.6427 161 94C162.925 95.3573 164.719 97.7812 166 100C163.943 94.8732 162.146 89.7072 162 84C161.854 78.2928 170.095 56.1981 200 80C206.482 85.159 211.956 102.352 235 107C234.288 107 235.712 108 235 108L157 107Z" fill="#C9EFFD"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M581 120C582.557 117.599 583.79 114.145 582 112C580.942 110.732 579.283 111.095 578 112C576.717 112.905 575.854 114.521 575 116C576.371 112.582 577.903 108.805 578 105C578.097 101.195 571.937 86.1321 552 102C547.679 105.439 544.363 116.901 529 120C529.475 120 528.525 121 529 121L581 120Z" fill="#C9EFFD"/>
|
||||
<mask id="mask0_257_17184" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="223" y="23" width="149" height="83">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M223 23H372V106H223V23Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_257_17184)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M362 106H233C227.899 106 223 101.111 223 96V33C223 27.8889 227.899 23 233 23H362C367.101 23 372 27.8889 372 33V96C372 101.111 367.101 106 362 106Z" fill="#F770FF"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M300 67C294.974 67 290.434 65.9048 286 63L226 24C225.758 23.8416 225.843 23.2449 226 23C226.157 22.7548 226.758 22.8412 227 23L286 62C294.777 67.7501 305.424 67.0499 314 61L367 24C367.236 23.833 367.835 23.7604 368 24C368.165 24.2393 368.237 24.833 368 25L314 62C309.407 65.2397 305.326 67 300 67Z" fill="#26154D"/>
|
||||
<mask id="mask1_257_17184" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="339" y="0" width="56" height="57">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M339 0H395V57H339V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_257_17184)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M395 28C395 43.7401 382.464 57 367 57C351.536 57 339 43.7401 339 28C339 12.2601 351.536 0 367 0C382.464 0 395 12.2601 395 28Z" fill="#9059FF"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M363 38C363 36.6944 364.103 34.9114 365 34C365.897 33.0886 366.706 32 368 32C369.307 32 370.103 33.0886 371 34C371.897 34.9114 373 36.6944 373 38C373 39.3059 371.897 39.0883 371 40C370.103 40.9114 369.307 42 368 42C366.706 42 365.897 40.9114 365 40C364.103 39.0883 363 39.3059 363 38ZM367 31C366.616 31 367.391 30.42 367 30C366.609 29.58 366.512 27.9826 366 27C365.218 25.5002 364.41 24.5448 364 23C363.59 21.4552 363 19.4995 363 18C363 16.009 363.129 15.0991 364 14C364.871 12.9013 366.437 12 368 12C369.563 12 371.141 12.9013 372 14C372.858 15.0991 373 16.009 373 18C373 19.2281 373.275 20.7464 373 22C372.724 23.2543 371.551 24.6812 371 26C370.398 27.435 369.551 28.2891 369 29C368.449 29.7112 367.499 31 367 31Z" fill="white"/>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 3.5 KiB |
|
@ -0,0 +1,54 @@
|
|||
/* 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 { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "../../../../../../api/utils/auth";
|
||||
import { SettingsView } from "./View";
|
||||
import getPremiumSubscriptionUrl from "../../../../../../functions/server/getPremiumSubscriptionUrl";
|
||||
import { getL10n } from "../../../../../../functions/server/l10n";
|
||||
import { getUserEmails } from "../../../../../../../db/tables/emailAddresses";
|
||||
import { getBreaches } from "../../../../../../functions/server/getBreaches";
|
||||
import { getBreachesForEmail } from "../../../../../../../utils/hibp";
|
||||
import { getSha1 } from "../../../../../../../utils/fxa";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.subscriber?.id) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const emailAddresses = await getUserEmails(session.user.subscriber.id);
|
||||
|
||||
const monthlySubscriptionUrl = getPremiumSubscriptionUrl({ type: "monthly" });
|
||||
const yearlySubscriptionUrl = getPremiumSubscriptionUrl({ type: "yearly" });
|
||||
const fxaSettingsUrl = process.env.FXA_SETTINGS_URL!;
|
||||
|
||||
const allBreaches = await getBreaches();
|
||||
const breachCountByEmailAddress: Record<string, number> = {};
|
||||
const emailAddressStrings = emailAddresses.map(
|
||||
(emailAddress) => emailAddress.email,
|
||||
);
|
||||
emailAddressStrings.push(session.user.email);
|
||||
for (const emailAddress of emailAddressStrings) {
|
||||
const breaches = await getBreachesForEmail(
|
||||
getSha1(emailAddress),
|
||||
allBreaches,
|
||||
true,
|
||||
);
|
||||
breachCountByEmailAddress[emailAddress] = breaches.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsView
|
||||
l10n={getL10n()}
|
||||
user={session.user}
|
||||
emailAddresses={emailAddresses}
|
||||
breachCountByEmailAddress={breachCountByEmailAddress}
|
||||
fxaSettingsUrl={fxaSettingsUrl}
|
||||
monthlySubscriptionUrl={monthlySubscriptionUrl}
|
||||
yearlySubscriptionUrl={yearlySubscriptionUrl}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -99,7 +99,7 @@ export const MobileShell = (props: Props) => {
|
|||
>
|
||||
<div className={styles.mainMenu}>
|
||||
<ul>
|
||||
<li>
|
||||
<li key="home">
|
||||
<PageLink
|
||||
href="/redesign/user/dashboard"
|
||||
activeClassName={styles.isActive}
|
||||
|
@ -112,7 +112,15 @@ export const MobileShell = (props: Props) => {
|
|||
{l10n.getString("main-nav-link-dashboard-label")}
|
||||
</PageLink>
|
||||
</li>
|
||||
<li>
|
||||
<li key="settings">
|
||||
<PageLink
|
||||
href="/redesign/user/settings"
|
||||
activeClassName={styles.isActive}
|
||||
>
|
||||
{l10n.getString("main-nav-link-settings-label")}
|
||||
</PageLink>
|
||||
</li>
|
||||
<li key="faq">
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
title={l10n.getString("main-nav-link-faq-tooltip")}
|
||||
|
|
|
@ -53,7 +53,7 @@ export const Shell = (props: Props) => {
|
|||
</Link>
|
||||
<ul>
|
||||
{/* Note: If you add elements here, also add them to <MobileShell>'s navigation */}
|
||||
<li>
|
||||
<li key="home">
|
||||
<PageLink
|
||||
href="/redesign/user/dashboard"
|
||||
activeClassName={styles.isActive}
|
||||
|
@ -61,7 +61,15 @@ export const Shell = (props: Props) => {
|
|||
{l10n.getString("main-nav-link-dashboard-label")}
|
||||
</PageLink>
|
||||
</li>
|
||||
<li>
|
||||
<li key="settings">
|
||||
<PageLink
|
||||
href="/redesign/user/settings"
|
||||
activeClassName={styles.isActive}
|
||||
>
|
||||
{l10n.getString("main-nav-link-settings-label")}
|
||||
</PageLink>
|
||||
</li>
|
||||
<li key="faq">
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
title={l10n.getString("main-nav-link-faq-tooltip")}
|
||||
|
|
|
@ -67,6 +67,14 @@ export const authOptions: AuthOptions = {
|
|||
if (trigger === "update") {
|
||||
// Refresh the user data from FxA, in case e.g. new subscriptions got added:
|
||||
profile = await fetchUserInfo(token.subscriber?.fxa_access_token ?? "");
|
||||
if (token.email) {
|
||||
const updatedSubscriberData = await getSubscriberByEmail(token.email);
|
||||
// MNTOR-2599 The breach_resolution object can get pretty big,
|
||||
// causing the session token cookie to balloon in size,
|
||||
// eventually resulting in a 400 Bad Request due to headers being too large.
|
||||
delete updatedSubscriberData.breach_resolution;
|
||||
token.subscriber = updatedSubscriberData;
|
||||
}
|
||||
}
|
||||
if (profile) {
|
||||
token.fxa = {
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { getL10n } from "../../../../functions/server/l10n";
|
||||
|
||||
interface EmailDeleteRequest {
|
||||
emailId: string;
|
||||
emailId: number;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
|
|
@ -13,8 +13,9 @@ import {
|
|||
setAllEmailsToPrimary,
|
||||
} from "../../../../../db/tables/subscribers";
|
||||
|
||||
interface EmailUpdateCommOptionRequest {
|
||||
communicationOption: string;
|
||||
export interface EmailUpdateCommOptionRequest {
|
||||
/** "1" to send breach alerts to the primary email address, "0" to send them to the affected address */
|
||||
communicationOption: "0" | "1";
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
|
|
@ -255,6 +255,56 @@ export const QuestionMarkCircle = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Keywords: error, warning, exclamation mark
|
||||
// https://www.figma.com/file/olFHozlwrdYlCkZaG4B262/Nebula-Design-System-V2-(WIP)?type=design&node-id=109-1376
|
||||
export const ErrorIcon = ({
|
||||
alt,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { alt: string }) => {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
aria-hidden={alt === ""}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 14 14"
|
||||
{...props}
|
||||
className={`${props.className ?? ""} ${styles.colorifyFill}`}
|
||||
>
|
||||
<path d="M13.6929 11.0119L8.73994 1.10586C8.57386 0.773576 8.31852 0.494108 8.00254 0.298784C7.68656 0.10346 7.32242 0 6.95094 0C6.57946 0 6.21532 0.10346 5.89934 0.298784C5.58336 0.494108 5.32802 0.773576 5.16194 1.10586L0.20894 11.0159C0.0574972 11.3206 -0.0136719 11.659 0.00216778 11.999C0.0180075 12.3389 0.120332 12.6692 0.299458 12.9586C0.478583 13.2479 0.728584 13.4868 1.0258 13.6526C1.32302 13.8183 1.65762 13.9055 1.99794 13.9059H11.9029C12.2439 13.906 12.5792 13.819 12.8771 13.6531C13.1749 13.4872 13.4255 13.2479 13.6048 12.9579C13.7842 12.668 13.8864 12.337 13.9018 11.9964C13.9172 11.6557 13.8453 11.3169 13.6929 11.0119ZM5.95094 3.90586C5.95094 3.64065 6.0563 3.38629 6.24383 3.19876C6.43137 3.01122 6.68572 2.90586 6.95094 2.90586C7.21616 2.90586 7.47051 3.01122 7.65805 3.19876C7.84558 3.38629 7.95094 3.64065 7.95094 3.90586V7.90586C7.95094 8.17108 7.84558 8.42543 7.65805 8.61297C7.47051 8.80051 7.21616 8.90586 6.95094 8.90586C6.68572 8.90586 6.43137 8.80051 6.24383 8.61297C6.0563 8.42543 5.95094 8.17108 5.95094 7.90586V3.90586ZM6.95094 12.1559C6.70371 12.1559 6.46204 12.0826 6.25648 11.9452C6.05092 11.8078 5.8907 11.6126 5.79609 11.3842C5.70148 11.1558 5.67673 10.9045 5.72496 10.662C5.77319 10.4195 5.89224 10.1968 6.06706 10.022C6.24187 9.84717 6.4646 9.72811 6.70708 9.67988C6.94955 9.63165 7.20089 9.65641 7.42929 9.75101C7.6577 9.84562 7.85292 10.0058 7.99028 10.2114C8.12763 10.417 8.20094 10.6586 8.20094 10.9059C8.20094 11.2374 8.06924 11.5553 7.83482 11.7897C7.6004 12.0242 7.28246 12.1559 6.95094 12.1559Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Keywords: delete, remove, trash can, rubbish bin
|
||||
// https://www.figma.com/file/olFHozlwrdYlCkZaG4B262/Nebula-Design-System-V2-(WIP)?type=design&node-id=109-1398
|
||||
export const DeleteIcon = ({
|
||||
alt,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { alt: string }) => {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
aria-hidden={alt === ""}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
className={`${props.className ?? ""} ${styles.colorifyFill}`}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14.95 6H18a1 1 0 110 2v9a3 3 0 01-3 3h-5a3 3 0 01-3-3V8a1 1 0 110-2h3.05a2.5 2.5 0 014.9 0zm-1.059 0A1.489 1.489 0 0012.5 5a1.489 1.489 0 00-1.391 1h2.782zM16 17a1 1 0 01-1 1h-5a1 1 0 01-1-1V8h7v9zm-5-1.5a.5.5 0 01-1 0v-6a.5.5 0 011 0v6zm2 0a.5.5 0 01-1 0v-6a.5.5 0 011 0v6zm1.5.5a.5.5 0 00.5-.5v-6a.5.5 0 00-1 0v6a.5.5 0 00.5.5z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Keywords: close, exit, cross
|
||||
// https://www.figma.com/file/olFHozlwrdYlCkZaG4B262/Nebula-Design-System-V2-(WIP)?type=design&node-id=109-1385&t=kxEcvIQoafGdPZ3y-4
|
||||
export const CloseBtn = ({
|
||||
|
|
|
@ -30,7 +30,7 @@ async function getEmailByToken (token) {
|
|||
/* c8 ignore stop */
|
||||
|
||||
/**
|
||||
* @param {string} emailAddressId
|
||||
* @param {number} emailAddressId
|
||||
*/
|
||||
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
|
||||
/* c8 ignore start */
|
||||
|
@ -346,7 +346,7 @@ async function removeEmail (email) {
|
|||
/* c8 ignore stop */
|
||||
|
||||
/**
|
||||
* @param {string} emailId
|
||||
* @param {number} emailId
|
||||
*/
|
||||
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
|
||||
/* c8 ignore start */
|
||||
|
|
Загрузка…
Ссылка в новой задаче