Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ import {
IconButtonProps as MIconButtonProps,
Skeleton,
} from "@mui/material";
import Router from "next/router";
import Router, { useRouter } from "next/router";
import { ElementType, JSX } from "react";
import { useProfile } from "../../../../../../../../../../hooks/authentication/profile/useProfile";
import { ROUTE } from "../../../../../../../../../../routes/constants";
import { isNavigationLinkSelected } from "../../../Navigation/common/utils";
import { AuthenticationMenu } from "./components/AuthenticationMenu/authenticationMenu";
import { StyledButton } from "./components/Button/button.styles";
import { getSignInPath, getSignInPathPattern } from "./utils";

export interface AuthenticationProps {
authenticationEnabled?: boolean;
/**
* `true` to enable the auth UI with the default sign-in path (`/login`),
* a string to enable with a custom sign-in path (e.g. when NextAuth's
* `pages.signIn` is configured elsewhere). Falsy disables the UI.
*/
authenticationEnabled?: boolean | string;
Button: ElementType<MButtonProps> | ElementType<MIconButtonProps>;
closeMenu: () => void;
}
Expand All @@ -25,13 +30,18 @@ export const Authentication = ({
closeMenu,
}: AuthenticationProps): JSX.Element | null => {
const { isLoading, profile } = useProfile();
const { asPath } = useRouter();
if (!authenticationEnabled) return null;
if (isLoading) return <Skeleton height={32} variant="circular" width={32} />;
if (profile) return <AuthenticationMenu profile={profile} />;
const signInPath = getSignInPath(authenticationEnabled);
return (
<Button
onClick={async (): Promise<void> => {
await Router.push(ROUTE.LOGIN);
await Router.push({
pathname: signInPath,
query: { callbackUrl: asPath },
});
closeMenu();
}}
/>
Expand All @@ -42,17 +52,21 @@ export const Authentication = ({
* Renders authentication button.
* @param props - Button props.
* @param pathname - Pathname.
* @param signInPath - Sign-in path used for the active state (see `getSignInPath`).
* @returns button.
*/
export function renderButton(
props: MButtonProps,
pathname: string,
signInPath: string,
): JSX.Element {
return (
<StyledButton
startIcon={<LoginRounded />}
variant={
isNavigationLinkSelected(pathname, [ROUTE.LOGIN]) ? "activeNav" : "nav"
isNavigationLinkSelected(pathname, [getSignInPathPattern(signInPath)])
? "activeNav"
: "nav"
}
{...props}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { escapeRegExp } from "../../../../../../../../../../common/utils";
import { ROUTE } from "../../../../../../../../../../routes/constants";

/**
* Resolves the sign-in path for the header's Sign In button.
*
* When `authenticationEnabled` is a string, treat it as the consumer's
* sign-in path (e.g. when NextAuth's `pages.signIn` is configured to `"/"`).
* Otherwise fall back to the library default (`ROUTE.LOGIN` = `"/login"`).
*
* Accepts `false`/`undefined` (returning the default) so callers that only
* build render props — e.g. the header wiring `renderButton` — can call it
* unconditionally; the auth UI itself still renders nothing when the prop is
* falsy.
*
* @param authenticationEnabled - The `authenticationEnabled` prop value.
* @returns The path to navigate to when the user clicks Sign In.
*/
export function getSignInPath(
authenticationEnabled: boolean | string | undefined,
): string {
return typeof authenticationEnabled === "string"
? authenticationEnabled
: ROUTE.LOGIN;
}

/**
* Builds the `isNavigationLinkSelected` pattern for the sign-in path.
*
* Anchored to the full pathname (with the path's regex special characters
* escaped) because `isNavigationLinkSelected` treats patterns as unanchored
* regexes — a root sign-in path (`"/"`) would otherwise match every pathname
* and leave the Sign In button permanently highlighted.
*
* Tolerates trailing-slash differences between the configured path and the
* runtime pathname (e.g. `authenticationEnabled="/login/"` or Next's
* `trailingSlash: true`), so either form highlights on either pathname.
*
* @param signInPath - The resolved sign-in path (see `getSignInPath`).
* @returns Pattern matching exactly the sign-in pathname.
*/
export function getSignInPathPattern(signInPath: string): string {
const path = signInPath === "/" ? signInPath : signInPath.replace(/\/+$/, "");
if (path === "/") return "^/$";
return `^${escapeRegExp(path)}/?$`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
export interface DialogTitleProps {
actions?: ReactNode;
announcements?: ComponentsConfig;
authenticationEnabled?: boolean;
authenticationEnabled?: boolean | string;
logo?: ReactNode;
onClose: () => void;
searchEnabled?: boolean;
Expand Down
17 changes: 15 additions & 2 deletions src/components/Layout/components/Header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
renderButton as renderAuthenticationButton,
renderIconButton as renderAuthenticationIconButton,
} from "./components/Content/components/Actions/components/Authentication/authentication";
import { getSignInPath } from "./components/Content/components/Actions/components/Authentication/utils";
import { Menu } from "./components/Content/components/Actions/components/Menu/menu";
import {
renderButton as renderSearchButton,
Expand All @@ -36,7 +37,15 @@ import { useMenu } from "./hooks/useMenu";
export interface HeaderProps {
actions?: ReactNode;
announcements?: ComponentsConfig;
authenticationEnabled?: boolean;
/**
* Enables the authentication UI in the header. Pass `true` to enable with
* the default sign-in path (`/login`), or pass a string to enable AND tell
* the Sign In button which path to navigate to (e.g. `"/"` when NextAuth's
* `pages.signIn` is configured to `"/"`). The current `asPath` is appended
* as a `?callbackUrl=` query param so the user lands back where they were
* after signing in.
*/
authenticationEnabled?: boolean | string;
className?: string;
logo: ReactNode;
navigation?: Navigation;
Expand Down Expand Up @@ -140,7 +149,11 @@ export const Header = ({ ...headerProps }: HeaderProps): JSX.Element => {
Button={({ ...props }): JSX.Element =>
isIn.isMenuIn
? renderAuthenticationIconButton(props)
: renderAuthenticationButton(props, pathname)
: renderAuthenticationButton(
props,
pathname,
getSignInPath(authenticationEnabled),
)
}
authenticationEnabled={authenticationEnabled}
closeMenu={onClose}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export const useHeaderVisibility = (
const hasSlogan = Boolean(slogan);
const hasSocials = Boolean(socialMedia);
// Determines header content visibility.
const isActionsIn =
const isActionsIn = Boolean(
(hasActions || searchEnabled || authenticationEnabled || hasMenu) &&
hasBreakpoint;
hasBreakpoint,
);
const isNavigationIn = smUp;
const isMenuIn = hasMenu;
const isSloganIn = hasSlogan && mdUp;
Expand Down
95 changes: 95 additions & 0 deletions tests/authentication.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { jest } from "@jest/globals";
import { ButtonProps as MButtonProps } from "@mui/material";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const PUBLIC_PATH = "/requesting-elevated-permissions";
const CUSTOM_SIGNIN_PATH = "/";

let mockAsPath = "/";

jest.unstable_mockModule("next/router", () => {
const push = jest.fn(async (): Promise<boolean> => true);
return {
...jest.requireActual<typeof import("next/router")>("next/router"),
default: { push },
useRouter: jest.fn(() => ({ asPath: mockAsPath, push })),
};
});
jest.unstable_mockModule(
"../src/hooks/authentication/profile/useProfile",
() => ({
useProfile: jest.fn(() => ({ isLoading: false, profile: undefined })),
}),
);

const Router = (await import("next/router")).default;
const { Authentication } =
await import("../src/components/Layout/components/Header/components/Content/components/Actions/components/Authentication/authentication");

const TestButton = ({ onClick }: MButtonProps): JSX.Element => (
<button onClick={onClick}>Sign in</button>
);
const closeMenu = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
mockAsPath = "/";
});

describe("Authentication Sign In button", () => {
test("does not render when authenticationEnabled is falsy", () => {
const { container } = render(
<Authentication
authenticationEnabled={false}
Button={TestButton}
closeMenu={closeMenu}
/>,
);
expect(container.firstChild).toBeNull();
});

test("navigates to ROUTE.LOGIN with current asPath as callbackUrl when authenticationEnabled is true", async () => {
mockAsPath = PUBLIC_PATH;
render(
<Authentication
authenticationEnabled
Button={TestButton}
closeMenu={closeMenu}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(Router.push).toHaveBeenCalledWith({
pathname: "/login",
query: { callbackUrl: PUBLIC_PATH },
});
});

test("navigates to the configured signInPath when authenticationEnabled is a string", async () => {
mockAsPath = PUBLIC_PATH;
render(
<Authentication
authenticationEnabled={CUSTOM_SIGNIN_PATH}
Button={TestButton}
closeMenu={closeMenu}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(Router.push).toHaveBeenCalledWith({
pathname: CUSTOM_SIGNIN_PATH,
query: { callbackUrl: PUBLIC_PATH },
});
});

test("closes the menu after navigating", async () => {
render(
<Authentication
authenticationEnabled
Button={TestButton}
closeMenu={closeMenu}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(closeMenu).toHaveBeenCalledTimes(1);
});
});
55 changes: 55 additions & 0 deletions tests/getSignInPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
getSignInPath,
getSignInPathPattern,
} from "../src/components/Layout/components/Header/components/Content/components/Actions/components/Authentication/utils";
import { isNavigationLinkSelected } from "../src/components/Layout/components/Header/components/Content/components/Navigation/common/utils";

describe("getSignInPath", () => {
test("returns the library default when authenticationEnabled is true", () => {
expect(getSignInPath(true)).toBe("/login");
});

test("returns the library default when authenticationEnabled is false or undefined", () => {
expect(getSignInPath(false)).toBe("/login");
expect(getSignInPath(undefined)).toBe("/login");
});

test("returns the consumer-supplied string verbatim", () => {
expect(getSignInPath("/")).toBe("/");
expect(getSignInPath("/auth/signin")).toBe("/auth/signin");
});
});

describe("getSignInPathPattern", () => {
test("default login path matches exactly the login pathname", () => {
const patterns = [getSignInPathPattern("/login")];
expect(isNavigationLinkSelected("/login", patterns)).toBe(true);
expect(isNavigationLinkSelected("/login/nested", patterns)).toBe(false);
expect(isNavigationLinkSelected("/atlases", patterns)).toBe(false);
});

test("root sign-in path matches only the root pathname", () => {
const patterns = [getSignInPathPattern("/")];
expect(isNavigationLinkSelected("/", patterns)).toBe(true);
expect(isNavigationLinkSelected("/atlases", patterns)).toBe(false);
});

test("tolerates trailing-slash differences between path and pathname", () => {
const fromSlashless = [getSignInPathPattern("/login")];
expect(isNavigationLinkSelected("/login/", fromSlashless)).toBe(true);
const fromSlashed = [getSignInPathPattern("/login/")];
expect(isNavigationLinkSelected("/login", fromSlashed)).toBe(true);
expect(isNavigationLinkSelected("/login/", fromSlashed)).toBe(true);
expect(isNavigationLinkSelected("/login/nested", fromSlashed)).toBe(false);
});

test("escapes regex special characters in the sign-in path", () => {
const patterns = [getSignInPathPattern("/auth/sign-in (sso)")];
expect(isNavigationLinkSelected("/auth/sign-in (sso)", patterns)).toBe(
true,
);
expect(isNavigationLinkSelected("/auth/sign-in -sso-", patterns)).toBe(
false,
);
});
});
Loading