Repository: nulib/dc-nextjs
Pull request #425: Update auth to handle magic links
Author: login: charlesLoder
Created: 2025-05-14T20:07:15Z | Merged: 2025-05-20T13:39:48Z

## Description

Refactors the UX for auth by allowing for Magic Links and routing all auth through a dialog (instead of just when toggling the gen ai input)

## Summary of changes

- routes all login through a global dialog
- refactors the dialog
  - changed from `AlertDialog` to regular `Dialog` component
  - adds ability to sign in with magic links
  - adds a `NEXT_PUBLIC_MAGIC_LINK_ENABLED` flag

## Testing

Preview branch: https://preview-5521-auth-flow.dc.rdc-staging.library.northwestern.edu/

locally:

```bash
export NEXT_PUBLIC_MAGIC_LINK_ENABLED=true
npm run dev
```

## Notes

The intent of `NEXT_PUBLIC_MAGIC_LINK_ENABLED` is to control whether or not Magic Links should be enabled at the app level. This provides, for the moment, an easy kill switch for the feature. If the environment variable is `true`, then we also check the new endpoint.



Diff:
diff --git a/components/Header/Header.styled.ts b/components/Header/Header.styled.ts
index 70b75c26..523eb41f 100644
--- a/components/Header/Header.styled.ts
+++ b/components/Header/Header.styled.ts
@@ -201,6 +201,14 @@ const User = styled("span", {
   },
 });
 
