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} + +
+ )} + + setIsLoginOptionsModalOpen(false)} + maxWidth="xs" + fullWidth + > + We recognize this email. + + Choose how you'd like to log in + + + + + + + + setIsOnboardingModalOpen(false)} + maxWidth="xs" + fullWidth + > + Your invite is approved! + + Let's finish setting up your account. + + + + + + +
+ ) +} 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); + }); +});