diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md
index 0fdc5618..5eed2d89 100644
--- a/PR_DESCRIPTION.md
+++ b/PR_DESCRIPTION.md
@@ -1,25 +1,55 @@
-# Fix: Add "Request Invite" Button to Mobile Nav (Pre-Login)
+# Feat: Legacy Auth Parity for Eyebrow + Magic Login Flows
## Description
-This PR addresses Issue #251 by adding a visible "Request Invite" button to the mobile navigation header for unauthenticated users. It also introduces a cleaner `/invite` route and ensures consistent behavior across the application.
+This PR brings the legacy monorepo behavior in line with the Next implementation for the guest eyebrow CTA and magic-login/onboarding entry points.
+
+It adds the missing backend onboarding-link mutation, updates the eyebrow UX to handle all status branches consistently, and adds targeted test coverage for critical parity paths on both client and server.
## Changes
-- **Added `/invite` Route**: Created a new route in `mui-routes.jsx` that maps to the `RequestAccessPage`.
-- **Routing Fix**: Updated `main.jsx` and `Auth.jsx` to correctly handle the top-level `/invite` route using the `AuthLayout`.
-- **Navbar Fix**: Updated `AuthNavbar.jsx` to ensure navigation buttons (Go Back, Login) appear correctly on the `/invite` route, and updated the "Get Access" link on the login page to point to `/invite`.
-- **Mobile Header Update**: Added a "Request Invite" button to the `MainNavBar` toolbar.
- - **Positioning**: Grouped the button with the hamburger menu using a flex container to ensure they appear together on the right side of the toolbar.
- - **Visibility**: Visible only on mobile screens (`Hidden mdUp`) when the user is not logged in.
-- **Route Updates**: Updated existing "Request Invite" buttons (desktop and mobile drawer) to use the new `/invite` route.
-- **Auth Redirect**: Updated `requireAuth` in `auth.js` to redirect to `/invite` instead of `/auth/request-access`.
-- **UX Improvements**: The "Request Invite" button is hidden if the user is already on the `/invite` page to prevent redundant navigation.
+- **Eyebrow CTA parity (`client`)**
+ - Refactored `EyebrowBar` flow to branch by `checkEmailStatus` and show modal-based actions for:
+ - `registered` -> login options modal (magic link or password login)
+ - `approved_no_password` -> onboarding completion modal
+ - Added dismiss behavior and preserved layout offset updates (`--eyebrow-height`).
+ - Normalized email input (`trim`) before network calls.
+ - Updated mutation handling to honor backend payloads (`success/message`) instead of assuming transport-level success.
+ - Added fallback behavior: if magic-link mutation returns incomplete-signup response, transition to onboarding modal.
+
+- **Client GraphQL contract updates**
+ - Added `SEND_ONBOARDING_COMPLETION_LINK` mutation in `client/src/graphql/mutations.jsx`.
+
+- **Magic login page hardening (`client`)**
+ - Kept token verification/login redirect flow aligned with parity behavior.
+ - Added tests for missing token, invalid token, valid token login dispatch + redirect, and expired token messaging.
+
+- **Backend parity (`server`)**
+ - Added new resolver `sendOnboardingCompletionLink`:
+ - sends tokenized `/auth/signup?token=...` onboarding link for approved users without passwords
+ - returns generic success for non-eligible accounts to avoid account enumeration
+ - Wired resolver into mutation exports and root mutation map.
+ - Added schema mutation definition: `sendOnboardingCompletionLink(email: String!): JSON`.
+ - Allowed public access for new onboarding-link mutation via `requireAuth` allowlist.
+
+- **Automated tests added**
+ - `client/src/components/EyebrowBar/EyebrowBar.test.jsx`
+ - `client/src/views/MagicLoginPage/MagicLoginPage.test.jsx`
+ - `server/app/tests/queries/user/checkEmailStatus.test.js`
+ - `server/app/tests/mutations/user/sendMagicLoginLink.test.js`
+ - `server/app/tests/mutations/user/sendOnboardingCompletionLink.test.js`
## Verification
-- **Mobile View**: Confirmed that the "Request Invite" button appears in the header on mobile screens for unauthenticated users, positioned correctly next to the menu icon.
-- **Navigation**: Verified that clicking the button navigates to `/invite`.
-- **Layout**: Verified that `/invite` renders with the correct `AuthLayout` and that the `AuthNavbar` buttons (Go Back, Login) are visible.
-- **Visibility Logic**: Confirmed the button is hidden when logged in or when already on the `/invite` page.
-- **Backward Compatibility**: The old `/auth/request-access` route remains functional (via the component mapping), but the app now prefers `/invite`.
-
-## Related Issue
-Fixes #251
+- **Client tests**
+ - `npm run test -- src/components/EyebrowBar/EyebrowBar.test.jsx src/views/MagicLoginPage/MagicLoginPage.test.jsx`
+ - Result: passing (11 tests)
+
+- **Server tests**
+ - `npm run test -- --runInBand app/tests/queries/user/checkEmailStatus.test.js app/tests/mutations/user/sendMagicLoginLink.test.js app/tests/mutations/user/sendOnboardingCompletionLink.test.js`
+ - Result: passing (11 tests)
+
+- **Lint / style**
+ - Client files linted and auto-fixed for repository rules; remaining warnings are existing workspace alias-resolution warnings for `@/...` imports.
+ - Prettier was applied to all files changed in this PR.
+
+## Notes
+- This PR intentionally focuses on parity-critical auth paths and their regression coverage.
+- A full-suite regression run can be done separately if broader release confidence is required.
diff --git a/client/src/assets/jss/material-dashboard-pro-react/layouts/adminStyle.jsx b/client/src/assets/jss/material-dashboard-pro-react/layouts/adminStyle.jsx
index 22e2ce88..d580cbca 100644
--- a/client/src/assets/jss/material-dashboard-pro-react/layouts/adminStyle.jsx
+++ b/client/src/assets/jss/material-dashboard-pro-react/layouts/adminStyle.jsx
@@ -94,7 +94,7 @@ const appStyle = (theme) => ({
content: {
flexGrow: 1,
height: '100%',
- marginTop: theme.spacing(10),
+ marginTop: `calc(${theme.spacing(10)}px + var(--eyebrow-height, 0px))`,
overflow: 'hidden',
width: '70%',
marginLeft: 'auto',
@@ -102,14 +102,14 @@ const appStyle = (theme) => ({
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
[theme.breakpoints.down('sm')]: {
- marginTop: theme.spacing(7),
+ marginTop: `calc(${theme.spacing(7)}px + var(--eyebrow-height, 0px))`,
width: '100%',
},
},
contentChat: {
flexGrow: 1,
height: '100%',
- marginTop: theme.spacing(10),
+ marginTop: `calc(${theme.spacing(10)}px + var(--eyebrow-height, 0px))`,
[theme.breakpoints.up('md')]: {
marginRight: drawerWidth,
},
diff --git a/client/src/components/EyebrowBar/EyebrowBar.jsx b/client/src/components/EyebrowBar/EyebrowBar.jsx
new file mode 100644
index 00000000..e6c8713c
--- /dev/null
+++ b/client/src/components/EyebrowBar/EyebrowBar.jsx
@@ -0,0 +1,444 @@
+import {
+ useState, useRef, useEffect, useCallback,
+} from 'react'
+import { makeStyles } from '@material-ui/core/styles'
+import {
+ Button,
+ Input,
+ Typography,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+} from '@material-ui/core'
+import { useSelector } from 'react-redux'
+import { useApolloClient, useMutation } from '@apollo/react-hooks'
+import { useHistory } from 'react-router-dom'
+import { CHECK_EMAIL_STATUS } from '@/graphql/query'
+import {
+ REQUEST_USER_ACCESS_MUTATION,
+ SEND_MAGIC_LOGIN_LINK,
+ SEND_ONBOARDING_COMPLETION_LINK,
+} from '@/graphql/mutations'
+
+const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ right: 0,
+ zIndex: theme.zIndex.appBar + 1,
+ background:
+ 'linear-gradient(135deg, #2AE6B2 0%, #27C4E1 50%, #178BE1 100%)',
+ padding: theme.spacing(1, 2),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: theme.spacing(1.5),
+ flexWrap: 'wrap',
+ [theme.breakpoints.down('xs')]: {
+ flexDirection: 'column',
+ padding: theme.spacing(1.5, 2),
+ gap: theme.spacing(1),
+ },
+ },
+ closeButton: {
+ position: 'absolute',
+ top: 8,
+ right: 12,
+ color: '#fff',
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ textDecoration: 'underline',
+ fontSize: '0.8rem',
+ fontWeight: 500,
+ padding: 0,
+ '&:hover': {
+ opacity: 0.85,
+ },
+ },
+ prompt: {
+ color: '#fff',
+ fontSize: '0.875rem',
+ fontWeight: 600,
+ whiteSpace: 'nowrap',
+ [theme.breakpoints.down('xs')]: {
+ fontSize: '0.8rem',
+ textAlign: 'center',
+ },
+ },
+ inputWrapper: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(1),
+ [theme.breakpoints.down('xs')]: {
+ width: '100%',
+ flexDirection: 'column',
+ },
+ },
+ emailInput: {
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ borderRadius: 20,
+ padding: theme.spacing(0.5, 2),
+ fontSize: '0.875rem',
+ minWidth: 240,
+ '& input': {
+ padding: theme.spacing(0.5, 0),
+ },
+ [theme.breakpoints.down('xs')]: {
+ width: '100%',
+ minWidth: 'unset',
+ },
+ },
+ continueButton: {
+ backgroundColor: '#fff',
+ color: '#178BE1',
+ borderRadius: 20,
+ padding: theme.spacing(0.5, 3),
+ textTransform: 'none',
+ fontWeight: 600,
+ fontSize: '0.875rem',
+ whiteSpace: 'nowrap',
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ },
+ [theme.breakpoints.down('xs')]: {
+ width: '100%',
+ },
+ },
+ responseArea: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: theme.spacing(1.5),
+ flexWrap: 'wrap',
+ [theme.breakpoints.down('xs')]: {
+ flexDirection: 'column',
+ width: '100%',
+ },
+ },
+ message: {
+ color: '#fff',
+ fontSize: '0.875rem',
+ fontWeight: 500,
+ textAlign: 'center',
+ },
+ actionButton: {
+ backgroundColor: '#fff',
+ color: '#178BE1',
+ borderRadius: 20,
+ padding: theme.spacing(0.5, 2.5),
+ textTransform: 'none',
+ fontWeight: 600,
+ fontSize: '0.8rem',
+ whiteSpace: 'nowrap',
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ },
+ },
+ linkButton: {
+ color: '#fff',
+ textDecoration: 'underline',
+ fontSize: '0.8rem',
+ fontWeight: 500,
+ cursor: 'pointer',
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ '&:hover': {
+ opacity: 0.85,
+ },
+ },
+ errorText: {
+ color: '#ffcdd2',
+ fontSize: '0.8rem',
+ fontWeight: 500,
+ },
+}))
+
+export default function EyebrowBar() {
+ const classes = useStyles()
+ const history = useHistory()
+ const client = useApolloClient()
+ const loggedIn = useSelector((state) => !!state.user.data._id)
+ const barRef = useRef(null)
+ const [isDismissed, setIsDismissed] = useState(false)
+
+ const [email, setEmail] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [feedback, setFeedback] = useState('')
+ const [error, setError] = useState('')
+ const [isLoginOptionsModalOpen, setIsLoginOptionsModalOpen] = useState(false)
+ const [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false)
+ const [isOnboardingLinkLoading, setIsOnboardingLinkLoading] = useState(false)
+
+ const [requestUserAccess] = useMutation(REQUEST_USER_ACCESS_MUTATION)
+ const [sendMagicLink] = useMutation(SEND_MAGIC_LOGIN_LINK)
+ const [sendOnboardingCompletionLink] = useMutation(
+ SEND_ONBOARDING_COMPLETION_LINK,
+ )
+
+ const updateHeight = useCallback(() => {
+ if (barRef.current) {
+ const height = barRef.current.offsetHeight
+ document.documentElement.style.setProperty(
+ '--eyebrow-height',
+ `${height}px`,
+ )
+ }
+ }, [])
+
+ // Set/clear the CSS variable based on visibility
+ useEffect(() => {
+ const isVisible = !(loggedIn || isDismissed)
+
+ if (!isVisible) {
+ document.documentElement.style.setProperty('--eyebrow-height', '0px')
+ } else {
+ updateHeight()
+ window.addEventListener('resize', updateHeight)
+ }
+
+ return () => {
+ if (isVisible) {
+ window.removeEventListener('resize', updateHeight)
+ }
+ document.documentElement.style.setProperty('--eyebrow-height', '0px')
+ }
+ }, [loggedIn, isDismissed, isLoading, feedback, updateHeight])
+
+ if (loggedIn || isDismissed) return null
+
+ const handleContinue = async () => {
+ const normalizedEmail = email.trim()
+
+ setError('')
+ setFeedback('')
+ setIsLoginOptionsModalOpen(false)
+ setIsOnboardingModalOpen(false)
+
+ if (!EMAIL_VALIDATION_PATTERN.test(normalizedEmail)) {
+ setError('Please enter a valid email address.')
+ return
+ }
+
+ if (normalizedEmail !== email) {
+ setEmail(normalizedEmail)
+ }
+
+ setIsLoading(true)
+
+ try {
+ const { data } = await client.query({
+ query: CHECK_EMAIL_STATUS,
+ variables: { email: normalizedEmail },
+ fetchPolicy: 'network-only',
+ })
+
+ const emailStatus = data?.checkEmailStatus?.status
+
+ switch (emailStatus) {
+ case 'not_requested':
+ // Auto-submit invite request
+ await requestUserAccess({
+ variables: { requestUserAccessInput: { email: normalizedEmail } },
+ })
+ setFeedback(
+ "Your request has been received! You'll be notified once approved.",
+ )
+ break
+
+ case 'requested_pending':
+ setFeedback('Your invite request is still waiting for approval.')
+ break
+
+ case 'approved_no_password':
+ setFeedback('')
+ setIsOnboardingModalOpen(true)
+ break
+
+ case 'registered':
+ setFeedback('')
+ setIsLoginOptionsModalOpen(true)
+ break
+
+ default:
+ setFeedback('An error has occurred')
+ break
+ }
+ } catch (err) {
+ setError(err.message || 'An error has occurred')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleSendMagicLink = async () => {
+ setError('')
+
+ try {
+ setFeedback('Sending login link...')
+ const { data } = await sendMagicLink({ variables: { email } })
+ const result = data?.sendMagicLoginLink
+
+ if (!result?.success) {
+ setIsLoginOptionsModalOpen(false)
+ setIsOnboardingModalOpen(true)
+ setFeedback('')
+
+ if (
+ result?.message &&
+ result.message !== 'This account has not completed signup yet.'
+ ) {
+ setError(result.message)
+ }
+
+ return
+ }
+
+ setIsLoginOptionsModalOpen(false)
+ setFeedback(result?.message || 'Login link sent! Check your email.')
+ } catch (err) {
+ setError(err.message || 'Failed to send login link.')
+ }
+ }
+
+ const handleSendOnboardingLink = async () => {
+ setError('')
+ setIsOnboardingLinkLoading(true)
+
+ try {
+ setFeedback('Sending onboarding link...')
+ const { data } = await sendOnboardingCompletionLink({
+ variables: { email },
+ })
+ const result = data?.sendOnboardingCompletionLink
+
+ if (result?.success === false) {
+ setError(result.message || 'Failed to send onboarding link.')
+ setFeedback('')
+ return
+ }
+
+ setIsOnboardingModalOpen(false)
+ setFeedback(result?.message || 'Onboarding link sent! Check your email.')
+ } catch (err) {
+ setError(err.message || 'Failed to send onboarding link.')
+ } finally {
+ setIsOnboardingLinkLoading(false)
+ }
+ }
+
+ const handleReset = () => {
+ setEmail('')
+ setFeedback('')
+ setError('')
+ setIsLoginOptionsModalOpen(false)
+ setIsOnboardingModalOpen(false)
+ }
+
+ return (
+
+
+
Join Quote Vote
+
+ {
+ setEmail(e.target.value)
+ if (error) setError('')
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && handleContinue()}
+ className={classes.emailInput}
+ />
+
+
+
+ {error &&
{error}}
+
+ {!!feedback && (
+
+ {feedback}
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/EyebrowBar/EyebrowBar.test.jsx b/client/src/components/EyebrowBar/EyebrowBar.test.jsx
new file mode 100644
index 00000000..7a40b5b1
--- /dev/null
+++ b/client/src/components/EyebrowBar/EyebrowBar.test.jsx
@@ -0,0 +1,191 @@
+import {
+ render, screen, fireEvent, waitFor,
+} from '@testing-library/react'
+import {
+ vi, beforeEach, describe, it, expect,
+} from 'vitest'
+import EyebrowBar from './EyebrowBar'
+
+const mockPush = vi.fn()
+const mockQuery = vi.fn()
+const mockRequestUserAccess = vi.fn()
+const mockSendMagicLink = vi.fn()
+const mockSendOnboardingLink = vi.fn()
+const mockUseSelector = vi.fn()
+
+vi.mock('react-router-dom', () => ({
+ useHistory: () => ({ push: mockPush }),
+}))
+
+vi.mock('react-redux', () => ({
+ useSelector: (selector) => mockUseSelector(selector),
+}))
+
+vi.mock('@apollo/react-hooks', () => ({
+ useApolloClient: () => ({ query: mockQuery }),
+ useMutation: (mutation) => {
+ const source = String(mutation?.loc?.source?.body || '')
+ if (source.includes('sendMagicLoginLink')) {
+ return [mockSendMagicLink]
+ }
+ if (source.includes('sendOnboardingCompletionLink')) {
+ return [mockSendOnboardingLink]
+ }
+ return [mockRequestUserAccess]
+ },
+}))
+
+describe('EyebrowBar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseSelector.mockImplementation((selector) => selector({ user: { data: {} } }),)
+ mockQuery.mockResolvedValue({
+ data: { checkEmailStatus: { status: 'not_requested' } },
+ })
+ mockRequestUserAccess.mockResolvedValue({ data: {} })
+ mockSendMagicLink.mockResolvedValue({
+ data: {
+ sendMagicLoginLink: {
+ success: true,
+ message: 'Login link sent! Check your email.',
+ },
+ },
+ })
+ mockSendOnboardingLink.mockResolvedValue({
+ data: {
+ sendOnboardingCompletionLink: {
+ success: true,
+ message: 'Onboarding link sent! Check your email.',
+ },
+ },
+ })
+ })
+
+ it('does not render for authenticated users', () => {
+ mockUseSelector.mockImplementation((selector) => selector({ user: { data: { _id: 'user-1' } } }),)
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('shows validation error for invalid email', async () => {
+ render()
+
+ fireEvent.click(screen.getByText('Continue'))
+
+ expect(
+ await screen.findByText('Please enter a valid email address.'),
+ ).toBeTruthy()
+ expect(mockQuery).not.toHaveBeenCalled()
+ })
+
+ it('shows not requested feedback after requesting invite', async () => {
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Enter your email'), {
+ target: { value: 'newuser@example.com' },
+ })
+ fireEvent.click(screen.getByText('Continue'))
+
+ await waitFor(() => {
+ expect(mockRequestUserAccess).toHaveBeenCalledWith({
+ variables: { requestUserAccessInput: { email: 'newuser@example.com' } },
+ })
+ })
+
+ expect(
+ await screen.findByText(
+ "Your request has been received! You'll be notified once approved.",
+ ),
+ ).toBeTruthy()
+ })
+
+ it('shows registered flow and sends magic link', async () => {
+ mockQuery.mockResolvedValueOnce({
+ data: { checkEmailStatus: { status: 'registered' } },
+ })
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Enter your email'), {
+ target: { value: 'existing@example.com' },
+ })
+ fireEvent.click(screen.getByText('Continue'))
+
+ expect(await screen.findByText('We recognize this email.')).toBeTruthy()
+
+ fireEvent.click(screen.getByText('Send me a login link'))
+
+ await waitFor(() => {
+ expect(mockSendMagicLink).toHaveBeenCalledWith({
+ variables: { email: 'existing@example.com' },
+ })
+ })
+
+ expect(
+ await screen.findByText('Login link sent! Check your email.'),
+ ).toBeTruthy()
+ })
+
+ it('falls back to onboarding when magic link mutation reports incomplete signup', async () => {
+ mockQuery.mockResolvedValueOnce({
+ data: { checkEmailStatus: { status: 'registered' } },
+ })
+ mockSendMagicLink.mockResolvedValueOnce({
+ data: {
+ sendMagicLoginLink: {
+ success: false,
+ message: 'This account has not completed signup yet.',
+ },
+ },
+ })
+
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Enter your email'), {
+ target: { value: 'existing@example.com' },
+ })
+ fireEvent.click(screen.getByText('Continue'))
+
+ expect(await screen.findByText('We recognize this email.')).toBeTruthy()
+
+ fireEvent.click(screen.getByText('Send me a login link'))
+
+ expect(await screen.findByText('Your invite is approved!')).toBeTruthy()
+ })
+
+ it("opens onboarding modal when user is 'approved_no_password'", async () => {
+ mockQuery.mockResolvedValueOnce({
+ data: { checkEmailStatus: { status: 'approved_no_password' } },
+ })
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Enter your email'), {
+ target: { value: 'approved@example.com' },
+ })
+ fireEvent.click(screen.getByText('Continue'))
+
+ expect(await screen.findByText('Your invite is approved!')).toBeTruthy()
+ expect(screen.getByText('Send me a link to finish onboarding')).toBeTruthy()
+
+ fireEvent.click(screen.getByText('Send me a link to finish onboarding'))
+
+ await waitFor(() => {
+ expect(mockSendOnboardingLink).toHaveBeenCalledWith({
+ variables: { email: 'approved@example.com' },
+ })
+ })
+
+ expect(
+ await screen.findByText('Onboarding link sent! Check your email.'),
+ ).toBeTruthy()
+ })
+
+ it('dismisses the eyebrow bar', async () => {
+ render()
+
+ fireEvent.click(screen.getByLabelText('Close banner'))
+
+ await waitFor(() => {
+ expect(screen.queryByPlaceholderText('Enter your email')).toBeNull()
+ })
+ })
+})
diff --git a/client/src/components/Navbars/MainNavBar.jsx b/client/src/components/Navbars/MainNavBar.jsx
index 5cd2e765..26626880 100644
--- a/client/src/components/Navbars/MainNavBar.jsx
+++ b/client/src/components/Navbars/MainNavBar.jsx
@@ -250,7 +250,7 @@ function MainNavBar(props) {
return (
<>
-
+
{/* Logo */}
diff --git a/client/src/config/apollo.js b/client/src/config/apollo.js
index 8ad87034..d5da218c 100644
--- a/client/src/config/apollo.js
+++ b/client/src/config/apollo.js
@@ -36,6 +36,15 @@ const httpLink = createHttpLink({
// Create an auth link that dynamically adds the authorization header
const authLink = new ApolloLink((operation, forward) => {
+ const includeRequestIdHeader =
+ typeof process !== 'undefined'
+ && process.env
+ && process.env.REACT_APP_ENABLE_REQUEST_ID_HEADER === 'true'
+
+ const requestId = includeRequestIdHeader
+ ? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+ : null
+
// Get the token from localStorage for each request
const token = localStorage.getItem('token')
@@ -45,6 +54,10 @@ const authLink = new ApolloLink((operation, forward) => {
...operation.getContext().headers,
}
+ if (requestId) {
+ headers['x-request-id'] = requestId
+ }
+
// Add the authorization header if token exists
if (token) {
// Remove 'Bearer ' prefix if already present to avoid duplication
diff --git a/client/src/graphql/mutations.jsx b/client/src/graphql/mutations.jsx
index fb0d7bda..872966c4 100644
--- a/client/src/graphql/mutations.jsx
+++ b/client/src/graphql/mutations.jsx
@@ -448,6 +448,19 @@ export const REMOVE_BUDDY = gql`
}
`
+// ===== Eyebrow CTA =====
+export const SEND_MAGIC_LOGIN_LINK = gql`
+ mutation sendMagicLoginLink($email: String!) {
+ sendMagicLoginLink(email: $email)
+ }
+`
+
+export const SEND_ONBOARDING_COMPLETION_LINK = gql`
+ mutation sendOnboardingCompletionLink($email: String!) {
+ sendOnboardingCompletionLink(email: $email)
+ }
+`
+
// ===== Typing Mutations =====
export const UPDATE_TYPING = gql`
mutation updateTyping($typing: TypingInput!) {
diff --git a/client/src/graphql/query.jsx b/client/src/graphql/query.jsx
index 54fbe0b9..6aea98a9 100644
--- a/client/src/graphql/query.jsx
+++ b/client/src/graphql/query.jsx
@@ -865,6 +865,13 @@ export const GET_ROSTER = gql`
}
`
+// ===== Eyebrow CTA =====
+export const CHECK_EMAIL_STATUS = gql`
+ query checkEmailStatus($email: String!) {
+ checkEmailStatus(email: $email)
+ }
+`
+
// ===== Typing Queries =====
export const GET_TYPING_USERS = gql`
query getTypingUsers($messageRoomId: String!) {
diff --git a/client/src/main.jsx b/client/src/main.jsx
index db1be7c8..5a245bfa 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -37,6 +37,7 @@ import LogoutPage from './components/LogoutPage'
import { AuthModalProvider } from './Context/AuthModalContext'
import { ThemeContextProvider, useTheme } from './Context/ThemeContext'
+import EyebrowBar from './components/EyebrowBar/EyebrowBar'
import 'fontsource-montserrat'
import ErrorPage from './mui-pro/views/Pages/ErrorPage'
@@ -86,6 +87,7 @@ function App() {
+
diff --git a/client/src/mui-pro/mui-routes.jsx b/client/src/mui-pro/mui-routes.jsx
index 6ae2ca4b..de58a416 100644
--- a/client/src/mui-pro/mui-routes.jsx
+++ b/client/src/mui-pro/mui-routes.jsx
@@ -9,6 +9,7 @@ import InvestorThanks from "../views/InvestorThanks/InvestorThanks";
import ForgotPasswordPage from "../views/ForgotPassword/ForgotPasswordPage";
import PasswordResetPage from "../views/PasswordResetPage/PasswordResetPage";
import SignupPage from "../views/SignupPage/SignupPage";
+import MagicLoginPage from "../views/MagicLoginPage/MagicLoginPage";
import AboutPage from "../views/AboutPage";
import PlanCarouselPage from "../views/PlanCarouselPage/PlanCarouselPage";
import TermsPage from "../views/TermsPage";
@@ -127,6 +128,13 @@ const dashRoutes = [
component: SignupPage,
layout: '/auth',
},
+ {
+ path: '/magic-login',
+ name: 'Magic Login',
+ mini: 'M',
+ component: MagicLoginPage,
+ layout: '/auth',
+ },
],
},
]
diff --git a/client/src/views/MagicLoginPage/MagicLoginPage.jsx b/client/src/views/MagicLoginPage/MagicLoginPage.jsx
new file mode 100644
index 00000000..a6ffbfe7
--- /dev/null
+++ b/client/src/views/MagicLoginPage/MagicLoginPage.jsx
@@ -0,0 +1,137 @@
+import { useEffect, useState } from 'react'
+import { useHistory, useLocation } from 'react-router-dom'
+import { useDispatch } from 'react-redux'
+import { useApolloClient } from '@apollo/react-hooks'
+import { makeStyles } from '@material-ui/core/styles'
+import { Typography, Button, CircularProgress } from '@material-ui/core'
+import { USER_LOGIN_SUCCESS } from 'store/user'
+import { VERIFY_PASSWORD_RESET_TOKEN } from '@/graphql/query'
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: '50vh',
+ padding: theme.spacing(4),
+ textAlign: 'center',
+ },
+ message: {
+ marginBottom: theme.spacing(3),
+ color: '#fff',
+ fontWeight: 500,
+ },
+ loginButton: {
+ backgroundColor: '#fff',
+ color: '#178BE1',
+ borderRadius: 20,
+ padding: theme.spacing(1, 4),
+ textTransform: 'none',
+ fontWeight: 600,
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ },
+ },
+}))
+
+export default function MagicLoginPage() {
+ const classes = useStyles()
+ const history = useHistory()
+ const location = useLocation()
+ const dispatch = useDispatch()
+ const client = useApolloClient()
+ const [status, setStatus] = useState('verifying') // verifying | success | error
+ const [errorMsg, setErrorMsg] = useState('')
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search)
+ const token = params.get('token')
+
+ if (!token) {
+ setStatus('error')
+ setErrorMsg('No login token provided.')
+ return
+ }
+
+ const verifyAndLogin = async () => {
+ try {
+ // Store token first so Apollo client sends it with the verify request
+ localStorage.setItem('token', token)
+
+ const { data } = await client.query({
+ query: VERIFY_PASSWORD_RESET_TOKEN,
+ variables: { token },
+ fetchPolicy: 'network-only',
+ })
+
+ const user = data?.verifyUserPasswordResetToken
+ if (!user || !user._id) {
+ localStorage.removeItem('token')
+ setStatus('error')
+ setErrorMsg('Invalid or expired login link.')
+ return
+ }
+
+ // Dispatch login success with user data
+ dispatch(
+ USER_LOGIN_SUCCESS({
+ data: user,
+ loading: false,
+ loginError: null,
+ }),
+ )
+
+ setStatus('success')
+
+ // Redirect to search page after brief delay
+ setTimeout(() => {
+ history.push('/search')
+ }, 1000)
+ } catch (err) {
+ localStorage.removeItem('token')
+ setStatus('error')
+ setErrorMsg(
+ err.message?.includes('expired') ?
+ 'This login link has expired. Please request a new one.' :
+ 'Invalid or expired login link.',
+ )
+ }
+ }
+
+ verifyAndLogin()
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ {status === 'verifying' && (
+ <>
+
+
+ Signing you in...
+
+ >
+ )}
+
+ {status === 'success' && (
+
+ You're in! Redirecting...
+
+ )}
+
+ {status === 'error' && (
+ <>
+
+ {errorMsg}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/client/src/views/MagicLoginPage/MagicLoginPage.test.jsx b/client/src/views/MagicLoginPage/MagicLoginPage.test.jsx
new file mode 100644
index 00000000..1cfb62e3
--- /dev/null
+++ b/client/src/views/MagicLoginPage/MagicLoginPage.test.jsx
@@ -0,0 +1,99 @@
+import {
+ render, screen, fireEvent, waitFor,
+} from '@testing-library/react'
+import {
+ vi, describe, it, beforeEach, expect,
+} from 'vitest'
+import { USER_LOGIN_SUCCESS } from 'store/user'
+import MagicLoginPage from './MagicLoginPage'
+
+let mockSearch = ''
+const mockPush = vi.fn()
+const mockDispatch = vi.fn()
+const mockQuery = vi.fn()
+
+vi.mock('react-router-dom', () => ({
+ useHistory: () => ({ push: mockPush }),
+ useLocation: () => ({ search: mockSearch }),
+}))
+
+vi.mock('react-redux', () => ({
+ useDispatch: () => mockDispatch,
+}))
+
+vi.mock('@apollo/react-hooks', () => ({
+ useApolloClient: () => ({ query: mockQuery }),
+}))
+
+describe('MagicLoginPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockSearch = ''
+ })
+
+ it('shows error when token is missing', async () => {
+ render()
+
+ expect(await screen.findByText('No login token provided.')).toBeTruthy()
+ expect(mockQuery).not.toHaveBeenCalled()
+ })
+
+ it('shows invalid token error when verification returns no user', async () => {
+ mockSearch = '?token=bad-token'
+ mockQuery.mockResolvedValueOnce({
+ data: { verifyUserPasswordResetToken: null },
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Invalid or expired login link.')).toBeTruthy()
+ })
+
+ expect(localStorage.setItem).toHaveBeenCalledWith('token', 'bad-token')
+ expect(localStorage.removeItem).toHaveBeenCalledWith('token')
+ })
+
+ it('dispatches login success and redirects on valid token', async () => {
+ mockSearch = '?token=good-token'
+ const user = { _id: 'u-1', username: 'tester' }
+ mockQuery.mockResolvedValueOnce({
+ data: { verifyUserPasswordResetToken: user },
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText("You're in! Redirecting...")).toBeTruthy()
+ })
+
+ expect(localStorage.setItem).toHaveBeenCalledWith('token', 'good-token')
+ expect(mockDispatch).toHaveBeenCalledWith(
+ USER_LOGIN_SUCCESS({ data: user, loading: false, loginError: null }),
+ )
+
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1100)
+ })
+
+ expect(mockPush).toHaveBeenCalledWith('/search')
+ })
+
+ it('shows expired message when query rejects with expired error', async () => {
+ mockSearch = '?token=expired-token'
+ mockQuery.mockRejectedValueOnce(new Error('token expired'))
+
+ render()
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'This login link has expired. Please request a new one.',
+ ),
+ ).toBeTruthy()
+ })
+
+ fireEvent.click(screen.getByText('Go to Login'))
+ expect(mockPush).toHaveBeenCalledWith('/auth/login')
+ })
+})
diff --git a/server/app/data/resolvers/mutations.js b/server/app/data/resolvers/mutations.js
index 5e0880e3..0c9e44fa 100644
--- a/server/app/data/resolvers/mutations.js
+++ b/server/app/data/resolvers/mutations.js
@@ -62,6 +62,8 @@ export const resolver_mutations = function () {
// User mutations
sendPasswordResetEmail: userMutations.sendPasswordResetEmail(),
+ sendMagicLoginLink: userMutations.sendMagicLoginLink(),
+ sendOnboardingCompletionLink: userMutations.sendOnboardingCompletionLink(),
updateUserPassword: userMutations.updateUserPassword(),
updateUser: userMutations.updateUser(),
diff --git a/server/app/data/resolvers/mutations/user/index.js b/server/app/data/resolvers/mutations/user/index.js
index 35197c7d..755b42f7 100644
--- a/server/app/data/resolvers/mutations/user/index.js
+++ b/server/app/data/resolvers/mutations/user/index.js
@@ -1,11 +1,3 @@
-export * from './addUser';
-export * from './followUser';
-export * from './updateUser';
-export * from './updateUserAdminRight';
-export * from './sendPasswordResetEmail';
-export * from './updateUserPassword';
-export * from './updateUserAvatar';
-
// Import default exports and re-export as named exports
import sendUserInvite from './sendUserInvite';
import reportUser from './reportUser';
@@ -14,4 +6,21 @@ import reportBot from './reportBot';
import disableUser from './disableUser';
import enableUser from './enableUser';
-export { sendUserInvite, reportUser, recalculateReputation, reportBot, disableUser, enableUser };
+export * from './addUser';
+export * from './followUser';
+export * from './updateUser';
+export * from './updateUserAdminRight';
+export * from './sendPasswordResetEmail';
+export * from './updateUserPassword';
+export * from './updateUserAvatar';
+export * from './sendMagicLoginLink';
+export * from './sendOnboardingCompletionLink';
+
+export {
+ sendUserInvite,
+ reportUser,
+ recalculateReputation,
+ reportBot,
+ disableUser,
+ enableUser,
+};
diff --git a/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js b/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js
new file mode 100644
index 00000000..04d48fbd
--- /dev/null
+++ b/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js
@@ -0,0 +1,92 @@
+import UserModel from '../../models/UserModel';
+import { logger } from '../../../utils/logger';
+import { addCreatorToUser } from '~/utils/authentication';
+import sendGridEmail, {
+ SENGRID_TEMPLATE_IDS,
+} from '../../../utils/send-grid-mail';
+
+export const sendMagicLoginLink = () => {
+ return async (_, args, context) => {
+ try {
+ const { email } = args;
+ const requestId = context && context.requestId;
+ logger.info('sendMagicLoginLink called', {
+ requestId,
+ email,
+ });
+
+ const user = await UserModel.findOne({
+ email: { $regex: new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') },
+ });
+
+ if (!user) {
+ logger.info('sendMagicLoginLink returning generic success for unknown account', {
+ requestId,
+ email,
+ });
+ // Don't reveal whether email exists — return generic success
+ return { success: true, message: 'If an account exists with that email, a login link has been sent.' };
+ }
+
+ if (!user.hash_password) {
+ logger.info('sendMagicLoginLink blocked because password not set', {
+ requestId,
+ email,
+ userId: user._id,
+ userStatus: user.status,
+ });
+ return { success: false, message: 'This account has not completed signup yet.' };
+ }
+
+ const { username } = user;
+ const expiresIn = 15 * 60; // 15 minutes
+ const token = await addCreatorToUser(
+ {
+ username,
+ password: '',
+ requirePassword: false,
+ },
+ () => {},
+ false,
+ expiresIn,
+ true,
+ );
+
+ const clientUrl = process.env.CLIENT_URL;
+ const mailOptions = {
+ requestId,
+ to: email,
+ from: `Team Quote.Vote <${process.env.SENDGRID_SENDER_EMAIL}>`,
+ templateId: SENGRID_TEMPLATE_IDS.MAGIC_LOGIN,
+ dynamicTemplateData: {
+ MAGIC_LINK_URL: `${clientUrl}/auth/magic-login?token=${token}`,
+ },
+ };
+
+ logger.info('sendMagicLoginLink sending email', {
+ requestId,
+ email,
+ hasClientUrl: !!clientUrl,
+ hasSendgridApiKey: !!process.env.SENDGRID_API_KEY,
+ hasSenderEmail: !!process.env.SENDGRID_SENDER_EMAIL,
+ });
+
+ const sendResult = await sendGridEmail(mailOptions);
+
+ logger.info('sendMagicLoginLink sendGrid result', {
+ requestId,
+ email,
+ success: !!sendResult.success,
+ error: sendResult.error || null,
+ });
+
+ return { success: true, message: 'If an account exists with that email, a login link has been sent.' };
+ } catch (err) {
+ logger.error('Error in sendMagicLoginLink', {
+ requestId: context && context.requestId,
+ error: err.message,
+ });
+ throw new Error(`Failed to send magic login link: ${err.message}`);
+ }
+ };
+};
diff --git a/server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js b/server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js
new file mode 100644
index 00000000..8ab3ab4b
--- /dev/null
+++ b/server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js
@@ -0,0 +1,99 @@
+import UserModel from '../../models/UserModel';
+import { logger } from '../../../utils/logger';
+import { addCreatorToUser } from '~/utils/authentication';
+import sendGridEmail, {
+ SENGRID_TEMPLATE_IDS,
+} from '../../../utils/send-grid-mail';
+
+export const sendOnboardingCompletionLink = () => {
+ return async (_, args, context) => {
+ try {
+ const { email } = args;
+ const requestId = context && context.requestId;
+ logger.info('sendOnboardingCompletionLink called', {
+ requestId,
+ email,
+ });
+
+ const user = await UserModel.findOne({
+ email: {
+ $regex: new RegExp(
+ `^${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
+ 'i',
+ ),
+ },
+ });
+
+ if (!user || user.hash_password || user.status !== 4) {
+ logger.info('sendOnboardingCompletionLink returning generic success for ineligible account', {
+ requestId,
+ email,
+ hasUser: !!user,
+ userStatus: user ? user.status : null,
+ hasPassword: user ? !!user.hash_password : null,
+ });
+ return {
+ success: true,
+ message:
+ 'If an eligible account exists with that email, an onboarding link has been sent.',
+ };
+ }
+
+ const expiresIn = 60 * 60 * 24;
+ const token = await addCreatorToUser(
+ {
+ username: user.username,
+ password: '',
+ requirePassword: false,
+ },
+ () => {},
+ false,
+ expiresIn,
+ true,
+ );
+
+ const clientUrl = process.env.CLIENT_URL;
+ const mailOptions = {
+ requestId,
+ to: email,
+ from: `Team Quote.Vote <${process.env.SENDGRID_SENDER_EMAIL}>`,
+ templateId: SENGRID_TEMPLATE_IDS.INVITATION_APPROVE,
+ dynamicTemplateData: {
+ create_password_url: `${clientUrl}/auth/signup?token=${token}`,
+ },
+ };
+
+ logger.info('sendOnboardingCompletionLink sending email', {
+ requestId,
+ email,
+ userId: user._id,
+ hasClientUrl: !!clientUrl,
+ hasSendgridApiKey: !!process.env.SENDGRID_API_KEY,
+ hasSenderEmail: !!process.env.SENDGRID_SENDER_EMAIL,
+ });
+
+ const sendResult = await sendGridEmail(mailOptions);
+
+ logger.info('sendOnboardingCompletionLink sendGrid result', {
+ requestId,
+ email,
+ success: !!sendResult.success,
+ error: sendResult.error || null,
+ });
+
+ return {
+ success: true,
+ message:
+ 'If an eligible account exists with that email, an onboarding link has been sent.',
+ };
+ } catch (err) {
+ logger.error('Error in sendOnboardingCompletionLink', {
+ requestId: context && context.requestId,
+ error: err.message,
+ });
+ throw new Error(
+ `Failed to send onboarding completion link: ${err.message}`,
+ );
+ }
+ };
+};
diff --git a/server/app/data/resolvers/mutations/userInvite/requestUserAccess.js b/server/app/data/resolvers/mutations/userInvite/requestUserAccess.js
index 8037f477..73c33cc7 100644
--- a/server/app/data/resolvers/mutations/userInvite/requestUserAccess.js
+++ b/server/app/data/resolvers/mutations/userInvite/requestUserAccess.js
@@ -4,13 +4,19 @@ import UserModel from '../../models/UserModel';
import { logger } from '../../../utils/logger';
export const requestUserAccess = (pubsub) => {
- return async (_, args) => {
+ return async (_, args, context) => {
const { requestUserAccessInput } = args;
const { email } = requestUserAccessInput;
- logger.debug('checking mail for', { email });
+ const requestId = context && context.requestId;
+ logger.debug('checking mail for', { email, requestId });
const existingUser = await UserModel.findOne({ email });
- logger.debug('Existing user', { email, hasUser: !!existingUser, status: existingUser?.status });
+ logger.debug('Existing user', {
+ email,
+ requestId,
+ hasUser: !!existingUser,
+ status: existingUser?.status,
+ });
let user;
if (existingUser) {
if (existingUser.status !== 1) {
@@ -28,13 +34,29 @@ export const requestUserAccess = (pubsub) => {
user = await new UserModel(userArgs).save();
}
const mailOptions = {
+ requestId,
to: email,
from: `Team Quote.Vote <${process.env.SENDGRID_SENDER_EMAIL}>`,
templateId: SENGRID_TEMPLATE_IDS.INVITE_REQUEST_RECEIVED_CONFIRMATION,
};
+ logger.info('requestUserAccess sending confirmation email', {
+ requestId,
+ email,
+ userId: user._id,
+ hasSendgridApiKey: !!process.env.SENDGRID_API_KEY,
+ hasSenderEmail: !!process.env.SENDGRID_SENDER_EMAIL,
+ });
+
const result = await sendGridEmail(mailOptions);
+ logger.info('requestUserAccess sendGrid result', {
+ requestId,
+ email,
+ success: !!result.success,
+ error: result.error || null,
+ });
+
if (result.error) {
throw new Error(result.error);
}
diff --git a/server/app/data/resolvers/queries.js b/server/app/data/resolvers/queries.js
index 8aba4756..af680b4d 100644
--- a/server/app/data/resolvers/queries.js
+++ b/server/app/data/resolvers/queries.js
@@ -43,5 +43,8 @@ export const resolver_query = function () {
// Typing queries
getTypingUsers: typingQuery.getTypingUsers,
+
+ // Email status check (eyebrow CTA)
+ checkEmailStatus: userQuery.checkEmailStatus(),
};
};
diff --git a/server/app/data/resolvers/queries/user/checkEmailStatus.js b/server/app/data/resolvers/queries/user/checkEmailStatus.js
new file mode 100644
index 00000000..76aaf6d9
--- /dev/null
+++ b/server/app/data/resolvers/queries/user/checkEmailStatus.js
@@ -0,0 +1,96 @@
+import UserModel from '../../models/UserModel';
+import { logger } from '../../../utils/logger';
+
+/**
+ * Check the status of an email address for the eyebrow CTA flow.
+ * Returns one of: "registered", "not_requested", "requested_pending", "approved_no_password"
+ */
+export const checkEmailStatus = () => {
+ return async (_, args, context) => {
+ try {
+ const { email } = args;
+ const requestId = context && context.requestId;
+
+ logger.debug('checkEmailStatus called', {
+ requestId,
+ email,
+ });
+
+ if (!email) {
+ throw new Error('Email parameter is required');
+ }
+
+ const user = await UserModel.findOne({
+ email: { $regex: new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') },
+ });
+
+ if (!user) {
+ logger.debug('checkEmailStatus result', {
+ requestId,
+ email,
+ status: 'not_requested',
+ hasUser: false,
+ });
+ return { status: 'not_requested' };
+ }
+
+ // Status 1 = Prospect (requested invite, pending approval)
+ // Status 2 = Declined — treat as re-requestable
+ if (user.status === 1 || user.status === 2) {
+ logger.debug('checkEmailStatus result', {
+ requestId,
+ email,
+ status: 'requested_pending',
+ hasUser: true,
+ userStatus: user.status,
+ hasPassword: !!user.hash_password,
+ });
+ return { status: 'requested_pending' };
+ }
+
+ // Status 4 = Approved but hasn't set up password yet
+ if (user.status === 4 && !user.hash_password) {
+ logger.debug('checkEmailStatus result', {
+ requestId,
+ email,
+ status: 'approved_no_password',
+ hasUser: true,
+ userStatus: user.status,
+ hasPassword: !!user.hash_password,
+ });
+ return { status: 'approved_no_password' };
+ }
+
+ // User has a password set — they are a registered user
+ if (user.hash_password) {
+ logger.debug('checkEmailStatus result', {
+ requestId,
+ email,
+ status: 'registered',
+ hasUser: true,
+ userStatus: user.status,
+ hasPassword: !!user.hash_password,
+ });
+ return { status: 'registered' };
+ }
+
+ // Fallback for any other state
+ logger.debug('checkEmailStatus result', {
+ requestId,
+ email,
+ status: 'not_requested',
+ hasUser: true,
+ userStatus: user.status,
+ hasPassword: !!user.hash_password,
+ });
+ return { status: 'not_requested' };
+ } catch (err) {
+ logger.error('Error in checkEmailStatus', {
+ error: err.message,
+ email: args.email,
+ requestId: context && context.requestId,
+ });
+ throw new Error(`Failed to check email status: ${err.message}`);
+ }
+ };
+};
diff --git a/server/app/data/resolvers/queries/user/index.js b/server/app/data/resolvers/queries/user/index.js
index a9ec4e1e..eea5d9cd 100644
--- a/server/app/data/resolvers/queries/user/index.js
+++ b/server/app/data/resolvers/queries/user/index.js
@@ -9,6 +9,7 @@ export * from './getUserFollowInfo';
export * from './getUserReputation';
export * from './getUserInvites';
export * from './getUserReports';
+export * from './checkEmailStatus';
// Import default exports and re-export as named exports
import getBotReportedUsers from './getBotReportedUsers';
diff --git a/server/app/data/type_definition/mutation_definition.js b/server/app/data/type_definition/mutation_definition.js
index bdc5da5a..89846111 100644
--- a/server/app/data/type_definition/mutation_definition.js
+++ b/server/app/data/type_definition/mutation_definition.js
@@ -64,6 +64,12 @@ export const Mutation = `type Mutation {
# Mutation for send email password reset link
sendPasswordResetEmail(email: String!): JSON
+ # Mutation for sending a magic login link
+ sendMagicLoginLink(email: String!): JSON
+
+ # Mutation for resending an onboarding completion link
+ sendOnboardingCompletionLink(email: String!): JSON
+
# Mutation for updating user password
updateUserPassword(username: String, password: String, token: String): JSON
diff --git a/server/app/data/type_definition/query_definition.js b/server/app/data/type_definition/query_definition.js
index b4450cb1..5e6c9afc 100644
--- a/server/app/data/type_definition/query_definition.js
+++ b/server/app/data/type_definition/query_definition.js
@@ -89,4 +89,8 @@ type Query {
# ===== Typing Queries =====
" Get typing users in a message room"
getTypingUsers(messageRoomId: String!): [TypingIndicator]
+
+ # ===== Eyebrow CTA =====
+ " Check email status for eyebrow bar CTA flow"
+ checkEmailStatus(email: String!): JSON
}`;
diff --git a/server/app/data/utils/requireAuth.js b/server/app/data/utils/requireAuth.js
index 4db27397..350bd67f 100644
--- a/server/app/data/utils/requireAuth.js
+++ b/server/app/data/utils/requireAuth.js
@@ -1,4 +1,5 @@
import { logger } from './logger';
+import { parse, Kind } from 'graphql';
const PUBLIC_QUERIES = [
'addStripeCustomer',
@@ -21,18 +22,56 @@ const PUBLIC_QUERIES = [
'getUserFollowInfo',
'group',
'groups',
+ 'checkEmailStatus',
+ 'sendMagicLoginLink',
+ 'sendOnboardingCompletionLink',
// add more public queries/mutations
];
+const PUBLIC_QUERY_SET = new Set(PUBLIC_QUERIES);
+
+const getRootOperationFields = (query) => {
+ try {
+ const document = parse(query);
+ const rootFields = [];
+
+ document.definitions.forEach((definition) => {
+ if (definition.kind !== Kind.OPERATION_DEFINITION) {
+ return;
+ }
+
+ if (!definition.selectionSet || !definition.selectionSet.selections) {
+ return;
+ }
+
+ definition.selectionSet.selections.forEach((selection) => {
+ if (selection.kind === Kind.FIELD) {
+ rootFields.push(selection.name.value);
+ return;
+ }
+
+ // Be conservative for fragment spreads/inline fragments at root.
+ rootFields.push('__NON_FIELD_SELECTION__');
+ });
+ });
+
+ return rootFields;
+ } catch (error) {
+ logger.warn('Failed to parse GraphQL query for auth check', {
+ error: error.message,
+ });
+ return null;
+ }
+};
+
const requireAuth = (query) => {
let requireAuth = true;
- for (const publicQuery of PUBLIC_QUERIES) {
- const isFound = query.includes(publicQuery);
- if (isFound) {
- requireAuth = false;
- break;
- }
+ const rootFields = getRootOperationFields(query);
+
+ if (rootFields && rootFields.length > 0) {
+ requireAuth = !rootFields.every((fieldName) => PUBLIC_QUERY_SET.has(fieldName));
}
+
logger.debug('requireAuth check', { requireAuth, query });
return requireAuth;
};
diff --git a/server/app/data/utils/send-grid-mail.js b/server/app/data/utils/send-grid-mail.js
index 6321c382..95cc22ad 100644
--- a/server/app/data/utils/send-grid-mail.js
+++ b/server/app/data/utils/send-grid-mail.js
@@ -6,6 +6,7 @@ export const SENGRID_TEMPLATE_IDS = {
INVITATION_APPROVE: 'd-87274eb1bc824899aa350b26ad33e8eb.1064787e-b021-478b-8e14-ab7f890f0c53',
INVITATION_DECLINE: 'd-cbac3519f74f4670915a658877550a75.aacbe956-5240-4692-ab80-d790d728f4c4',
PASSWORD_RESET: 'd-8be5275161b04a0f85f32b8023ac727f.3f4240d3-9533-44ad-9ac0-ae25b90cee6c',
+ MAGIC_LOGIN: 'd-c561351713dc44e48a1fdc6a69c9de4f',
};
/**
@@ -22,12 +23,22 @@ export const SENGRID_TEMPLATE_IDS = {
*/
const sendGridEmail = async (emailData) => {
const apiKey = process.env.SENDGRID_API_KEY;
+ const requestId = emailData.requestId;
if (!apiKey) {
- logger.error('SENDGRID_API_KEY environment variable is not set');
+ logger.error('SENDGRID_API_KEY environment variable is not set', {
+ requestId,
+ });
throw new Error('SENDGRID_API_KEY environment variable is not set');
}
+ if (!apiKey.startsWith('SG.')) {
+ logger.error('SENDGRID_API_KEY does not start with SG.', {
+ requestId,
+ keyPrefix: apiKey.slice(0, 3),
+ });
+ }
+
sgMail.setApiKey(apiKey);
// Validate required fields
@@ -60,6 +71,7 @@ const sendGridEmail = async (emailData) => {
};
logger.debug('sendGridEmail', {
+ requestId,
to: emailData.to,
from: emailData.from || `Team Quote.Vote <${process.env.SENDGRID_SENDER_EMAIL}>`,
subject: emailData.subject,
@@ -68,10 +80,15 @@ const sendGridEmail = async (emailData) => {
try {
await sgMail.send(msg);
- logger.info('Email sent successfully', { to: emailData.to, subject: emailData.subject });
+ logger.info('Email sent successfully', {
+ requestId,
+ to: emailData.to,
+ subject: emailData.subject,
+ });
return { success: true, message: 'Email sent successfully' };
} catch (error) {
logger.error('Error sending email', {
+ requestId,
error: error.message,
to: emailData.to,
stack: error.stack,
@@ -79,7 +96,9 @@ const sendGridEmail = async (emailData) => {
if (error.response) {
logger.error('SendGrid API Error', {
+ requestId,
error: error.response.body,
+ statusCode: error.response.statusCode,
to: emailData.to,
});
@@ -89,6 +108,7 @@ const sendGridEmail = async (emailData) => {
errors.forEach((err) => {
if (err.message.includes('authorization grant is invalid')) {
logger.error('SendGrid API Key Error: Please check your SENDGRID_API_KEY environment variable', {
+ requestId,
error: err.message,
});
}
diff --git a/server/app/server.js b/server/app/server.js
index 2d1ac7a2..5d97b2d4 100644
--- a/server/app/server.js
+++ b/server/app/server.js
@@ -35,6 +35,15 @@ if (process.env.CLIENT_URL && process.env.CLIENT_URL.endsWith('/')) {
const GRAPHQL_PORT = process.env.PORT || 4000;
logger.info(`Database URL: ${process.env.DATABASE_URL ? 'SET' : 'NOT SET'}`);
+logger.info('Email environment check', {
+ hasSendgridApiKey: !!process.env.SENDGRID_API_KEY,
+ hasSendgridSenderEmail: !!process.env.SENDGRID_SENDER_EMAIL,
+ hasClientUrl: !!process.env.CLIENT_URL,
+});
+
+const buildRequestId = () => {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+};
// Set mongoose global options to prevent deprecation warnings
mongoose.set('useNewUrlParser', true);
@@ -64,6 +73,13 @@ connectDB();
const app = express();
+// Attach a request ID to all requests for browser-to-server correlation.
+app.use((req, res, next) => {
+ req.requestId = req.headers['x-request-id'] || buildRequestId();
+ res.setHeader('x-request-id', req.requestId);
+ next();
+});
+
// ✅ GLOBAL CORS middleware
app.use(cors({
origin(origin, callback) {
@@ -87,6 +103,13 @@ app.use(cors({
return callback(null, true);
}
+ logger.warn('CORS blocked request origin', {
+ requestId: req.requestId,
+ origin,
+ method: req.method,
+ path: req.path,
+ });
+
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
@@ -98,12 +121,44 @@ app.use(cors({
'Pragma',
'Origin',
'X-Requested-With',
+ 'X-Request-Id',
+ 'x-request-id',
],
optionsSuccessStatus: 200, // Some legacy browsers choke on 204
}));
app.use(bodyParser.json({ limit: '17mb' }));
+app.use('/graphql', (req, res, next) => {
+ const startedAt = Date.now();
+ const query = req.body && req.body.query ? req.body.query : '';
+ const operationName = req.body && req.body.operationName ? req.body.operationName : null;
+
+ logger.debug('GraphQL request received', {
+ requestId: req.requestId,
+ method: req.method,
+ path: req.path,
+ origin: req.headers.origin,
+ contentType: req.headers['content-type'],
+ operationName,
+ hasAuthorization: !!req.headers.authorization,
+ hasQuery: !!query,
+ queryPreview: query ? query.replace(/\s+/g, ' ').trim().slice(0, 160) : null,
+ });
+
+ res.on('finish', () => {
+ logger.debug('GraphQL request completed', {
+ requestId: req.requestId,
+ method: req.method,
+ path: req.path,
+ statusCode: res.statusCode,
+ durationMs: Date.now() - startedAt,
+ });
+ });
+
+ next();
+});
+
// Handle preflight requests for GraphQL endpoint
app.options('/graphql', (req, res) => {
res.status(200).end();
@@ -216,10 +271,10 @@ const server = new ApolloServer({
if (authToken && authToken.length) {
try {
const user = await verifyToken(authToken);
- return { user, res };
+ return { user, res, requestId: req && req.requestId };
} catch (error) {
logger.debug('Invalid token, proceeding without user context:', error.message);
- return { res };
+ return { res, requestId: req && req.requestId };
}
}
@@ -232,7 +287,7 @@ const server = new ApolloServer({
}
}
- return { res };
+ return { res, requestId: req && req.requestId };
},
});
diff --git a/server/app/tests/mutations/user/sendMagicLoginLink.test.js b/server/app/tests/mutations/user/sendMagicLoginLink.test.js
new file mode 100644
index 00000000..ff4b1281
--- /dev/null
+++ b/server/app/tests/mutations/user/sendMagicLoginLink.test.js
@@ -0,0 +1,96 @@
+import { expect } from 'chai';
+import sinon from 'sinon';
+import UserModel from '~/resolvers/models/UserModel';
+import { sendMagicLoginLink } from '~/resolvers/mutations/user/sendMagicLoginLink';
+import * as authenticationUtils from '~/utils/authentication';
+import * as sendGridUtils from '~/utils/send-grid-mail';
+import { logger } from '~/utils/logger';
+
+describe('Mutations > user > sendMagicLoginLink', () => {
+ let userModelStub;
+ let addCreatorToUserStub;
+ let sendGridEmailStub;
+ let loggerStub;
+
+ beforeEach(() => {
+ process.env.CLIENT_URL = 'https://example.com';
+ process.env.SENDGRID_SENDER_EMAIL = 'noreply@example.com';
+
+ userModelStub = sinon.stub(UserModel, 'findOne');
+ addCreatorToUserStub = sinon
+ .stub(authenticationUtils, 'addCreatorToUser')
+ .resolves('magic-token');
+ sendGridEmailStub = sinon
+ .stub(sendGridUtils, 'default')
+ .resolves({ success: true });
+ loggerStub = sinon.stub(logger, 'error');
+ });
+
+ afterEach(() => {
+ userModelStub.restore();
+ addCreatorToUserStub.restore();
+ sendGridEmailStub.restore();
+ loggerStub.restore();
+ });
+
+ it('returns generic success when no user matches the email', async () => {
+ userModelStub.resolves(null);
+
+ const result = await sendMagicLoginLink()(undefined, {
+ email: 'missing@example.com',
+ });
+
+ expect(result).to.deep.equal({
+ success: true,
+ message:
+ 'If an account exists with that email, a login link has been sent.',
+ });
+ sinon.assert.notCalled(addCreatorToUserStub);
+ sinon.assert.notCalled(sendGridEmailStub);
+ });
+
+ it('returns a signup-completion error for users without passwords', async () => {
+ userModelStub.resolves({ status: 4, hash_password: null });
+
+ const result = await sendMagicLoginLink()(undefined, {
+ email: 'approved@example.com',
+ });
+
+ expect(result).to.deep.equal({
+ success: false,
+ message: 'This account has not completed signup yet.',
+ });
+ sinon.assert.notCalled(addCreatorToUserStub);
+ sinon.assert.notCalled(sendGridEmailStub);
+ });
+
+ it('sends the dedicated magic login template for registered users', async () => {
+ userModelStub.resolves({
+ username: 'registered-user',
+ hash_password: 'hashed-password',
+ });
+
+ const result = await sendMagicLoginLink()(undefined, {
+ email: 'registered@example.com',
+ });
+
+ expect(result).to.deep.equal({
+ success: true,
+ message:
+ 'If an account exists with that email, a login link has been sent.',
+ });
+
+ sinon.assert.calledOnce(addCreatorToUserStub);
+ sinon.assert.calledOnce(sendGridEmailStub);
+ expect(sendGridEmailStub.firstCall.args[0]).to.deep.include({
+ to: 'registered@example.com',
+ from: 'Team Quote.Vote ',
+ templateId: sendGridUtils.SENGRID_TEMPLATE_IDS.MAGIC_LOGIN,
+ });
+ expect(
+ sendGridEmailStub.firstCall.args[0].dynamicTemplateData,
+ ).to.deep.equal({
+ MAGIC_LINK_URL: 'https://example.com/auth/magic-login?token=magic-token',
+ });
+ });
+});
diff --git a/server/app/tests/mutations/user/sendOnboardingCompletionLink.test.js b/server/app/tests/mutations/user/sendOnboardingCompletionLink.test.js
new file mode 100644
index 00000000..4faa938c
--- /dev/null
+++ b/server/app/tests/mutations/user/sendOnboardingCompletionLink.test.js
@@ -0,0 +1,99 @@
+import { expect } from 'chai';
+import sinon from 'sinon';
+import UserModel from '~/resolvers/models/UserModel';
+import { sendOnboardingCompletionLink } from '~/resolvers/mutations/user/sendOnboardingCompletionLink';
+import * as authenticationUtils from '~/utils/authentication';
+import * as sendGridUtils from '~/utils/send-grid-mail';
+import { logger } from '~/utils/logger';
+
+describe('Mutations > user > sendOnboardingCompletionLink', () => {
+ let userModelStub;
+ let addCreatorToUserStub;
+ let sendGridEmailStub;
+ let loggerStub;
+
+ beforeEach(() => {
+ process.env.CLIENT_URL = 'https://example.com';
+ process.env.SENDGRID_SENDER_EMAIL = 'noreply@example.com';
+
+ userModelStub = sinon.stub(UserModel, 'findOne');
+ addCreatorToUserStub = sinon
+ .stub(authenticationUtils, 'addCreatorToUser')
+ .resolves('onboarding-token');
+ sendGridEmailStub = sinon
+ .stub(sendGridUtils, 'default')
+ .resolves({ success: true });
+ loggerStub = sinon.stub(logger, 'error');
+ });
+
+ afterEach(() => {
+ userModelStub.restore();
+ addCreatorToUserStub.restore();
+ sendGridEmailStub.restore();
+ loggerStub.restore();
+ });
+
+ it('returns generic success when no eligible account exists', async () => {
+ userModelStub.resolves(null);
+
+ const result = await sendOnboardingCompletionLink()(undefined, {
+ email: 'missing@example.com',
+ });
+
+ expect(result).to.deep.equal({
+ success: true,
+ message:
+ 'If an eligible account exists with that email, an onboarding link has been sent.',
+ });
+ sinon.assert.notCalled(addCreatorToUserStub);
+ sinon.assert.notCalled(sendGridEmailStub);
+ });
+
+ it('returns generic success when the user already completed signup', async () => {
+ userModelStub.resolves({ status: 4, hash_password: 'hashed-password' });
+
+ const result = await sendOnboardingCompletionLink()(undefined, {
+ email: 'registered@example.com',
+ });
+
+ expect(result).to.deep.equal({
+ success: true,
+ message:
+ 'If an eligible account exists with that email, an onboarding link has been sent.',
+ });
+ sinon.assert.notCalled(addCreatorToUserStub);
+ sinon.assert.notCalled(sendGridEmailStub);
+ });
+
+ it('emails a tokenized signup link for approved users without passwords', async () => {
+ userModelStub.resolves({
+ status: 4,
+ hash_password: null,
+ username: 'approved-user',
+ });
+
+ const result = await sendOnboardingCompletionLink()(undefined, {
+ email: 'approved@example.com',
+ });
+
+ expect(result).to.deep.equal({
+ success: true,
+ message:
+ 'If an eligible account exists with that email, an onboarding link has been sent.',
+ });
+
+ sinon.assert.calledOnce(addCreatorToUserStub);
+ sinon.assert.calledOnce(sendGridEmailStub);
+ expect(sendGridEmailStub.firstCall.args[0]).to.deep.include({
+ to: 'approved@example.com',
+ from: 'Team Quote.Vote ',
+ templateId: sendGridUtils.SENGRID_TEMPLATE_IDS.INVITATION_APPROVE,
+ });
+ expect(
+ sendGridEmailStub.firstCall.args[0].dynamicTemplateData,
+ ).to.deep.equal({
+ create_password_url:
+ 'https://example.com/auth/signup?token=onboarding-token',
+ });
+ });
+});
diff --git a/server/app/tests/mutations/userInvite/requestUserAccess.test.js b/server/app/tests/mutations/userInvite/requestUserAccess.test.js
new file mode 100644
index 00000000..5586f5c1
--- /dev/null
+++ b/server/app/tests/mutations/userInvite/requestUserAccess.test.js
@@ -0,0 +1,113 @@
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { UserInputError } from 'apollo-server-express';
+import UserModel from '~/resolvers/models/UserModel';
+import { requestUserAccess } from '~/resolvers/mutations/userInvite/requestUserAccess';
+import * as sendGridUtils from '~/utils/send-grid-mail';
+
+describe('Mutations > userInvite > requestUserAccess', () => {
+ let findOneStub;
+ let saveStub;
+ let sendGridEmailStub;
+
+ beforeEach(() => {
+ process.env.SENDGRID_SENDER_EMAIL = 'noreply@example.com';
+
+ findOneStub = sinon.stub(UserModel, 'findOne');
+ saveStub = sinon.stub(UserModel.prototype, 'save');
+ sendGridEmailStub = sinon
+ .stub(sendGridUtils, 'default')
+ .resolves({ success: true });
+ });
+
+ afterEach(() => {
+ findOneStub.restore();
+ saveStub.restore();
+ sendGridEmailStub.restore();
+ });
+
+ it('creates a prospect and sends confirmation email for new request', async () => {
+ findOneStub.resolves(null);
+ saveStub.resolves({
+ _id: 'new-user-id',
+ email: 'new@example.com',
+ status: 1,
+ });
+
+ const resolver = requestUserAccess();
+ const result = await resolver(
+ undefined,
+ {
+ requestUserAccessInput: { email: 'new@example.com' },
+ },
+ { requestId: 'req-test-1' },
+ );
+
+ expect(result).to.deep.equal({
+ _id: 'new-user-id',
+ email: 'new@example.com',
+ status: 1,
+ });
+
+ sinon.assert.calledOnce(sendGridEmailStub);
+ expect(sendGridEmailStub.firstCall.args[0]).to.deep.include({
+ requestId: 'req-test-1',
+ to: 'new@example.com',
+ from: 'Team Quote.Vote ',
+ templateId: sendGridUtils.SENGRID_TEMPLATE_IDS.INVITE_REQUEST_RECEIVED_CONFIRMATION,
+ });
+ });
+
+ it('reuses existing pending user and sends confirmation email', async () => {
+ findOneStub.resolves({
+ _id: 'existing-id',
+ email: 'pending@example.com',
+ status: 1,
+ });
+
+ const resolver = requestUserAccess();
+ const result = await resolver(
+ undefined,
+ {
+ requestUserAccessInput: { email: 'pending@example.com' },
+ },
+ { requestId: 'req-test-2' },
+ );
+
+ expect(result).to.deep.equal({
+ _id: 'existing-id',
+ email: 'pending@example.com',
+ status: 1,
+ });
+
+ sinon.assert.notCalled(saveStub);
+ sinon.assert.calledOnce(sendGridEmailStub);
+ });
+
+ it('throws when email belongs to a non-pending account', async () => {
+ findOneStub.resolves({
+ _id: 'registered-id',
+ email: 'registered@example.com',
+ status: 4,
+ });
+
+ const resolver = requestUserAccess();
+
+ let thrownError;
+ try {
+ await resolver(
+ undefined,
+ {
+ requestUserAccessInput: { email: 'registered@example.com' },
+ },
+ { requestId: 'req-test-3' },
+ );
+ } catch (error) {
+ thrownError = error;
+ }
+
+ expect(thrownError).to.be.instanceOf(UserInputError);
+ expect(thrownError.message).to.equal('Email already exists');
+ sinon.assert.notCalled(sendGridEmailStub);
+ });
+});
diff --git a/server/app/tests/queries/user/checkEmailStatus.test.js b/server/app/tests/queries/user/checkEmailStatus.test.js
new file mode 100644
index 00000000..cede67c4
--- /dev/null
+++ b/server/app/tests/queries/user/checkEmailStatus.test.js
@@ -0,0 +1,77 @@
+import { expect } from 'chai';
+import sinon from 'sinon';
+import UserModel from '~/resolvers/models/UserModel';
+import { checkEmailStatus } from '~/resolvers/queries/user/checkEmailStatus';
+import { logger } from '~/utils/logger';
+
+describe('Queries > user > checkEmailStatus', () => {
+ let userModelStub;
+ let loggerStub;
+
+ beforeEach(() => {
+ userModelStub = sinon.stub(UserModel, 'findOne');
+ loggerStub = sinon.stub(logger, 'error');
+ });
+
+ afterEach(() => {
+ userModelStub.restore();
+ loggerStub.restore();
+ });
+
+ it('returns not_requested when no user exists', async () => {
+ userModelStub.resolves(null);
+
+ const result = await checkEmailStatus()(undefined, {
+ email: 'missing@example.com',
+ });
+
+ expect(result).to.deep.equal({ status: 'not_requested' });
+ });
+
+ it('returns requested_pending for pending or declined invite statuses', async () => {
+ userModelStub.onFirstCall().resolves({ status: 1, hash_password: null });
+ userModelStub.onSecondCall().resolves({ status: 2, hash_password: null });
+
+ const pendingResult = await checkEmailStatus()(undefined, {
+ email: 'pending@example.com',
+ });
+ const declinedResult = await checkEmailStatus()(undefined, {
+ email: 'declined@example.com',
+ });
+
+ expect(pendingResult).to.deep.equal({ status: 'requested_pending' });
+ expect(declinedResult).to.deep.equal({ status: 'requested_pending' });
+ });
+
+ it('returns approved_no_password for approved users without a password', async () => {
+ userModelStub.resolves({ status: 4, hash_password: null });
+
+ const result = await checkEmailStatus()(undefined, {
+ email: 'approved@example.com',
+ });
+
+ expect(result).to.deep.equal({ status: 'approved_no_password' });
+ });
+
+ it('returns registered when the user has a password', async () => {
+ userModelStub.resolves({ status: 4, hash_password: 'hashed-password' });
+
+ const result = await checkEmailStatus()(undefined, {
+ email: 'registered@example.com',
+ });
+
+ expect(result).to.deep.equal({ status: 'registered' });
+ });
+
+ it('wraps and logs validation errors', async () => {
+ try {
+ await checkEmailStatus()(undefined, { email: '' });
+ expect.fail('Expected error to be thrown');
+ } catch (error) {
+ expect(error.message).to.equal(
+ 'Failed to check email status: Email parameter is required',
+ );
+ sinon.assert.calledOnce(loggerStub);
+ }
+ });
+});
diff --git a/server/app/tests/utils/requireAuth.test.js b/server/app/tests/utils/requireAuth.test.js
new file mode 100644
index 00000000..cbd06c94
--- /dev/null
+++ b/server/app/tests/utils/requireAuth.test.js
@@ -0,0 +1,54 @@
+import requireAuth from '~/utils/requireAuth';
+
+describe('requireAuth', () => {
+ it('returns false for public checkEmailStatus query', () => {
+ const query = `
+ query checkEmailStatus($email: String!) {
+ checkEmailStatus(email: $email)
+ }
+ `;
+
+ expect(requireAuth(query)).toBe(false);
+ });
+
+ it('returns false for public sendMagicLoginLink mutation', () => {
+ const mutation = `
+ mutation sendMagicLoginLink($email: String!) {
+ sendMagicLoginLink(email: $email)
+ }
+ `;
+
+ expect(requireAuth(mutation)).toBe(false);
+ });
+
+ it('returns true for protected users query', () => {
+ const query = `
+ query getUsers {
+ users {
+ _id
+ }
+ }
+ `;
+
+ expect(requireAuth(query)).toBe(true);
+ });
+
+ it('returns true when operation mixes public and protected root fields', () => {
+ const query = `
+ query mixed($email: String!) {
+ checkEmailStatus(email: $email)
+ users {
+ _id
+ }
+ }
+ `;
+
+ expect(requireAuth(query)).toBe(true);
+ });
+
+ it('returns true when query cannot be parsed', () => {
+ const invalidQuery = 'this is not graphql';
+
+ expect(requireAuth(invalidQuery)).toBe(true);
+ });
+});