Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add social login 2fa warning #2234

Merged
merged 3 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/desktop/src/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
/* istanbul ignore file */
import { DynamicModalContext, useDynamicModal } from "@umami/components";
import { useDataPolling } from "@umami/data-polling";
import { WalletClient, useImplicitAccounts, useResetBeaconConnections } from "@umami/state";
import {
WalletClient,
useCurrentAccount,
useImplicitAccounts,
useResetBeaconConnections,
} from "@umami/state";
import { noop } from "lodash";
import { useEffect } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";

import { AnnouncementBanner } from "./components/AnnouncementBanner";
import { SocialLoginWarningModal } from "./components/SocialLoginWarningModal/SocialLoginWarningModal";
import { BeaconProvider } from "./utils/beacon/BeaconProvider";
import { useDeeplinkHandler } from "./utils/useDeeplinkHandler";
import { AddressBookView } from "./views/addressBook/AddressBookView";
Expand All @@ -33,6 +39,18 @@ export const Router = () => {
const LoggedInRouterWithPolling = () => {
useDataPolling();
const modalDisclosure = useDynamicModal();
const currentUser = useCurrentAccount();

useEffect(() => {
if (currentUser?.type === "social") {
const isInformed = localStorage.getItem("user:isSocialLoginWarningShown");

if (!isInformed || !JSON.parse(isInformed)) {
void modalDisclosure.openWith(<SocialLoginWarningModal />, { closeOnEsc: false });
ajinkyaraj-23 marked this conversation as resolved.
Show resolved Hide resolved
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser]);

return (
<HashRouter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const VerifySeedphrase = ({
inputProps={{
paddingLeft: "36px",
size: "md",
height: "48px",
}}
listProps={{
marginTop: "6px",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { SocialLoginWarningModal } from "./SocialLoginWarningModal";
import {
act,
dynamicModalContextMock,
render,
screen,
userEvent,
waitFor,
} from "../../mocks/testUtils";

beforeEach(() => {
localStorage.clear();
});

describe("<SocialLoginWarningModal />", () => {
it("renders the modal with correct title and content", async () => {
render(<SocialLoginWarningModal />);

await waitFor(() => {
expect(screen.getByText("Important notice about your social account wallet")).toBeVisible();
});

expect(
screen.getByText(
"Wallets created with social accounts depend on those accounts to function. Losing access to this social account will result in loosing the wallet. Enable two-factor authentication to protect your social accounts."
)
).toBeVisible();
});

it("disables 'Continue' button when checkbox is not checked", () => {
render(<SocialLoginWarningModal />);

const button = screen.getByRole("button", { name: "Continue" });
expect(button).toBeDisabled();
});

it("enables 'Continue' button when checkbox is checked", async () => {
const user = userEvent.setup();
render(<SocialLoginWarningModal />);

const checkbox = screen.getByRole("checkbox", {
name: "I understand and accept the risks.",
});
await act(() => user.click(checkbox));

const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toBeEnabled();
});

it("sets localStorage and closes modal when 'Continue' is clicked", async () => {
const { onClose } = dynamicModalContextMock;
const user = userEvent.setup();
render(<SocialLoginWarningModal />);

const checkbox = screen.getByRole("checkbox", {
name: "I understand and accept the risks.",
});
await act(() => user.click(checkbox));

const continueButton = screen.getByRole("button", { name: "Continue" });
await act(() => user.click(continueButton));

await waitFor(() => {
expect(localStorage.getItem("user:isSocialLoginWarningShown")).toBe("true");
});

expect(onClose).toHaveBeenCalled();
});

it("toggles checkbox state correctly", async () => {
const user = userEvent.setup();
render(<SocialLoginWarningModal />);

const checkbox = screen.getByRole("checkbox", {
name: "I understand and accept the risks.",
});

expect(checkbox).not.toBeChecked();

await act(() => user.click(checkbox));
expect(checkbox).toBeChecked();

await act(() => user.click(checkbox));
expect(checkbox).not.toBeChecked();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
Button,
Checkbox,
Flex,
Heading,
Icon,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Text,
} from "@chakra-ui/react";
import { useDynamicModalContext } from "@umami/components";
import { useState } from "react";

import { WarningIcon } from "../../assets/icons";
import colors from "../../style/colors";

export const SocialLoginWarningModal = () => {
const { onClose } = useDynamicModalContext();
const [isAgreed, setIsAgreed] = useState(false);

const handleInform = () => {
localStorage.setItem("user:isSocialLoginWarningShown", "true");
onClose();
};

return (
<ModalContent>
<ModalHeader>
<Flex
alignItems="center"
justifyContent="center"
flexDirection="column"
gap="16px"
textAlign="center"
>
<Icon as={WarningIcon} width="22px" stroke={colors.gray[450]} />
<Heading size="xl">Important notice about your social account wallet</Heading>
</Flex>
</ModalHeader>
<ModalBody marginTop="24px">
<Flex alignItems="center" flexDirection="column" gap="16px" textAlign="center">
<Text color={colors.gray[400]} fontSize="md">
Wallets created with social accounts depend on those accounts to function. Losing access
to this social account will result in loosing the wallet. Enable two-factor
authentication to protect your social accounts.
</Text>
<Checkbox
marginTop="16px"
color={colors.gray[400]}
isChecked={isAgreed}
onChange={e => setIsAgreed(e.target.checked)}
>
<Text fontSize="sm" fontWeight="bold">
I understand and accept the risks.
</Text>
</Checkbox>
</Flex>
</ModalBody>
<ModalFooter>
<Button width="full" isDisabled={!isAgreed} onClick={handleInform} variant="primary">
Continue
</Button>
</ModalFooter>
</ModalContent>
);
};
8 changes: 4 additions & 4 deletions apps/desktop/src/setupTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ Object.defineProperties(global, {
fetch: { value: jest.fn(), writable: true },
});

Object.defineProperty(window, "localStorage", {
value: mockLocalStorage(),
});

beforeEach(() => {
// Add missing browser APIs
Object.defineProperties(global, {
Expand All @@ -79,6 +75,10 @@ beforeEach(() => {
// Hack for testing HashRouter: clears URL between tests.
window.location.hash = "";

Object.defineProperty(window, "localStorage", {
value: mockLocalStorage(),
});

setupJestCanvasMock();
});

Expand Down
53 changes: 46 additions & 7 deletions apps/web/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Grid, GridItem } from "@chakra-ui/react";
import { useDynamicModalContext } from "@umami/components";
import { useDataPolling } from "@umami/data-polling";
import { useCurrentAccount } from "@umami/state";
import { useEffect } from "react";

import { Footer } from "./components/Footer";
Expand All @@ -9,22 +10,60 @@ import { Main } from "./components/Main";
import { Navbar } from "./components/Navbar";
import { SecurityWarningModal } from "./components/SecurityWarningModal";
import { Sidebar } from "./components/Sidebar";
import { SocialLoginWarningModal } from "./components/SocialLoginWarningModal/SocialLoginWarningModal";

export const Layout = () => {
useDataPolling();
const { openWith } = useDynamicModalContext();
const currentUser = useCurrentAccount();

useEffect(() => {
const isInformed = localStorage.getItem("user:isExtensionsWarningShown");
const CLOSING_DELAY = 300;

if (!isInformed || !JSON.parse(isInformed)) {
// Trick to ensure the modal is rendered after the initial render
setTimeout(() => {
void openWith(<SecurityWarningModal />, { closeOnEsc: false, size: "xl" });
}, 500);
const warnings = [
{
key: "user:isSocialLoginWarningShown",
component: <SocialLoginWarningModal />,
options: { closeOnEsc: false },
isEnabled: () => currentUser?.type === "social",
},
{
key: "user:isExtensionsWarningShown",
component: <SecurityWarningModal />,
options: { closeOnEsc: false, size: "xl" },
isEnabled: () => true,
},
];

const warningsToShow = warnings.filter(warning => {
const isInformed = localStorage.getItem(warning.key);
return (!isInformed || !JSON.parse(isInformed)) && warning.isEnabled();
});

const showWarnings = async () => {
for (const warning of warningsToShow) {
await new Promise(
resolve =>
void openWith(warning.component, {
...warning.options,
onClose: () => {
localStorage.setItem(warning.key, "true");
resolve(true);
},
})
);

// Setting a delay to ensure the modal is properly closed before the next one is opened
await new Promise(resolve => setTimeout(resolve, CLOSING_DELAY));
}
};

if (warningsToShow.length > 0) {
// Immediate opening of the first modal causes freezes
setTimeout(() => void showWarnings(), 500);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [currentUser]);

return (
<Grid
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { SecurityWarningModal } from "./SecurityWarningModal";
import {
act,
dynamicModalContextMock,
renderInModal,
screen,
userEvent,
waitFor,
} from "../../testUtils";
import { act, renderInModal, screen, userEvent, waitFor } from "../../testUtils";

beforeEach(() => {
localStorage.clear();
Expand Down Expand Up @@ -55,31 +48,11 @@ describe("<SecurityWarningModal />", () => {
await renderInModal(<SecurityWarningModal />);

const checkbox = screen.getByRole("checkbox", {
name: "I have read and understood all security guidelines",
name: "I understand and accept the risks.",
});
await act(() => user.click(checkbox));

const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toBeEnabled();
});

it("sets localStorage and closes modal when 'Continue' is clicked", async () => {
const { onClose } = dynamicModalContextMock;
const user = userEvent.setup();
await renderInModal(<SecurityWarningModal />);

const checkbox = screen.getByRole("checkbox", {
name: "I have read and understood all security guidelines",
});
await act(() => user.click(checkbox));

const continueButton = screen.getByRole("button", { name: "Continue" });
await act(() => user.click(continueButton));

await waitFor(() => {
expect(localStorage.getItem("user:isExtensionsWarningShown")).toBe("true");
});

expect(onClose).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export const SecurityWarningModal = () => {
const [isAgreed, setIsAgreed] = useState(false);

const handleInform = () => {
localStorage.setItem("user:isExtensionsWarningShown", "true");
onClose();
};

Expand Down Expand Up @@ -128,10 +127,12 @@ export const SecurityWarningModal = () => {
marginX="auto"
onChange={e => setIsAgreed(e.target.checked)}
>
<Text whiteSpace="break-spaces">I have read and understood all security guidelines</Text>
<Text fontWeight="bold" whiteSpace="break-spaces">
I understand and accept the risks.
</Text>
</Checkbox>
</ModalBody>
<ModalFooter flexDirection="column" gap="16px">
<ModalFooter>
<Button width="full" isDisabled={!isAgreed} onClick={handleInform} variant="primary">
Continue
</Button>
Expand Down
Loading
Loading