diff --git a/src/components/Export/components/ExportForm/components/ExportButton/exportButton.tsx b/src/components/Export/components/ExportForm/components/ExportButton/exportButton.tsx index 9d369ea9..013ed428 100644 --- a/src/components/Export/components/ExportForm/components/ExportButton/exportButton.tsx +++ b/src/components/Export/components/ExportForm/components/ExportButton/exportButton.tsx @@ -2,6 +2,7 @@ import { Tooltip } from "@mui/material"; import React, { ElementType, ReactNode } from "react"; import { useDownloadStatus } from "../../../../../../hooks/useDownloadStatus"; import { useFileManifestState } from "../../../../../../hooks/useFileManifestState"; +import { useLoginGuard } from "../../../../../../providers/loginGuard/hook"; import { ButtonPrimary } from "../../../../../common/Button/components/ButtonPrimary/buttonPrimary"; export interface ExportButtonProps { @@ -19,6 +20,10 @@ export const ExportButton = ({ const { fileManifestState: { isLoading }, } = useFileManifestState(); + + // Prompt user for login before export, if required. + const { requireLogin } = useLoginGuard(); + return ( @@ -26,7 +31,9 @@ export const ExportButton = ({ disabled={ isLoading || downloadStatus.disabled || downloadStatus.isLoading } - onClick={onClick} + onClick={() => { + requireLogin(onClick); + }} > {children} diff --git a/src/components/Export/components/ManifestDownload/components/ManifestDownloadEntity/components/FileManifestDownload/fileManifestDownload.tsx b/src/components/Export/components/ManifestDownload/components/ManifestDownloadEntity/components/FileManifestDownload/fileManifestDownload.tsx index e7244285..3a57d48d 100644 --- a/src/components/Export/components/ManifestDownload/components/ManifestDownloadEntity/components/FileManifestDownload/fileManifestDownload.tsx +++ b/src/components/Export/components/ManifestDownload/components/ManifestDownloadEntity/components/FileManifestDownload/fileManifestDownload.tsx @@ -10,6 +10,7 @@ import React, { useRef } from "react"; import { Filters } from "../../../../../../../../common/entities"; import { useDownloadStatus } from "../../../../../../../../hooks/useDownloadStatus"; import { useFileManifestDownload } from "../../../../../../../../hooks/useFileManifest/useFileManifestDownload"; +import { useLoginGuard } from "../../../../../../../../providers/loginGuard/hook"; import { ButtonGroup } from "../../../../../../../common/ButtonGroup/buttonGroup"; import { ButtonGroupButton } from "../../../../../../../common/ButtonGroup/components/ButtonGroupButton/buttonGroupButton"; import { @@ -46,6 +47,9 @@ export const FileManifestDownload = ({ const isInProgress = (isIdle || isLoading) && !disabled; const isReady = Boolean(manifestURL) || disabled; + // Prompt user for login before download and copy, if required. + const { requireLogin } = useLoginGuard(); + // Copies file manifest. const copyManifestURL = (url?: string): void => { if (!url) return; @@ -89,15 +93,19 @@ export const FileManifestDownload = ({ action="Download file manifest" disabled={disabled} label={} - onClick={downloadManifestURL} + onClick={() => + requireLogin(downloadManifestURL) + } />, } - onClick={(): void => - copyManifestURL(manifestURL) + onClick={() => + requireLogin((): void => + copyManifestURL(manifestURL) + ) } />, ]} diff --git a/src/components/Index/components/AzulFileDownload/azulFileDownload.tsx b/src/components/Index/components/AzulFileDownload/azulFileDownload.tsx index 722de90d..76b67925 100644 --- a/src/components/Index/components/AzulFileDownload/azulFileDownload.tsx +++ b/src/components/Index/components/AzulFileDownload/azulFileDownload.tsx @@ -1,6 +1,7 @@ import { Box } from "@mui/material"; import React, { Fragment, useEffect, useRef, useState } from "react"; import { useFileLocation } from "../../../../hooks/useFileLocation"; +import { useLoginGuard } from "../../../../providers/loginGuard/hook"; import { DownloadIcon } from "../../../common/CustomIcon/components/DownloadIcon/downloadIcon"; import { LoadingIcon } from "../../../common/CustomIcon/components/LoadingIcon/loadingIcon"; import { IconButton } from "../../../common/IconButton/iconButton"; @@ -29,6 +30,9 @@ export const AzulFileDownload = ({ const downloadRef = useRef(null); const [isRequestPending, setIsRequestPending] = useState(false); + // Prompt user for login before download, if required. + const { requireLogin } = useLoginGuard(); + // Initiates file download when file location request is successful. useEffect(() => { if (!fileUrl) return; @@ -39,6 +43,13 @@ export const AzulFileDownload = ({ setIsRequestPending(false); }, [fileUrl]); + // Initiates file download when download button is clicked. + const handleDownloadClick = (): void => { + setIsRequestPending(true); + trackFileDownloaded(entityName, relatedEntityId, relatedEntityName); + run(); + }; + return ( {isRequestPending ? ( @@ -54,11 +65,7 @@ export const AzulFileDownload = ({ data-testid={AZUL_FILE_REQUEST_DOWNLOAD_TEST_ID} disabled={!url} Icon={isLoading ? LoadingIcon : DownloadIcon} - onClick={(): void => { - setIsRequestPending(true); - trackFileDownloaded(entityName, relatedEntityId, relatedEntityName); - run(); - }} + onClick={() => requireLogin(handleDownloadClick)} size="medium" /> )} diff --git a/src/components/Login/components/Button/types.ts b/src/components/Login/components/Button/types.ts index 1fe03654..f28ee240 100644 --- a/src/components/Login/components/Button/types.ts +++ b/src/components/Login/components/Button/types.ts @@ -1,4 +1,4 @@ -import { ButtonProps } from "@mui/material/Button/Button"; +import { ButtonProps } from "@mui/material"; import { BaseComponentProps } from "../../../../theme/common/entities"; export type Props = BaseComponentProps & ButtonProps; diff --git a/src/components/Login/components/Buttons/buttons.tsx b/src/components/Login/components/Buttons/buttons.tsx new file mode 100644 index 00000000..309dcd30 --- /dev/null +++ b/src/components/Login/components/Buttons/buttons.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Button } from "../Button/button"; +import { Props } from "./types"; + +export const Buttons = ({ + className, + handleLogin, + providers = [], + ...props /* Mui ButtonProps */ +}: Props

): JSX.Element[] => { + return providers?.map((provider) => ( + + )); +}; diff --git a/src/components/Login/components/Buttons/types.ts b/src/components/Login/components/Buttons/types.ts new file mode 100644 index 00000000..66dd11be --- /dev/null +++ b/src/components/Login/components/Buttons/types.ts @@ -0,0 +1,9 @@ +import { ButtonProps } from "@mui/material"; +import { ClientSafeProvider } from "next-auth/react"; +import { OAuthProvider } from "../../../../config/entities"; +import { BaseComponentProps } from "../../../../theme/common/entities"; + +export interface Props

extends BaseComponentProps, ButtonProps { + handleLogin: (providerId: string) => void; + providers?: ClientSafeProvider[] | OAuthProvider

[]; +} diff --git a/src/components/Login/components/Section/components/Consent/consent.styles.ts b/src/components/Login/components/Section/components/Consent/consent.styles.ts new file mode 100644 index 00000000..32bae727 --- /dev/null +++ b/src/components/Login/components/Section/components/Consent/consent.styles.ts @@ -0,0 +1,15 @@ +import styled from "@emotion/styled"; +import { Grid2 } from "@mui/material"; + +export const StyledGrid2 = styled(Grid2)` + align-items: center; + align-self: flex-start; + display: flex; + gap: 12px; + + .MuiTypography-text-body-400 { + .MuiLink-root { + color: inherit; + } + } +`; diff --git a/src/components/Login/components/Section/components/Consent/consent.tsx b/src/components/Login/components/Section/components/Consent/consent.tsx new file mode 100644 index 00000000..c2d65e19 --- /dev/null +++ b/src/components/Login/components/Section/components/Consent/consent.tsx @@ -0,0 +1,30 @@ +import { Checkbox, Typography } from "@mui/material"; +import React from "react"; +import { TEXT_BODY_400 } from "../../../../../../theme/common/typography"; +import { CheckedIcon } from "../../../../../common/CustomIcon/components/CheckedIcon/checkedIcon"; +import { UncheckedErrorIcon } from "../../../../../common/CustomIcon/components/UncheckedErrorIcon/uncheckedErrorIcon"; +import { UncheckedIcon } from "../../../../../common/CustomIcon/components/UncheckedIcon/uncheckedIcon"; +import { BaseComponentProps } from "../../../../../types"; +import { StyledGrid2 } from "./consent.styles"; +import { ConsentProps } from "./types"; + +export const Consent = ({ + children, + className, + handleConsent, + isDisabled, + isError, + ...props /* Mui Grid2Props */ +}: BaseComponentProps & ConsentProps): JSX.Element | null => { + if (isDisabled) return null; + return ( + + } + icon={isError ? : } + onChange={handleConsent} + /> + {children} + + ); +}; diff --git a/src/components/Login/components/Section/components/Consent/types.ts b/src/components/Login/components/Section/components/Consent/types.ts new file mode 100644 index 00000000..b957f1b0 --- /dev/null +++ b/src/components/Login/components/Section/components/Consent/types.ts @@ -0,0 +1,10 @@ +import { Grid2Props } from "@mui/material"; +import { ReactNode } from "react"; +import { UseUserConsent } from "../../../../hooks/useUserConsent/types"; + +export interface ConsentProps + extends Grid2Props, + Pick, + Pick { + children: ReactNode; +} diff --git a/src/components/Login/components/Section/components/Warning/warning.tsx b/src/components/Login/components/Section/components/Warning/warning.tsx new file mode 100644 index 00000000..c1f6e7c2 --- /dev/null +++ b/src/components/Login/components/Section/components/Warning/warning.tsx @@ -0,0 +1,24 @@ +import { Typography, TypographyProps } from "@mui/material"; +import React from "react"; +import { COLOR } from "../../../../../../styles/common/mui/typography"; +import { TEXT_BODY_SMALL_400 } from "../../../../../../theme/common/typography"; +import { BaseComponentProps } from "../../../../../types"; + +export const Warning = ({ + children, + className, + ...props /* Mui TypographyOwnProps */ +}: BaseComponentProps & TypographyProps): JSX.Element | null => { + if (!children) return null; + return ( + + {children} + + ); +}; diff --git a/src/components/Login/hooks/useUserConsent/types.ts b/src/components/Login/hooks/useUserConsent/types.ts new file mode 100644 index 00000000..66adfc26 --- /dev/null +++ b/src/components/Login/hooks/useUserConsent/types.ts @@ -0,0 +1,11 @@ +import { ChangeEvent } from "react"; + +export interface UseUserConsent { + handleConsent: (e: ChangeEvent) => void; + handleError: (error: boolean) => void; + state: { + isDisabled: boolean; + isError: boolean; + isValid: boolean; + }; +} diff --git a/src/components/Login/hooks/useUserConsent/useUserConsent.ts b/src/components/Login/hooks/useUserConsent/useUserConsent.ts new file mode 100644 index 00000000..ea1a6ac1 --- /dev/null +++ b/src/components/Login/hooks/useUserConsent/useUserConsent.ts @@ -0,0 +1,32 @@ +import { ChangeEvent, useCallback, useState } from "react"; +import { useAuthenticationConfig } from "../../../../hooks/authentication/config/useAuthenticationConfig"; +import { UseUserConsent } from "./types"; + +export const useUserConsent = (): UseUserConsent => { + const authConfig = useAuthenticationConfig(); + const [isDisabled] = useState(Boolean(!authConfig?.termsOfService)); + const [isError, setIsError] = useState(false); + const [isValid, setIsValid] = useState(false); + + const handleError = useCallback((error: boolean) => { + setIsError(error); + }, []); + + const handleConsent = useCallback( + (changeEvent: ChangeEvent): void => { + handleError(false); + setIsValid(changeEvent.target.checked); + }, + [handleError] + ); + + return { + handleConsent, + handleError, + state: { + isDisabled, + isError, + isValid, + }, + }; +}; diff --git a/src/components/Login/hooks/useUserLogin/types.ts b/src/components/Login/hooks/useUserLogin/types.ts new file mode 100644 index 00000000..ffb00506 --- /dev/null +++ b/src/components/Login/hooks/useUserLogin/types.ts @@ -0,0 +1,8 @@ +import { ProviderId } from "../../../../providers/authentication/common/types"; +import { UseUserConsent } from "../useUserConsent/types"; + +export interface UseUserLogin + extends Omit { + consentState: Pick; + handleLogin: (providerId: ProviderId) => void; +} diff --git a/src/components/Login/hooks/useUserLogin/useUserLogin.ts b/src/components/Login/hooks/useUserLogin/useUserLogin.ts new file mode 100644 index 00000000..fda22d2d --- /dev/null +++ b/src/components/Login/hooks/useUserLogin/useUserLogin.ts @@ -0,0 +1,29 @@ +import { useCallback } from "react"; +import { useAuth } from "../../../../providers/authentication/auth/hook"; +import { ProviderId } from "../../../../providers/authentication/common/types"; +import { useUserConsent } from "../useUserConsent/useUserConsent"; +import { UseUserLogin } from "./types"; + +export const useUserLogin = (): UseUserLogin => { + const { service: { requestLogin } = {} } = useAuth(); + const { handleConsent, handleError, state: consentState } = useUserConsent(); + const { isDisabled, isError, isValid } = consentState; // Consent state: { isValid } is an indicator of whether the user has accepted the login terms. + + const handleLogin = useCallback( + (providerId: ProviderId): void => { + if (!isDisabled && !isValid) { + // If the user has not accepted terms, set error state to true. + handleError(true); + return; + } + requestLogin?.(providerId); + }, + [handleError, isDisabled, isValid, requestLogin] + ); + + return { + consentState: { isDisabled, isError }, + handleConsent, + handleLogin, + }; +}; diff --git a/src/components/common/CustomIcon/components/CloseIcon/closeIcon.tsx b/src/components/common/CustomIcon/components/CloseIcon/closeIcon.tsx new file mode 100644 index 00000000..b35a5748 --- /dev/null +++ b/src/components/common/CustomIcon/components/CloseIcon/closeIcon.tsx @@ -0,0 +1,17 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; +import React from "react"; + +export const CloseIcon = ({ + fontSize = "xsmall", + viewBox = "0 0 18 18", + ...props /* Spread props to allow for Mui SvgIconProps specific prop overrides e.g. "htmlColor". */ +}: SvgIconProps): JSX.Element => { + return ( + + + + ); +}; diff --git a/src/components/common/LoginDialog/constants.ts b/src/components/common/LoginDialog/constants.ts new file mode 100644 index 00000000..0bedfec0 --- /dev/null +++ b/src/components/common/LoginDialog/constants.ts @@ -0,0 +1,33 @@ +import { + DialogContentTextProps, + DialogProps, + DialogTitleProps, + IconButtonProps, + IconProps, +} from "@mui/material"; +import { FONT_SIZE } from "../../../styles/common/mui/icon"; +import { COLOR, VARIANT } from "../../../styles/common/mui/typography"; + +export const DIALOG_CONTENT_TEXT_PROPS: DialogContentTextProps = { + color: COLOR.INK_LIGHT, + component: "div", + variant: VARIANT.TEXT_BODY_400, +}; + +export const DIALOG_PROPS: Partial = { + PaperProps: { elevation: 0 }, +}; + +export const DIALOG_TITLE_PROPS: DialogTitleProps = { + variant: VARIANT.TEXT_HEADING_SMALL, +}; + +export const ICON_BUTTON_PROPS: IconButtonProps = { + color: "inkLight", + edge: "end", + size: "xsmall", +}; + +export const ICON_PROPS: Pick = { + fontSize: FONT_SIZE.SMALL, +}; diff --git a/src/components/common/LoginDialog/loginDialog.styles.ts b/src/components/common/LoginDialog/loginDialog.styles.ts new file mode 100644 index 00000000..6f7ce1cc --- /dev/null +++ b/src/components/common/LoginDialog/loginDialog.styles.ts @@ -0,0 +1,51 @@ +import styled from "@emotion/styled"; +import { Dialog } from "@mui/material"; +import { inkMain } from "../../../styles/common/mixins/colors"; +import { alpha80 } from "../../../theme/common/palette"; + +export const StyledDialog = styled(Dialog)` + &.MuiDialog-root { + .MuiBackdrop-root { + background-color: ${inkMain}${alpha80}; + } + + .MuiDialog-paper { + border-radius: 8px; + max-width: 400px; + padding: 32px; + position: relative; /* positions close icon */ + + .MuiDialogTitle-root, + .MuiDialogContent-root, + .MuiDialogActions-root { + padding: 0; + } + + .MuiDialogTitle-root { + font-size: 20px; + + .MuiIconButton-root { + position: absolute; + right: 12px; + top: 12px; + } + } + + .MuiDialogContent-root { + .MuiDialogContentText-root { + margin: 8px 0; + } + + .MuiGrid2-root { + margin: 24px 0; + } + } + + .MuiDialogActions-root { + display: flex; + flex-direction: column; + gap: 16px 0; + } + } + } +`; diff --git a/src/components/common/LoginDialog/loginDialog.tsx b/src/components/common/LoginDialog/loginDialog.tsx new file mode 100644 index 00000000..2b43f8ee --- /dev/null +++ b/src/components/common/LoginDialog/loginDialog.tsx @@ -0,0 +1,56 @@ +import { + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, +} from "@mui/material"; +import React from "react"; +import { useAuthenticationConfig } from "../../../hooks/authentication/config/useAuthenticationConfig"; +import { Buttons } from "../../Login/components/Buttons/buttons"; +import { Consent } from "../../Login/components/Section/components/Consent/consent"; +import { Warning } from "../../Login/components/Section/components/Warning/warning"; +import { useUserLogin } from "../../Login/hooks/useUserLogin/useUserLogin"; +import { CloseIcon } from "../CustomIcon/components/CloseIcon/closeIcon"; +import { + DIALOG_CONTENT_TEXT_PROPS, + DIALOG_PROPS, + DIALOG_TITLE_PROPS, + ICON_BUTTON_PROPS, + ICON_PROPS, +} from "./constants"; +import { StyledDialog } from "./loginDialog.styles"; +import { LoginDialogProps } from "./types"; + +export const LoginDialog = ({ + onClose, + open, +}: LoginDialogProps): JSX.Element | null => { + const authConfig = useAuthenticationConfig(); + const { consentState, handleConsent, handleLogin } = useUserLogin(); + + if (!authConfig) return null; + + return ( + + +

Sign In Required
+ + + + + + + Please sign in to proceed with this action. + + + {authConfig.termsOfService} + + + + + + {authConfig.warning} + + ); +}; diff --git a/src/components/common/LoginDialog/types.ts b/src/components/common/LoginDialog/types.ts new file mode 100644 index 00000000..6c29024f --- /dev/null +++ b/src/components/common/LoginDialog/types.ts @@ -0,0 +1,4 @@ +export interface LoginDialogProps { + onClose: () => void; + open: boolean; +} diff --git a/src/config/entities.ts b/src/config/entities.ts index 33be77cc..8ecc6fe8 100644 --- a/src/config/entities.ts +++ b/src/config/entities.ts @@ -379,6 +379,7 @@ export interface SiteConfig { entities: EntityConfig[]; explorerTitle: HeroTitle; export?: ExportConfig; + exportsRequireAuth?: boolean; exportToTerraUrl?: string; // TODO(cc) revist location; possibly nest inside "export"? gitHubUrl?: string; layout: { diff --git a/src/providers/loginGuard/common/types.ts b/src/providers/loginGuard/common/types.ts new file mode 100644 index 00000000..03ed05f0 --- /dev/null +++ b/src/providers/loginGuard/common/types.ts @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; + +/** + * A callback function to be stored and then executed upon successful login. + */ +export type LoginGuardCallback = () => void; + +/** + * The shape of the LoginGuard context, provides a function to trigger the + * login process. + */ +export interface LoginGuardContextProps { + requireLogin: (callback?: LoginGuardCallback) => void; +} + +/** + * The properties for the LoginGuardProvider component. + */ +export interface LoginGuardProviderProps { + children: ReactNode; +} diff --git a/src/providers/loginGuard/context.ts b/src/providers/loginGuard/context.ts new file mode 100644 index 00000000..4f667060 --- /dev/null +++ b/src/providers/loginGuard/context.ts @@ -0,0 +1,12 @@ +import { createContext } from "react"; +import { LoginGuardCallback, LoginGuardContextProps } from "./common/types"; + +/** + * LoginGuardContext provides a way to trigger a login process. Default value is to + * call the callback immediately, if specified. + */ +export const LoginGuardContext = createContext({ + requireLogin: (callback?: LoginGuardCallback) => { + callback?.(); + }, +}); diff --git a/src/providers/loginGuard/hook.ts b/src/providers/loginGuard/hook.ts new file mode 100644 index 00000000..d586a632 --- /dev/null +++ b/src/providers/loginGuard/hook.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { LoginGuardContextProps } from "./common/types"; +import { LoginGuardContext } from "./context"; + +/** + * Custom hook to access the LoginGuard context. This hook returns an object + * containing the "requireLogin" function, which allows triggering the application's + * login process. + * + * @returns The current LoginGuard context value. + */ +export function useLoginGuard(): LoginGuardContextProps { + return useContext(LoginGuardContext); +} diff --git a/src/providers/loginGuard/provider.tsx b/src/providers/loginGuard/provider.tsx new file mode 100644 index 00000000..c9cc7b07 --- /dev/null +++ b/src/providers/loginGuard/provider.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { LoginDialog } from "../../components/common/LoginDialog/loginDialog"; +import { useAuthenticationConfig } from "../../hooks/authentication/config/useAuthenticationConfig"; +import { useConfig } from "../../hooks/useConfig"; +import { useAuth } from "../authentication/auth/hook"; +import { LoginGuardCallback, LoginGuardProviderProps } from "./common/types"; +import { LoginGuardContext } from "./context"; + +/** + * LoginGuardProvider is responsible for intercepting actions that require user authentication. + * It provides a "requireLogin" function via context. When a protected action is triggered while the + * user is unauthenticated, the LoginDialog is displayed. Upon successful authentication, the saved + * callback is invoked. + * + * @param {LoginGuardProviderProps} props - The provider props that include children. + * @returns The provider component. + */ +export function LoginGuardProvider({ + children, +}: LoginGuardProviderProps): JSX.Element { + // Dialog open state. + const [open, setOpen] = useState(false); + + // Use ref to store the callback without triggering re-render. + const callbackRef = useRef(undefined); + + // Determine if authentication is enabled. + const authConfig = useAuthenticationConfig(); + + // Determine if authentication is required for downloads and exports. + const { + config: { exportsRequireAuth }, + } = useConfig(); + + // Get the user's authenticated state. + const { + authState: { isAuthenticated }, + } = useAuth(); + + // If the user authenticates, close dialog then fire and clear callback. + useEffect(() => { + if (isAuthenticated) { + setOpen(false); + callbackRef.current?.(); + // Clear callback after firing. + callbackRef.current = undefined; + } + }, [isAuthenticated]); + + // Handler to close the dialog. + const onClose = useCallback(() => { + setOpen(false); + // Clear any stored callback. + callbackRef.current = undefined; + }, []); + + // Block actions that require authentication, or fire callback if already authenticated. + const requireLogin = useCallback( + (cb?: LoginGuardCallback) => { + if (authConfig && exportsRequireAuth && !isAuthenticated) { + callbackRef.current = cb; + setOpen(true); + } else { + cb?.(); + } + }, + [authConfig, exportsRequireAuth, isAuthenticated] + ); + + return ( + + {children} + + + ); +} diff --git a/src/styles/common/mui/typography.ts b/src/styles/common/mui/typography.ts index 079f90c4..bf5d3c69 100644 --- a/src/styles/common/mui/typography.ts +++ b/src/styles/common/mui/typography.ts @@ -1,5 +1,13 @@ import { TypographyOwnProps } from "@mui/material"; +export const COLOR: Record = { + INHERIT: "inherit", + INK_LIGHT: "ink.light", + INK_MAIN: "ink.main", +}; + export const VARIANT: Record = { INHERIT: "inherit", + TEXT_BODY_400: "text-body-400", + TEXT_HEADING_SMALL: "text-heading-small", }; diff --git a/tests/provider.test.tsx b/tests/provider.test.tsx new file mode 100644 index 00000000..1ce9c428 --- /dev/null +++ b/tests/provider.test.tsx @@ -0,0 +1,191 @@ +import { jest } from "@jest/globals"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import { LoginGuardContext } from "../src/providers/loginGuard/context"; + +jest.unstable_mockModule("../src/hooks/useConfig", () => ({ + useConfig: jest.fn(), +})); + +jest.unstable_mockModule("../src/providers/authentication/auth/hook", () => ({ + useAuth: jest.fn(), +})); + +jest.unstable_mockModule( + "../src/hooks/authentication/config/useAuthenticationConfig", + () => ({ + useAuthenticationConfig: jest.fn(), + }) +); + +const TEST_ID_LOGIN_DIALOG = "login-dialog"; +const TEXT_DIALOG_CLOSED = "closed"; +const TEXT_DIALOG_OPEN = "open"; +jest.unstable_mockModule( + "../src/components/common/LoginDialog/loginDialog", + () => ({ + LoginDialog: ({ open }: { open: boolean }): JSX.Element => ( +
+ {open ? TEXT_DIALOG_OPEN : TEXT_DIALOG_CLOSED} +
+ ), + }) +); + +const { useConfig } = await import("../src/hooks/useConfig"); +const { useAuth } = await import("../src/providers/authentication/auth/hook"); +const { useAuthenticationConfig } = await import( + "../src/hooks/authentication/config/useAuthenticationConfig" +); + +const { LoginGuardProvider } = await import( + "../src/providers/loginGuard/provider" +); + +const TEXT_BUTTON_EXPORT = "export"; + +describe("LoginGuardProvider", () => { + beforeEach(() => { + // Mock hooks used by login guard. + (useConfig as jest.Mock).mockReturnValue({ + config: { + exportsRequireAuth: true, + }, + }); + (useAuth as jest.Mock).mockReturnValue({ + authState: { + isAuthenticated: false, + }, + }); + (useAuthenticationConfig as jest.Mock).mockReturnValue({}); + }); + + it("should render children and login dialog closed", () => { + render( + +
child component
+
+ ); + + expect(screen.getByTestId("child")).toBeTruthy(); + expect(screen.getByTestId(TEST_ID_LOGIN_DIALOG).textContent).toBe( + TEXT_DIALOG_CLOSED + ); + }); + + it("calls callback immediately if user is authenticated", () => { + const callback = jest.fn(); + + // Simulate user authentication. + (useAuth as jest.Mock).mockReturnValue({ + authState: { isAuthenticated: true }, + }); + + render( + + + {({ requireLogin }) => ( + + )} + + + ); + + // Click button requiring login. + act(() => { + screen.getByText(TEXT_BUTTON_EXPORT).click(); + }); + + // User is authenticated; callback should be fired immediately. + expect(callback).toHaveBeenCalled(); + + // Login dialog should not be open. + expect(screen.getByTestId(TEST_ID_LOGIN_DIALOG).textContent).toBe( + TEXT_DIALOG_CLOSED + ); + }); + + it("calls callback immediately if exportsRequireAuth is false", () => { + const callback = jest.fn(); + + // Simulate exportsRequireAuth being false. + (useConfig as jest.Mock).mockReturnValue({ + config: { + exportsRequireAuth: false, + }, + }); + + render( + + + {({ requireLogin }) => ( + + )} + + + ); + + // Click button requiring login. + act(() => { + screen.getByText(TEXT_BUTTON_EXPORT).click(); + }); + + // exportsRequireAuth is false; callback should be fired immediately. + expect(callback).toHaveBeenCalled(); + + // Login dialog should not be open. + expect(screen.getByTestId(TEST_ID_LOGIN_DIALOG).textContent).toBe( + TEXT_DIALOG_CLOSED + ); + }); + + it("should call callback after user authenticates", async () => { + const callback = jest.fn(); + + const { rerender } = render( + + + {({ requireLogin }) => ( + + )} + + + ); + + // Click button requiring login. + act(() => { + screen.getByText(TEXT_BUTTON_EXPORT).click(); + }); + + // User is not authenticated; callback should not have been called. + expect(callback).not.toHaveBeenCalled(); + + // User is not authenticated; login dialog should be open. + expect(screen.getByTestId(TEST_ID_LOGIN_DIALOG).textContent).toBe( + TEXT_DIALOG_OPEN + ); + + // Simulate user authentication. + await act(async () => { + (useAuth as jest.Mock).mockReturnValue({ + authState: { isAuthenticated: true }, + }); + }); + + // Rerender to trigger useEffect. + rerender( + +
+ + ); + + // Callback should be called (in useEffect called on re-render). + expect(callback).toHaveBeenCalled(); + }); +});