+const Login = styled("button", {
+  border: "none",
+  background: "none",
+  color: "$white",
+  cursor: "pointer",
+  paddingLeft: "$gr2",
+});
+
 const Logout = styled("button", {
   border: "none",
   background: "none",
@@ -241,6 +249,7 @@ export type HeaderVariants = VariantProps<typeof HeaderStyled>;
 
 export {
   Lockup,
+  Login,
   Logout,
   Menu,
   MenuToggle,
diff --git a/components/Header/Super.test.tsx b/components/Header/Super.test.tsx
index 8cf77a90..afe600a0 100644
--- a/components/Header/Super.test.tsx
+++ b/components/Header/Super.test.tsx
@@ -10,6 +10,9 @@ describe("PlaceholderBlock component", () => {
     expect(navItems[0]).toHaveTextContent("Libraries");
     expect(navItems[1]).toHaveTextContent("About");
     expect(navItems[2]).toHaveTextContent("Contact");
-    expect(navItems[3]).toHaveTextContent("Sign in");
+    const signInButton = within(navEl).getByRole("button", {
+      name: /Sign In/i,
+    });
+    expect(signInButton).toBeInTheDocument();
   });
 });
diff --git a/components/Header/Super.tsx b/components/Header/Super.tsx
index 9402fd3f..7593ff64 100644
--- a/components/Header/Super.tsx
+++ b/components/Header/Super.tsx
@@ -1,5 +1,6 @@
 import { IconClear, IconMenu } from "../Shared/SVG/Icons";
 import {
+  Login,
   Logout,
   Menu,
   MenuToggle,
@@ -77,11 +78,7 @@ export default function HeaderSuper() {
               </Link>
             ))}
             {!userAuthContext?.user?.isLoggedIn && (
-              <Link
-                href={`${DCAPI_ENDPOINT}/auth/login?goto=${window.location}`}
-              >
-                Sign in
-              </Link>
+              <Login onClick={userAuthContext.openSignInModal}>Sign in</Login>
             )}
             {userAuthContext?.user?.isLoggedIn && (
               <>
diff --git a/components/Search/GenerativeAIToggle.test.tsx b/components/Search/GenerativeAIToggle.test.tsx
index 67f63e4d..be6bac15 100644
--- a/components/Search/GenerativeAIToggle.test.tsx
+++ b/components/Search/GenerativeAIToggle.test.tsx
@@ -11,21 +11,31 @@ import { UserContext as UserContextType } from "@/types/context/user";
 import mockRouter from "next-router-mock";
 import userEvent from "@testing-library/user-event";
 
-const defaultUser = {
+const defaultUserContext: UserContextType = {
   user: {
     email: "ace@northewestern.edu",
+    primaryAffiliation: "student",
     isLoggedIn: true,
     isReadingRoom: false,
     name: "Ace Frehley",
     sub: "xyz123",
   },
+  isSignInModalOpen: false,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
 };
 
 const withUserProvider = (
   Component: React.ReactNode,
-  user: UserContextType = defaultUser,
+  userContext: UserContextType = defaultUserContext,
 ) => {
-  return <UserContext.Provider value={user}>{Component}</UserContext.Provider>;
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
 };
 
 const withSearchProvider = (
@@ -74,11 +84,18 @@ describe("GenerativeAIToggle", () => {
 
   it("renders a login dialog for a non-logged-in user when generative ai checkbox is checked", async () => {
     const user = userEvent.setup();
-    const nonLoggedInUser = {
+    const nonLoggedInUser: UserContextType = {
+      ...defaultUserContext,
       user: {
-        ...defaultUser.user,
+        ...defaultUserContext.user!,
         isLoggedIn: false,
       },
+      openSignInModal: jest.fn(() => {
+        nonLoggedInUser.isSignInModalOpen = true;
+      }),
+      closeSignInModal: jest.fn(() => {
+        nonLoggedInUser.isSignInModalOpen = false;
+      }),
     };
 
     render(
@@ -91,11 +108,7 @@ describe("GenerativeAIToggle", () => {
     const checkbox = await screen.findByRole("checkbox");
     await user.click(checkbox);
 
-    const generativeAIDialog = await screen.findByText(
-      "You must be logged in with a Northwestern NetID to use the Generative AI search feature.",
-    );
-
-    expect(generativeAIDialog).toBeInTheDocument();
+    expect(nonLoggedInUser.isSignInModalOpen).toBe(true);
   });
 
   it("renders a toggled generative ai state when localStorage variable is set and user is logged in", () => {
diff --git a/components/Search/GenerativeAIToggle.tsx b/components/Search/GenerativeAIToggle.tsx
index f48968d8..e8f4aab3 100644
--- a/components/Search/GenerativeAIToggle.tsx
+++ b/components/Search/GenerativeAIToggle.tsx
@@ -2,7 +2,6 @@ import * as Tooltip from "@radix-ui/react-tooltip";
 
 import {
   AI_DISCLAIMER,
-  AI_LOGIN_ALERT,
   AI_TOGGLE_LABEL,
   AI_SYS_PROMPT_MSG,
 } from "@/lib/constants/common";
@@ -20,7 +19,6 @@ import { TooltipArrow, TooltipBody } from "../Shared/Tooltip.styled";
 import { IconCheck } from "@/components/Shared/SVG/Icons";
 import { IconInfo } from "@/components/Shared/SVG/Icons";
 import React from "react";
-import SharedAlertDialog from "../Shared/AlertDialog";
 import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
 
 function GenerativeAITooltip() {
@@ -47,8 +45,7 @@ function GenerativeAITooltip() {
 }
 
 export default function GenerativeAIToggle() {
-  const { closeDialog, dialog, isChecked, handleCheckChange, handleLogin } =
-    useGenerativeAISearchToggle();
+  const { isChecked, handleCheckChange } = useGenerativeAISearchToggle();
 
   return (
     <>
@@ -65,15 +62,6 @@ export default function GenerativeAIToggle() {
         <label htmlFor="isGenerativeAI">{AI_TOGGLE_LABEL}</label>
         <GenerativeAITooltip />
       </GenerativeAIToggleWrapper>
-
-      <SharedAlertDialog
-        isOpen={dialog.isOpen}
-        cancel={{ label: "Cancel", onClick: closeDialog }}
-        action={{ label: "Sign in", onClick: handleLogin }}
-        title="Sign in to Digital Collections"
-      >
-        {AI_LOGIN_ALERT}
-      </SharedAlertDialog>
     </>
   );
 }
diff --git a/components/Shared/AlertDialog.styled.ts b/components/Shared/AlertDialog.styled.ts
deleted file mode 100644
index d13e7bb1..00000000
--- a/components/Shared/AlertDialog.styled.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import * as AlertDialog from "@radix-ui/react-alert-dialog";
-
-import { styled } from "@/stitches.config";
-
-/* eslint sort-keys: 0 */
-
-const AlertDialogOverlay = styled(AlertDialog.Overlay, {
-  background: "rgba(0 0 0 / 0.382)",
-  position: "fixed",
-  top: 0,
-  left: 0,
-  right: 0,
-  bottom: 0,
-  display: "grid",
-  placeItems: "center",
-  overflowY: "auto",
-  zIndex: "1",
-});
-
-const AlertDialogContent = styled(AlertDialog.Content, {
-  backgroundColor: "white",
-  borderRadius: "6px",
-  boxShadow:
-    "hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
-  position: "fixed",
-  top: "50%",
-  left: "50%",
-  transform: "translate(-50%, -50%)",
-  width: "90vw",
-  maxWidth: "500px",
-  maxHeight: "85vh",
-  padding: "$gr4",
-  zIndex: "2",
-  fontSize: "$gr3",
-
-  "&:focus": { outline: "none" },
-});
-
-const AlertDialogTitle = styled(AlertDialog.Title, {
-  fontSize: "$gr4",
-  lineHeight: "1.5rem",
-  padding: "0",
-  margin: "0",
-  color: "$black50",
-  fontWeight: "400",
-});
-
-const AlertDialogButtonRow = styled("div", {
-  display: "flex",
-  justifyContent: "space-between",
-
-  "> button": {
-    margin: 0,
-  },
-
-  "& > *:not(:last-child)": {
-    marginRight: "$gr3",
-  },
-});
-
-export {
-  AlertDialogButtonRow,
-  AlertDialogContent,
-  AlertDialogOverlay,
-  AlertDialogTitle,
-};
diff --git a/components/Shared/AlertDialog.test.tsx b/components/Shared/AlertDialog.test.tsx
deleted file mode 100644
index ac18aca0..00000000
--- a/components/Shared/AlertDialog.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { render, screen } from "@testing-library/react";
-
-import React from "react";
-import SharedAlertDialog from "./AlertDialog";
-
-const defaultProps = {
-  action: { label: "Login", onClick: jest.fn() },
-  cancel: { label: "Cancel", onClick: jest.fn() },
-  isOpen: true,
-  title: "Title",
-};
-
-describe("SharedAlertDialog", () => {
-  it("renders the primary components", () => {
-    render(<SharedAlertDialog {...defaultProps}>Content</SharedAlertDialog>);
-
-    expect(screen.getByText("Content")).toBeInTheDocument();
-    expect(screen.getByRole("heading")).toHaveTextContent(defaultProps.title);
-    expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: "Login" })).toBeInTheDocument();
-  });
-
-  it("calls the cancel and action callback functions", () => {
-    render(<SharedAlertDialog {...defaultProps}>Content</SharedAlertDialog>);
-
-    screen.getByRole("button", { name: "Cancel" }).click();
-    expect(defaultProps.cancel.onClick).toHaveBeenCalled();
-
-    screen.getByRole("button", { name: "Login" }).click();
-    expect(defaultProps.action.onClick).toHaveBeenCalled();
-  });
-});
diff --git a/components/Shared/AlertDialog.tsx b/components/Shared/AlertDialog.tsx
deleted file mode 100644
index 516419eb..00000000
--- a/components/Shared/AlertDialog.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as AlertDialog from "@radix-ui/react-alert-dialog";
-
-import {
-  AlertDialogButtonRow,
-  AlertDialogContent,
-  AlertDialogOverlay,
-  AlertDialogTitle,
-} from "@/components/Shared/AlertDialog.styled";
-
-import { Button } from "@nulib/design-system";
-import { ReactNode } from "react";
-
-type DialogButton = {
-  label: string;
-  onClick: () => void;
-};
-
-interface SharedAlertDialogProps {
-  children: ReactNode;
-  action: DialogButton;
-  cancel?: DialogButton;
-  isOpen: boolean;
-  size?: "small" | "large";
-  title?: string;
-}
-
-export default function SharedAlertDialog({
-  children,
-  action,
-  cancel,
-  isOpen,
-  title,
-}: SharedAlertDialogProps) {
-  const cancelLabel = cancel?.label || "Cancel";
-
-  return (
-    <AlertDialog.Root open={isOpen}>
-      <AlertDialog.Portal>
-        <AlertDialogOverlay />
-        <AlertDialogContent>
-          {title && <AlertDialogTitle>{title}</AlertDialogTitle>}
-
-          <AlertDialog.Description>{children}</AlertDialog.Description>
-          <AlertDialogButtonRow>
-            {cancel && (
-              <Button onClick={cancel?.onClick} isLowercase>
-                {cancelLabel}
-              </Button>
-            )}
-
-            <AlertDialog.Action asChild>
-              <Button isPrimary onClick={action.onClick} isLowercase>
-                {action.label}
-              </Button>
-            </AlertDialog.Action>
-          </AlertDialogButtonRow>
-        </AlertDialogContent>
-      </AlertDialog.Portal>
-    </AlertDialog.Root>
-  );
-}
diff --git a/components/Shared/AuthDialog.styled.ts b/components/Shared/AuthDialog.styled.ts
new file mode 100644
index 00000000..53dae1e2
--- /dev/null
+++ b/components/Shared/AuthDialog.styled.ts
@@ -0,0 +1,152 @@
+import * as Dialog from "@radix-ui/react-dialog";
+
+import { styled } from "@/stitches.config";
+
+/* eslint sort-keys: 0 */
+
+const AuthDialogOverlay = styled(Dialog.Overlay, {
+  background: "rgba(0 0 0 / 0.382)",
+  position: "fixed",
+  top: 0,
+  left: 0,
+  right: 0,
+  bottom: 0,
+  display: "grid",
+  placeItems: "center",
+  overflowY: "auto",
+  zIndex: "1",
+});
+
+const AuthDialogContent = styled(Dialog.Content, {
+  backgroundColor: "white",
+  borderRadius: "6px",
+  boxShadow:
+    "hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
+  position: "fixed",
+  top: "50%",
+  left: "50%",
+  transform: "translate(-50%, -50%)",
+  width: "90vw",
+  maxWidth: "500px",
+  maxHeight: "85vh",
+  padding: "$gr4",
+  zIndex: "2",
+  fontSize: "$gr3",
+  display: "flex",
+  flexDirection: "column",
+  alignItems: "center",
+  gap: "$gr4",
+
+  "* + *": {
+    margin: 0,
+    padding: 0,
+  },
+
+  "&:focus": { outline: "none" },
+});
+
+const AuthDialogTitle = styled(Dialog.Title, {
+  fontSize: "$gr6",
+  lineHeight: "1.5rem",
+  padding: "0",
+  margin: "0",
+  color: "$black50",
+  fontWeight: "400",
+});
+
+const AuthDialogColumn = styled("div", {
+  display: "flex",
+  flexDirection: "column",
+  alignItems: "center",
+  justifyContent: "center",
+  textAlign: "center",
+  "text-wrap": "balance",
+  gap: "$gr1",
+  width: "100%",
+
+  "> *": {
+    width: "100%",
+  },
+
+  [`> *[role=button]`]: {
+    paddingInline: "0",
+    margin: 0,
+  },
+
+  a: {
+    color: "$white",
+    width: "100%",
+  },
+});
+
+const MagicLinkInput = styled("input", {
+  width: "100%",
+  height: "2.5rem",
+  padding: "$gr1",
+  borderWidth: "1px",
+  borderColor: "$black10",
+  borderStyle: "solid",
+  fontSize: "$gr3",
+  lineHeight: "1.5rem",
+  color: "$black50",
+
+  "&:user-invalid": {
+    borderColor: "$brightRed",
+  },
+});
+
+const AuthDialogDivider = styled("div", {
+  display: "flex",
+  alignItems: "center",
+  width: "100%",
+  color: "$black20",
+  fontSize: "$gr3",
+
+  "&::before, &::after": {
+    content: '""',
+    flex: "1",
+    height: "1px",
+    backgroundColor: "$black20",
+  },
+
+  "&::before": {
+    marginRight: "$gr3",
+  },
+
+  "&::after": {
+    marginLeft: "$gr3",
+  },
+});
+
+const IconButton = styled("button", {
+  all: "unset",
+  fontFamily: "inherit",
+  borderRadius: "100%",
+  height: "25px",
+  width: "25px",
+  display: "inline-flex",
+  alignItems: "center",
+  justifyContent: "center",
+  color: "$black50",
+  position: "absolute",
+  top: "10px",
+  right: "10px",
+
+  "&:hover": {
+    backgroundColor: "$gray6",
+  },
+
+  "&:focus": {
+    boxShadow: "0 0 0 2px $gray6",
+  },
+});
+
+export {
+  AuthDialogContent,
+  AuthDialogColumn,
+  AuthDialogDivider,
+  AuthDialogOverlay,
+  AuthDialogTitle,
+  IconButton,
+  MagicLinkInput,
+};
diff --git a/components/Shared/AuthDialog.test.tsx b/components/Shared/AuthDialog.test.tsx
new file mode 100644
index 00000000..0d03c071
--- /dev/null
+++ b/components/Shared/AuthDialog.test.tsx
@@ -0,0 +1,188 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  let mockedAxios: jest.Mocked<typeof axios>;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockedAxios = axios as jest.Mocked<typeof axios>;
+    mockedAxios.get.mockResolvedValue({ data: { enabled: true } });
+  });
+
+  it("renders the primary components", async () => {
+    render(withUserProvider(<AuthDialog />));
+
+    // uesEfect will be called immediately, so we can check if the API was called
+    await waitFor(() => {
+      expect(mockedAxios.get).toHaveBeenCalled();
+    });
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  describe("Magic Link section", () => {
+    beforeEach(() => {
+      jest.clearAllMocks();
+      let mockedAxios = axios as jest.Mocked<typeof axios>;
+      mockedAxios.get.mockResolvedValue({ data: { enabled: true } });
+    });
+
+    it("should not render magic link section when API returns false", async () => {
+      mockedAxios.get.mockResolvedValueOnce({ data: { enabled: false } });
+
+      render(withUserProvider(<AuthDialog />));
+
+      // uesEfect will be called immediately, so we can check if the API was called
+      await waitFor(() => {
+        expect(mockedAxios.get).toHaveBeenCalled();
+      });
+
+      const magicLinkSection = screen.queryByTestId("magic-link");
+      expect(magicLinkSection).not.toBeInTheDocument();
+    });
+
+    it("should render magic link section", async () => {
+      render(withUserProvider(<AuthDialog />));
+
+      // uesEfect will be called immediately, so we can check if the API was called
+      await waitFor(() => {
+        expect(mockedAxios.get).toHaveBeenCalled();
+      });
+
+      const magicLinkSection = screen.queryByTestId("magic-link");
+      expect(magicLinkSection).toBeInTheDocument();
+    });
+
+    it("should disable magic link button with invalid email", async () => {
+      render(withUserProvider(<AuthDialog />));
+
+      // uesEfect will be called immediately, so we can check if the API was called
+      await waitFor(() => {
+        expect(mockedAxios.get).toHaveBeenCalled();
+      });
+
+      const emailInput = screen.getByTestId("magic-link-input");
+      const magicLinkButton = screen.getByTestId("magic-link-button");
+
+      await userEvent.type(emailInput, "invalid-email");
+
+      expect(magicLinkButton).toBeDisabled();
+
+      await userEvent.clear(emailInput);
+      await userEvent.type(emailInput, "valid@example.com");
+
+      expect(magicLinkButton).not.toBeDisabled();
+    });
+
+    it("should show loading spinner when magic link is requested", async () => {
+      render(withUserProvider(<AuthDialog />));
+
+      // uesEfect will be called immediately, so we can check if the API was called
+      await waitFor(() => {
+        expect(mockedAxios.get).toHaveBeenCalled();
+      });
+
+      const emailInput = screen.getByTestId("magic-link-input");
+      const magicLinkButton = screen.getByTestId("magic-link-button");
+
+      await userEvent.type(emailInput, "valid@example.com");
+      await userEvent.click(magicLinkButton);
+
+      const spinner = screen.getByTestId("spin-loader");
+      expect(spinner).toBeInTheDocument();
+    });
+
+    it("should show success message after successful magic link request", async () => {
+      render(withUserProvider(<AuthDialog />));
+
+      // uesEfect will be called immediately, so we can check if the API was called
+      await waitFor(() => {
+        expect(mockedAxios.get).toHaveBeenCalled();
+      });
+
+      const emailInput = screen.getByTestId("magic-link-input");
+      const magicLinkButton = screen.getByTestId("magic-link-button");
+
+      await userEvent.type(emailInput, "valid@example.com");
+      await userEvent.click(magicLinkButton);
+
+      await waitFor(() => {
+        expect(magicLinkButton).toHaveTextContent("Sent! Check your email.");
+      });
+    });
+
+    it("should show error message when magic link request fails", async () => {
+      // mock failed magic link request
+      require("@/lib/dc-api").apiGetRequest = jest.fn(() => {
+        return new Promise((_, reject) => {
+          setTimeout(() => {
+            reject(new Error("Network error"));
+          }, 200);
+        });
+      });
+
+      // Mock console.error to avoid polluting test output
+      jest.spyOn(console, "error").mockImplementation(() => {});
+
+      render(withUserProvider(<AuthDialog />));
+
+      // uesEfect will be called immediately, so we can check if the API was called
+      await waitFor(() => {
+        expect(mockedAxios.get).toHaveBeenCalled();
+      });
+
+      const emailInput = screen.getByTestId("magic-link-input");
+      const magicLinkButton = screen.getByTestId("magic-link-button");
+
+      await userEvent.type(emailInput, "valid@example.com");
+      await userEvent.click(magicLinkButton);
+
+      await waitFor(() => {
+        expect(magicLinkButton).toHaveClass(/isDanger/);
+      });
+    });
+  });
+});
diff --git a/components/Shared/AuthDialog.tsx b/components/Shared/AuthDialog.tsx
new file mode 100644
index 00000000..361069d8
--- /dev/null
+++ b/components/Shared/AuthDialog.tsx
@@ -0,0 +1,173 @@
+import { useEffect, useState } from "react";
+import * as Dialog from "@radix-ui/react-dialog";
+import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
+import { UserContext } from "@/context/user-context";
+import {
+  AuthDialogContent,
+  AuthDialogColumn,
+  AuthDialogDivider,
+  AuthDialogOverlay,
+  AuthDialogTitle,
+  IconButton,
+  MagicLinkInput,
+} from "@/components/Shared/AuthDialog.styled";
+import { Button } from "@nulib/design-system";
+import { useContext } from "react";
+import { DCAPI_ENDPOINT } from "@/lib/constants/endpoints";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import { SpinLoader } from "@/components/Shared/Loader.styled";
+import { useRouter } from "next/router";
+import { apiGetRequest } from "@/lib/dc-api";
+import axios from "axios";
+
+export default function AuthDialog() {
+  const { closeSignInModal, isSignInModalOpen } = useContext(UserContext);
+  const router = useRouter();
+  const [gotoUrl, setGotoUrl] = useState("");
+  const [magicLinkResponse, setMagicLinkResponse] = useState<
+    "idle" | "sending" | "success" | "error"
+  >("idle");
+  const [disableMagicLinkButton, setDisableMagicLinkButton] = useState(false);
+  const [userEmail, setUserEmail] = useState("");
+  const [shouldRenderMagicLink, setShouldRenderMagicLink] = useState(false);
+
+  function handleClose() {
+    closeSignInModal();
+    setMagicLinkResponse("idle");
+  }
+
+  function getGotoLocation(window: Window) {
+    const currentUrl = window.location.href;
+    const url = new URL(currentUrl);
+    url.searchParams.delete("login_modal");
+    return url.toString();
+  }
+
+  function handleEmailChange(e: React.ChangeEvent<HTMLInputElement>) {
+    setDisableMagicLinkButton(!e.target.validity.valid);
+    setUserEmail(e.target.value);
+  }
+
+  async function handleMagicLinkRequest() {
+    setMagicLinkResponse("sending");
+    setDisableMagicLinkButton(true);
+    apiGetRequest({
+      url: `${DCAPI_ENDPOINT}/auth/login/magic?email=${encodeURIComponent(userEmail)}`,
+    })
+      .then(() => {
+        setMagicLinkResponse("success");
+      })
+      .catch((error) => {
+        console.error(error);
+        setMagicLinkResponse("error");
+      });
+    setTimeout(() => {
+      setMagicLinkResponse("idle");
+    }, 2000);
+    setDisableMagicLinkButton(false);
+  }
+
+  useEffect(() => {
+    axios
+      .get(`${DCAPI_ENDPOINT}/capabilities/magic/chat`)
+      .then((response) => {
+        const resp: { enabled: boolean; provider: string; feature: string } =
+          response.data;
+        setShouldRenderMagicLink(resp.enabled);
+      })
+      .catch((error) => {
+        console.error("Error fetching magic link availability:", error);
+        setShouldRenderMagicLink(false);
+      });
+  }, []);
+
+  useEffect(() => {
+    if (!window) return;
+    setGotoUrl(
+      `${DCAPI_ENDPOINT}/auth/login?goto=${encodeURIComponent(getGotoLocation(window))}`,
+    );
+  }, [router.isReady]);
+
+  return (
+    <Dialog.Root open={isSignInModalOpen} onOpenChange={handleClose}>
+      <Dialog.Portal>
+        <AuthDialogOverlay />
+        <AuthDialogContent
+          onEscapeKeyDown={closeSignInModal}
+          data-testid="auth-dialog"
+        >
+          <AuthDialogColumn data-testid="dialog-header">
+            <AuthDialogTitle>Sign in to Digital Collections</AuthDialogTitle>
+            {!shouldRenderMagicLink ? (
+              <VisuallyHidden asChild>
+                <Dialog.Description>
+                  Sign in with your Northwestern NetID
+                </Dialog.Description>
+              </VisuallyHidden>
+            ) : (
+              <Dialog.Description>
+                Choose your preferred login method below
+              </Dialog.Description>
+            )}
+          </AuthDialogColumn>
+
+          <AuthDialogColumn data-testid="sso-login">
+            Login with your NetID {shouldRenderMagicLink && "for full access"}
+            <Button
+              as="a"
+              isPrimary
+              isLowercase
+              //@ts-ignore - by setting as="a" we lose the type but href is set
+              href={gotoUrl}
+            >
+              Northwestern NetID
+            </Button>
+          </AuthDialogColumn>
+
+          {shouldRenderMagicLink && (
+            <>
+              <AuthDialogColumn>
+                <AuthDialogDivider>OR</AuthDialogDivider>
+              </AuthDialogColumn>
+
+              <AuthDialogColumn data-testid="magic-link">
+                <MagicLinkInput
+                  data-testid="magic-link-input"
+                  type="email"
+                  onChange={handleEmailChange}
+                  aria-label="Enter your email address"
+                  placeholder="name@email.com"
+                />
+                <Button
+                  isLight={magicLinkResponse !== "error"}
+                  isDanger={magicLinkResponse === "error"}
+                  onClick={handleMagicLinkRequest}
+                  isLowercase
+                  disabled={disableMagicLinkButton}
+                  data-testid="magic-link-button"
+                >
+                  {magicLinkResponse === "idle" &&
+                    (disableMagicLinkButton
+                      ? "Enter a valid email"
+                      : "Get a temporary magic link")}
+                  {magicLinkResponse === "sending" && (
+                    <SpinLoader data-testid="spin-loader" size={"small"} />
+                  )}
+                  {magicLinkResponse === "success" && "Sent! Check your email."}
+                  {magicLinkResponse === "error" &&
+                    "There was an error sending the link. Please try again."}
+                </Button>
+              </AuthDialogColumn>
+            </>
+          )}
+
+          <Dialog.Close asChild>
+            <IconButton aria-label="Close">
+              <Cross2Icon />
+            </IconButton>
+          </Dialog.Close>
+        </AuthDialogContent>
+      </Dialog.Portal>
+    </Dialog.Root>
+  );
+}
diff --git a/components/Work/RestrictedDisplay.tsx b/components/Work/RestrictedDisplay.tsx
index 0c4dfc01..4fc2a1c4 100644
--- a/components/Work/RestrictedDisplay.tsx
+++ b/components/Work/RestrictedDisplay.tsx
@@ -1,8 +1,8 @@
 import Announcement from "@/components/Shared/Announcement";
 import BlurredBgImage from "@/components/Shared/BlurredBgImage";
-import { DCAPI_ENDPOINT } from "@/lib/constants/endpoints";
-import Link from "next/link";
-import React from "react";
+import { UserContext } from "@/context/user-context";
+import React, { useContext } from "react";
+import { Button } from "@nulib/design-system";
 
 interface Props {
   thumbnail: string | null;
@@ -15,6 +15,7 @@ const WorkRestrictedDisplay: React.FC<Props> = ({
   workId,
   workTitle = "",
 }) => {
+  const { openSignInModal } = useContext(UserContext);
   return (
     <div data-testid="restricted-display">
       {thumbnail && (
@@ -30,12 +31,9 @@ const WorkRestrictedDisplay: React.FC<Props> = ({
         <p>
           This Work requires Northwestern University NetID authentication.
           Please{" "}
-          <Link
-            href={`${DCAPI_ENDPOINT}/auth/login?goto=${window.location}`}
-            style={{ cursor: "pointer" }}
-          >
+          <Button onClick={openSignInModal} isText isLowercase as="a">
             sign in
-          </Link>
+          </Button>
         </p>
         <p>
           If you are not a member of the Northwestern Community, email{" "}
diff --git a/context/user-context.tsx b/context/user-context.tsx
index d6b4c64d..9e6ef0b4 100644
--- a/context/user-context.tsx
+++ b/context/user-context.tsx
@@ -1,16 +1,24 @@
-import React, { ReactNode, createContext, useState } from "react";
+import React, { ReactNode, createContext, useState, useEffect } from "react";
+import { useRouter } from "next/router";
 import {
   type User,
   type UserContext as UserContextType,
 } from "@/types/context/user";
 import { getUser } from "@/lib/user-helpers";
 
-const UserContext = createContext<UserContextType>({ user: null });
+const UserContext = createContext<UserContextType>({
+  user: null,
+  isSignInModalOpen: false,
+  openSignInModal: () => {},
+  closeSignInModal: () => {},
+});
 
 const UserProvider = ({ children }: { children: ReactNode }) => {
   const [user, setUser] = useState<User | null>(null);
+  const [isSignInModalOpen, setIsSignInModalOpen] = useState(false);
+  const router = useRouter();
 
-  React.useEffect(() => {
+  useEffect(() => {
     /* Determine if user is authenticated via cookie */
     getUser().then((result) => {
       if (!result) return;
@@ -39,8 +47,52 @@ const UserProvider = ({ children }: { children: ReactNode }) => {
     });
   }, []);
 
+  useEffect(() => {
+    const { login_modal } = router.query;
+    if (login_modal === "true") {
+      setIsSignInModalOpen(true);
+    }
+  }, [router.query]);
+
+  const openSignInModal = () => {
+    router.push(
+      {
+        pathname: router.pathname,
+        query: { ...router.query, login_modal: "true" },
+      },
+      undefined,
+      { shallow: true },
+    );
+
+    setIsSignInModalOpen(true);
+  };
+
+  const closeSignInModal = () => {
+    const query = { ...router.query };
+    delete query.login_modal;
+    router.push(
+      {
+        pathname: router.pathname,
+        query,
+      },
+      undefined,
+      { shallow: true },
+    );
+
+    setIsSignInModalOpen(false);
+  };
+
   return (
-    <UserContext.Provider value={{ user }}>{children}</UserContext.Provider>
+    <UserContext.Provider
+      value={{
+        user,
+        isSignInModalOpen,
+        openSignInModal,
+        closeSignInModal,
+      }}
+    >
+      {children}
+    </UserContext.Provider>
   );
 };
 
diff --git a/hooks/useGenerativeAISearchToggle.ts b/hooks/useGenerativeAISearchToggle.ts
index 59e7b480..608e5e31 100644
--- a/hooks/useGenerativeAISearchToggle.ts
+++ b/hooks/useGenerativeAISearchToggle.ts
@@ -1,6 +1,4 @@
-import React, { useEffect, useState } from "react";
-
-import { DCAPI_ENDPOINT } from "@/lib/constants/endpoints";
+import React, { useEffect } from "react";
 import { UserContext } from "@/context/user-context";
 import useLocalStorage from "@/hooks/useLocalStorage";
 import { useRouter } from "next/router";
@@ -10,31 +8,22 @@ export const defaultAIState = {
   expires: undefined,
 };
 
-export const defaultModalState = {
-  isOpen: false,
-  title: "Use Generative AI",
-};
-
 export default function useGenerativeAISearchToggle() {
   const router = useRouter();
 
   const [ai, setAI] = useLocalStorage("ai", defaultAIState);
-  const { user } = React.useContext(UserContext);
-
-  const [dialog, setDialog] = useState(defaultModalState);
+  const { user, openSignInModal } = React.useContext(UserContext);
 
   const expires = Date.now() + 1000 * 60 * 60;
   const isAIPreference = ai.enabled === "true";
   const isChecked = isAIPreference && user?.isLoggedIn;
 
-  const loginUrl = `${DCAPI_ENDPOINT}/auth/login?goto=${goToLocation()}`;
-
   // If "ai" is saved in localStorage and the user is
   // not logged in on initial load, show the alert dialog
   useEffect(() => {
     if (!user) return;
-    if (isAIPreference) {
-      setDialog((prevDialog) => ({ ...prevDialog, isOpen: !user?.isLoggedIn }));
+    if (isAIPreference && !user.isLoggedIn) {
+      openSignInModal();
     }
   }, [isAIPreference, user]);
 
@@ -47,25 +36,9 @@ export default function useGenerativeAISearchToggle() {
     }
   }, [router.asPath]);
 
-  function goToLocation() {
-    const currentUrl =
-      typeof window !== "undefined"
-        ? `${window.location.origin}${router.asPath}`
-        : "";
-    if (!currentUrl) return;
-
-    const url = new URL(currentUrl);
-    url.searchParams.set("ai", "true");
-    return encodeURIComponent(url.href);
-  }
-
-  function closeDialog() {
-    setDialog({ ...dialog, isOpen: false });
-  }
-
   function handleCheckChange(checked: boolean) {
     if (!user?.isLoggedIn) {
-      setDialog({ ...dialog, isOpen: checked });
+      openSignInModal();
     } else {
       setAI({
         enabled: checked ? "true" : "false",
@@ -74,15 +47,8 @@ export default function useGenerativeAISearchToggle() {
     }
   }
 
-  function handleLogin() {
-    router.push(loginUrl);
-  }
-
   return {
-    closeDialog,
-    dialog,
     handleCheckChange,
-    handleLogin,
     isChecked,
   };
 }
diff --git a/lib/dc-api.ts b/lib/dc-api.ts
index ac45b992..2a95dcff 100644
--- a/lib/dc-api.ts
+++ b/lib/dc-api.ts
@@ -36,6 +36,7 @@ async function apiGetRequest<R>(
     return rawResponse ? (response as unknown) : work;
   } catch (err) {
     handleError(err);
+    throw err;
   }
 }
 
diff --git a/pages/_app.tsx b/pages/_app.tsx
index e15891ad..e9b90077 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -13,7 +13,7 @@ import { getUser } from "@/lib/user-helpers";
 import globalStyles from "@/styles/global";
 import setupHoneyBadger from "@/lib/honeybadger/config";
 import useLocalStorage from "@/hooks/useLocalStorage";
-
+import AuthDialog from "@/components/Shared/AuthDialog";
 // Init Honeybadger
 setupHoneyBadger();
 
@@ -79,6 +79,7 @@ function MyApp({ Component, pageProps }: MyAppProps) {
             }
           `}</style>
           {mounted && <Component {...pageProps} />}
+          <AuthDialog />
           <GoogleTagManager gtmId="GTM-NDJXLQW" />
         </SearchProvider>
       </UserProvider>
diff --git a/tests/layout.spec.ts b/tests/layout.spec.ts
index 8c922982..8967329f 100644
--- a/tests/layout.spec.ts
+++ b/tests/layout.spec.ts
@@ -21,7 +21,7 @@ test("top header renders logo and links", async ({ page }) => {
     navEl.getByRole("link", { exact: true, name: "Contact" }),
   ).toHaveAttribute("href", "/contact");
 
-  await expect(navEl.getByRole("link", { name: "Sign in" })).toBeVisible();
+  await expect(navEl.getByRole("button", { name: "Sign in" })).toBeVisible();
 });
 
 test("footer renders", async ({ page }) => {
diff --git a/types/context/user.ts b/types/context/user.ts
index d3b8e4e2..e7dc7c93 100644
--- a/types/context/user.ts
+++ b/types/context/user.ts
@@ -15,4 +15,7 @@ export type User = {
 
 export type UserContext = {
   user: User | null;
+  isSignInModalOpen: boolean;
+  openSignInModal: () => void;
+  closeSignInModal: () => void;
 };


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the authentication flow to support Magic Links by routing all login through a global dialog and updating related components and tests. Key changes include:
- Adding modal state and modal control functions to the user context.
- Replacing the previous AlertDialog with a new AuthDialog that supports Magic Links.
- Updating tests and header components to reflect the new dialog-based sign in flow.

### Reviewed Changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                      | Description                                                                      |
| ----------------------------------------- | -------------------------------------------------------------------------------- |
| types/context/user.ts                     | Adds modal state and control functions to the UserContext interface              |
| tests/layout.spec.ts                      | Updates sign in element test to expect a button instead of a link                  |
| pages/_app.tsx                            | Integrates the new AuthDialog component                                          |
| lib/dc-api.ts                            | Propagates errors in API requests by throwing the error after handling            |
| hooks/useGenerativeAISearchToggle.ts      | Refactors login flow by using openSignInModal from UserContext                     |
| context/user-context.tsx                  | Implements modal open/close with router query updates                            |
| components/Shared/AuthDialog.tsx          | Implements the new dialog for sign in with Magic Links                           |
| components/Shared/AuthDialog.test.tsx     | Adds tests for the new AuthDialog functionality                                  |
| components/Shared/AuthDialog.styled.ts      | Provides styled components for the new AuthDialog                                |
| components/Search/GenerativeAIToggle.tsx    | Removes legacy AlertDialog usage in favor of the global sign in dialog             |
| components/Search/GenerativeAIToggle.test.tsx | Updates tests to verify sign in modal behavior                                   |
| components/Header/Super.tsx                | Replaces sign in link with a button that triggers the sign in modal               |
| components/Header/Super.test.tsx           | Updates tests to confirm the presence of the sign in button                        |
| components/Header/Header.styled.ts         | Adds a new styled Login button component in the header                           |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**components/Shared/AuthDialog.tsx:30**
* [nitpick] The variable 'disbableMagicLinkButton' appears to be misspelled. Consider renaming it to 'disableMagicLinkButton' for clarity.
```
const [disbableMagicLinkButton, setDisableMagicLinkButton] = useState(false);
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T20:13:41Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors authentication to route all login flows through a global dialog and adds support for magic link sign-in, controlled by a `NEXT_PUBLIC_MAGIC_LINK_ENABLED` flag.

- Introduce a shared `AuthDialog` component with NetID and magic-link login
- Add modal open/close state to `UserContext` and wire it through various components
- Replace direct auth redirects with `openSignInModal` calls in header, AI toggle, and restricted displays

### Reviewed Changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

<details>
<summary>Show a summary per file</summary>

| File                                    | Description                                           |
|-----------------------------------------|-------------------------------------------------------|
| types/context/user.ts                   | Extend `UserContext` type to include modal controls   |
| context/user-context.tsx                | Implement `openSignInModal`/`closeSignInModal` logic  |
| pages/_app.tsx                          | Mount the `AuthDialog` at the app root                |
| components/Shared/AuthDialog.tsx        | New dialog UI and magic-link feature                  |
| hooks/useGenerativeAISearchToggle.ts    | Use `openSignInModal` instead of inline dialog state  |
| components/Search/GenerativeAIToggle.tsx| Remove old alert dialog and use global modal          |
| components/Work/RestrictedDisplay.tsx   | Swap link redirect for `openSignInModal` button       |
| components/Header/Super.tsx             | Swap header sign-in link for modal trigger            |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**hooks/useGenerativeAISearchToggle.ts:28**
* Include `openSignInModal` in the dependency array of this `useEffect` to avoid stale closures and satisfy React Hooks lint rules.
```
}, [isAIPreference, user]);
```
**components/Shared/AuthDialog.tsx:35**
* [nitpick] Remove the unnecessary optional chaining (`process?`) and reference the env var directly as `process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED` for clarity.
```
const [shouldRenderMagicLink, setShouldRenderMagicLink] = useState(process?.env?.NEXT_PUBLIC_MAGIC_LINK_ENABLED === "true",);
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T20:50:04Z
user: login: kdid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T18:35:33Z
user: login: charlesLoder
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-19T18:44:55Z
user: login: mbklein
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T18:53:15Z
user: login: charlesLoder
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-19T19:10:05Z
user: login: mathewjordan
body: This is so nice. I'm interested in @davidschober  and @vlsrobinson perspective on the modal layout/language. I think there could be some additional guidance there on the perks for being logged in but that I think can be incremented on. Overall it is awesome and really ties together things.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T19:25:00Z
user: login: mathewjordan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T19:25:29Z
user: login: charlesLoder
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-19T19:39:06Z
user: login: kdid
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T21:02:50Z
user: login: mbklein
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T21:57:00Z

Review comments:
diff_hunk: @@ -0,0 +1,177 @@
+import { useEffect, useState } from "react";
+import * as Dialog from "@radix-ui/react-dialog";
+import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
+import { UserContext } from "@/context/user-context";
+import {
+  AuthDialogContent,
+  AuthDialogColumn,
+  AuthDialogDivider,
+  AuthDialogOverlay,
+  AuthDialogTitle,
+  IconButton,
+  MagicLinkInput,
+} from "@/components/Shared/AuthDialog.styled";
+import { Button } from "@nulib/design-system";
+import { useContext } from "react";
+import { DCAPI_ENDPOINT } from "@/lib/constants/endpoints";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import { SpinLoader } from "@/components/Shared/Loader.styled";
+import { useRouter } from "next/router";
+import { apiGetRequest } from "@/lib/dc-api";
+import axios from "axios";
+
+export default function AuthDialog() {
+  const { closeSignInModal, isSignInModalOpen } = useContext(UserContext);
+  const router = useRouter();
+  const [gotoUrl, setGotoUrl] = useState("");
+  const [magicLinkResponse, setMagicLinkResponse] = useState<
+    "idle" | "sending" | "success" | "error"
+  >("idle");
+  const [disableMagicLinkButton, setDisableMagicLinkButton] = useState(false);
+  const [userEmail, setUserEmail] = useState("");
+  // set the environment variable for if we even want to allow magic link login
+  // regardless of if it's available for chat
+  const [shouldRenderMagicLink, setShouldRenderMagicLink] = useState(
+    process?.env?.NEXT_PUBLIC_MAGIC_LINK_ENABLED === "true",
+  );
+
+  function handleClose() {
+    closeSignInModal();
+    setMagicLinkResponse("idle");
+  }
+
+  function getGotoLocation(window: Window) {
+    const currentUrl = window.location.href;
+    const url = new URL(currentUrl);
+    url.searchParams.delete("login_modal");
+    return url.toString();
+  }
+
+  function handleEmailChange(e: React.ChangeEvent<HTMLInputElement>) {
+    setDisableMagicLinkButton(!e.target.validity.valid);
+    setUserEmail(e.target.value);
+  }
+
+  async function handleMagicLinkRequest() {
+    setMagicLinkResponse("sending");
+    apiGetRequest({
+      url: `${DCAPI_ENDPOINT}/auth/login/magic?email=${encodeURIComponent(userEmail)}`,
+    })
+      .then(() => {
+        setMagicLinkResponse("success");
+      })
+      .catch((error) => {
+        console.error(error);
+        setMagicLinkResponse("error");
+      });
+    setTimeout(() => {
+      setMagicLinkResponse("idle");
+    }, 2000);
+  }
+
+  useEffect(() => {
+    // if we want to render the magic link button,
+    // check the api if it's available for chat
+    if (shouldRenderMagicLink) {
+      axios
+        .get(`${DCAPI_ENDPOINT}/capabilities/magic/chat`)
+        .then((response) => {
+          const resp: { enabled: boolean; provider: string; feature: string } =
+            JSON.parse(response.data);
+          setShouldRenderMagicLink(resp.enabled);
+        })
+        .catch((error) => {
+          console.error("Error fetching magic link availability:", error);
+          setShouldRenderMagicLink(false);
+        });
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!window) return;
+    setGotoUrl(
+      `${DCAPI_ENDPOINT}/auth/login?goto=${encodeURIComponent(getGotoLocation(window))}`,
+    );
+  }, [router.isReady]);
+
+  return (
+    <Dialog.Root open={isSignInModalOpen} onOpenChange={handleClose}>
+      <Dialog.Portal>
+        <AuthDialogOverlay />
+        <AuthDialogContent
+          onEscapeKeyDown={closeSignInModal}
+          data-testid="auth-dialog"
+        >
+          <AuthDialogColumn data-testid="dialog-header">
+            <AuthDialogTitle>Sign in to Digital Collections</AuthDialogTitle>
+            {!shouldRenderMagicLink ? (
+              <VisuallyHidden asChild>
+                <Dialog.Description>
+                  Sign in with your Northwestern NetID
+                </Dialog.Description>
+              </VisuallyHidden>
+            ) : (
+              <Dialog.Description>
+                Choose your preferred login method below
+              </Dialog.Description>
+            )}
+          </AuthDialogColumn>
+
+          <AuthDialogColumn data-testid="sso-login">
+            Login with your NetID {shouldRenderMagicLink && "for full access"}
+            <Button
+              as="a"
+              isPrimary
+              isLowercase
+              //@ts-ignore - by setting as="a" we lose the type but href is set
+              href={gotoUrl}
+            >
+              Northwestern NetID
+            </Button>
+          </AuthDialogColumn>
+
+          {shouldRenderMagicLink && (
+            <>
+              <AuthDialogColumn>
+                <AuthDialogDivider>OR</AuthDialogDivider>
+              </AuthDialogColumn>
+
+              <AuthDialogColumn data-testid="magic-link">
+                <MagicLinkInput
+                  data-testid="magic-link-input"
+                  type="email"
+                  onChange={handleEmailChange}
+                />
+                <Button
+                  isLight={magicLinkResponse !== "error"}
+                  isDanger={magicLinkResponse === "error"}
+                  onClick={handleMagicLinkRequest}
+                  isLowercase
+                  disabled={disableMagicLinkButton}
path: components/Shared/AuthDialog.tsx
user: login: Copilot
body: Prevent duplicate requests by also disabling the button when `magicLinkResponse === 'sending'`, e.g. `disabled={disableMagicLinkButton || magicLinkResponse === 'sending'}`.
```suggestion
                  disabled={disableMagicLinkButton || magicLinkResponse === "sending"}
```
created_at: 2025-05-16T20:50:03Z
author_association: NONE
diff_hunk: @@ -0,0 +1,177 @@
+import { useEffect, useState } from "react";
+import * as Dialog from "@radix-ui/react-dialog";
+import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
+import { UserContext } from "@/context/user-context";
+import {
+  AuthDialogContent,
+  AuthDialogColumn,
+  AuthDialogDivider,
+  AuthDialogOverlay,
+  AuthDialogTitle,
+  IconButton,
+  MagicLinkInput,
+} from "@/components/Shared/AuthDialog.styled";
+import { Button } from "@nulib/design-system";
+import { useContext } from "react";
+import { DCAPI_ENDPOINT } from "@/lib/constants/endpoints";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import { SpinLoader } from "@/components/Shared/Loader.styled";
+import { useRouter } from "next/router";
+import { apiGetRequest } from "@/lib/dc-api";
+import axios from "axios";
+
+export default function AuthDialog() {
+  const { closeSignInModal, isSignInModalOpen } = useContext(UserContext);
+  const router = useRouter();
+  const [gotoUrl, setGotoUrl] = useState("");
+  const [magicLinkResponse, setMagicLinkResponse] = useState<
+    "idle" | "sending" | "success" | "error"
+  >("idle");
+  const [disableMagicLinkButton, setDisableMagicLinkButton] = useState(false);
+  const [userEmail, setUserEmail] = useState("");
+  // set the environment variable for if we even want to allow magic link login
+  // regardless of if it's available for chat
+  const [shouldRenderMagicLink, setShouldRenderMagicLink] = useState(
+    process?.env?.NEXT_PUBLIC_MAGIC_LINK_ENABLED === "true",
+  );
+
+  function handleClose() {
+    closeSignInModal();
+    setMagicLinkResponse("idle");
+  }
+
+  function getGotoLocation(window: Window) {
+    const currentUrl = window.location.href;
+    const url = new URL(currentUrl);
+    url.searchParams.delete("login_modal");
+    return url.toString();
+  }
+
+  function handleEmailChange(e: React.ChangeEvent<HTMLInputElement>) {
+    setDisableMagicLinkButton(!e.target.validity.valid);
+    setUserEmail(e.target.value);
+  }
+
+  async function handleMagicLinkRequest() {
+    setMagicLinkResponse("sending");
+    apiGetRequest({
+      url: `${DCAPI_ENDPOINT}/auth/login/magic?email=${encodeURIComponent(userEmail)}`,
+    })
+      .then(() => {
+        setMagicLinkResponse("success");
+      })
+      .catch((error) => {
+        console.error(error);
+        setMagicLinkResponse("error");
+      });
+    setTimeout(() => {
+      setMagicLinkResponse("idle");
+    }, 2000);
+  }
+
+  useEffect(() => {
+    // if we want to render the magic link button,
+    // check the api if it's available for chat
+    if (shouldRenderMagicLink) {
+      axios
+        .get(`${DCAPI_ENDPOINT}/capabilities/magic/chat`)
+        .then((response) => {
+          const resp: { enabled: boolean; provider: string; feature: string } =
+            JSON.parse(response.data);
+          setShouldRenderMagicLink(resp.enabled);
+        })
+        .catch((error) => {
+          console.error("Error fetching magic link availability:", error);
+          setShouldRenderMagicLink(false);
+        });
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!window) return;
+    setGotoUrl(
+      `${DCAPI_ENDPOINT}/auth/login?goto=${encodeURIComponent(getGotoLocation(window))}`,
+    );
+  }, [router.isReady]);
+
+  return (
+    <Dialog.Root open={isSignInModalOpen} onOpenChange={handleClose}>
+      <Dialog.Portal>
+        <AuthDialogOverlay />
+        <AuthDialogContent
+          onEscapeKeyDown={closeSignInModal}
+          data-testid="auth-dialog"
+        >
+          <AuthDialogColumn data-testid="dialog-header">
+            <AuthDialogTitle>Sign in to Digital Collections</AuthDialogTitle>
+            {!shouldRenderMagicLink ? (
+              <VisuallyHidden asChild>
+                <Dialog.Description>
+                  Sign in with your Northwestern NetID
+                </Dialog.Description>
+              </VisuallyHidden>
+            ) : (
+              <Dialog.Description>
+                Choose your preferred login method below
+              </Dialog.Description>
+            )}
+          </AuthDialogColumn>
+
+          <AuthDialogColumn data-testid="sso-login">
+            Login with your NetID {shouldRenderMagicLink && "for full access"}
+            <Button
+              as="a"
+              isPrimary
+              isLowercase
+              //@ts-ignore - by setting as="a" we lose the type but href is set
+              href={gotoUrl}
+            >
+              Northwestern NetID
path: components/Shared/AuthDialog.tsx
user: login: Copilot
body: Instead of ignoring the type error, use the `asChild` prop on `Button` to wrap a native `<a href={gotoUrl}>` and preserve correct typing.
```suggestion
              asChild
              isPrimary
              isLowercase
            >
              <a href={gotoUrl}>
                Northwestern NetID
              </a>
```
created_at: 2025-05-16T20:50:03Z
author_association: NONE
diff_hunk: @@ -0,0 +1,177 @@
+import { useEffect, useState } from "react";
+import * as Dialog from "@radix-ui/react-dialog";
+import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
+import { UserContext } from "@/context/user-context";
+import {
+  AuthDialogContent,
+  AuthDialogColumn,
+  AuthDialogDivider,
+  AuthDialogOverlay,
+  AuthDialogTitle,
+  IconButton,
+  MagicLinkInput,
+} from "@/components/Shared/AuthDialog.styled";
+import { Button } from "@nulib/design-system";
+import { useContext } from "react";
+import { DCAPI_ENDPOINT } from "@/lib/constants/endpoints";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import { SpinLoader } from "@/components/Shared/Loader.styled";
+import { useRouter } from "next/router";
+import { apiGetRequest } from "@/lib/dc-api";
+import axios from "axios";
+
+export default function AuthDialog() {
+  const { closeSignInModal, isSignInModalOpen } = useContext(UserContext);
+  const router = useRouter();
+  const [gotoUrl, setGotoUrl] = useState("");
+  const [magicLinkResponse, setMagicLinkResponse] = useState<
+    "idle" | "sending" | "success" | "error"
+  >("idle");
+  const [disableMagicLinkButton, setDisableMagicLinkButton] = useState(false);
+  const [userEmail, setUserEmail] = useState("");
+  // set the environment variable for if we even want to allow magic link login
+  // regardless of if it's available for chat
+  const [shouldRenderMagicLink, setShouldRenderMagicLink] = useState(
+    process?.env?.NEXT_PUBLIC_MAGIC_LINK_ENABLED === "true",
+  );
+
+  function handleClose() {
+    closeSignInModal();
+    setMagicLinkResponse("idle");
path: components/Shared/AuthDialog.tsx
user: login: Copilot
body: Reset `userEmail` and `disableMagicLinkButton` alongside `magicLinkResponse` in `handleClose` so form inputs are cleared when the dialog reopens.
```suggestion
    setMagicLinkResponse("idle");
    setUserEmail("");
    setDisableMagicLinkButton(false);
```
created_at: 2025-05-16T20:50:04Z
author_association: NONE
diff_hunk: @@ -0,0 +1,173 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  it("renders the primary components", () => {
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it("should not render magic link section", () => {
+    // update process.env to not include magic link
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const magicLinkSection = screen.queryByTestId("magic-link");
+
+    expect(magicLinkSection).not.toBeInTheDocument();
+  });
+
+  describe("Magic Link enabled", () => {
+    beforeEach(() => {
+      process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "true";
path: components/Shared/AuthDialog.test.tsx
user: login: kdid
body: @charlesLoder I'm questioning why we need this variable. If our overall goal is to remove as much logic as possible from the front end, if this is needed we should add `/capabilities/magic/login` route to the API. But is that even necessary at this point? 
created_at: 2025-05-19T18:35:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,173 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  it("renders the primary components", () => {
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it("should not render magic link section", () => {
+    // update process.env to not include magic link
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const magicLinkSection = screen.queryByTestId("magic-link");
+
+    expect(magicLinkSection).not.toBeInTheDocument();
+  });
+
+  describe("Magic Link enabled", () => {
+    beforeEach(() => {
+      process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "true";
path: components/Shared/AuthDialog.test.tsx
user: login: charlesLoder
body: It isn't entirely needed, but I think it can serve a purpose.

I'm imagining a scenario where it is requested we immediately remove the magic link functionality because of reasons from on high. We could simply change the env variable in Amplify and redeploy, which is faster than updating the api. Maybe that isn't a scenario that would happen though
created_at: 2025-05-19T18:44:55Z
author_association: NONE
diff_hunk: @@ -0,0 +1,173 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  it("renders the primary components", () => {
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it("should not render magic link section", () => {
+    // update process.env to not include magic link
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const magicLinkSection = screen.queryByTestId("magic-link");
+
+    expect(magicLinkSection).not.toBeInTheDocument();
+  });
+
+  describe("Magic Link enabled", () => {
+    beforeEach(() => {
+      process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "true";
path: components/Shared/AuthDialog.test.tsx
user: login: mbklein
body: I have the same question about the need for the toggle variable – changing the response from the `/capabilities/magic/login` route in the API is an easy and immediate toggle, without having to redeploy anything, so I think it'd be simpler to leave it there.
created_at: 2025-05-19T18:53:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  it("renders the primary components", () => {
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it("should not render magic link section", () => {
+    // update process.env to not include magic link
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const magicLinkSection = screen.queryByTestId("magic-link");
+
+    expect(magicLinkSection).not.toBeInTheDocument();
+  });
+
+  describe("Magic Link enabled", () => {
+    beforeEach(() => {
+      process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "true";
path: components/Shared/AuthDialog.test.tsx
user: login: charlesLoder
body: That works for me
created_at: 2025-05-19T19:10:05Z
author_association: NONE
diff_hunk: @@ -0,0 +1,173 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  it("renders the primary components", () => {
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it("should not render magic link section", () => {
+    // update process.env to not include magic link
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const magicLinkSection = screen.queryByTestId("magic-link");
+
+    expect(magicLinkSection).not.toBeInTheDocument();
+  });
+
+  describe("Magic Link enabled", () => {
+    beforeEach(() => {
+      process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "true";
path: components/Shared/AuthDialog.test.tsx
user: login: mathewjordan
body: I'll defer to @kdid and @mbklein on this one.
created_at: 2025-05-19T19:25:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import React from "react";
+import AuthDialog from "./AuthDialog";
+import userEvent from "@testing-library/user-event";
+import axios from "axios";
+
+jest.mock("axios");
+jest.mock("@/lib/dc-api", () => ({
+  apiGetRequest: jest.fn(
+    () => new Promise((resolve) => setTimeout(resolve, 1000)),
+  ),
+}));
+
+const defaultUserContext: UserContextType = {
+  user: null,
+  isSignInModalOpen: true,
+  openSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = true;
+  }),
+  closeSignInModal: jest.fn(() => {
+    defaultUserContext.isSignInModalOpen = false;
+  }),
+};
+
+const withUserProvider = (
+  Component: React.ReactNode,
+  userContext: UserContextType = defaultUserContext,
+) => {
+  return (
+    <UserContext.Provider value={userContext}>{Component}</UserContext.Provider>
+  );
+};
+
+describe("AuthDialog", () => {
+  it("renders the primary components", () => {
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const dialogHeader = screen.getByTestId("dialog-header");
+    expect(dialogHeader).toBeInTheDocument();
+
+    const ssoLogin = screen.getByTestId("sso-login");
+    expect(ssoLogin).toBeInTheDocument();
+
+    const closeButton = screen.getByRole("button", {
+      name: /Close/i,
+    });
+
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it("should not render magic link section", () => {
+    // update process.env to not include magic link
+    process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "false";
+    render(withUserProvider(<AuthDialog />));
+
+    const magicLinkSection = screen.queryByTestId("magic-link");
+
+    expect(magicLinkSection).not.toBeInTheDocument();
+  });
+
+  describe("Magic Link enabled", () => {
+    beforeEach(() => {
+      process.env.NEXT_PUBLIC_MAGIC_LINK_ENABLED = "true";
path: components/Shared/AuthDialog.test.tsx
user: login: charlesLoder
body: var removed and tests updated
created_at: 2025-05-19T19:39:06Z
author_association: NONE

PR comments:
user: login: mathewjordan
created_at: 2025-05-19T19:30:49Z
body: Followup: I just noted that this hasn't been rebased on the scopes work by @kdid in #424. We may want to verify that everything comes in cleanly without conflict.
author_association: MEMBER
user: login: kdid
created_at: 2025-05-19T19:32:49Z
body: @charlesLoder - Functionality looks good. I think we need some indication here of what people are supposed to put in the form field (placeholder text that says "email"?) It stopped me for a uncertain sec and I know behind the scenes what's needed.

<img width="674" alt="Screenshot 2025-05-19 at 12 29 52 PM" src="https://github.com/user-attachments/assets/82c4d71d-bc29-4ef3-bd09-79cc1826e7b1" />

author_association: CONTRIBUTOR
user: login: charlesLoder
created_at: 2025-05-19T20:02:15Z
body: @mathewjordan — rebased

@kdid — placeholder and aria added (Lastpass kept adding my email so I never noticed the missing placeholder!)
author_association: NONE
user: login: mathewjordan
created_at: 2025-05-19T20:55:21Z
body: Nice. Seeing those beautiful locks now.

<img width="1745" alt="image" src="https://github.com/user-attachments/assets/4865544b-abec-4fa9-9036-1ddd0e524b1e" />

author_association: MEMBER