From 1cc9479745512773f0e6898eac2833c9ee933250 Mon Sep 17 00:00:00 2001 From: Otavio Ximarelli Date: Sun, 8 Mar 2026 22:26:16 -0300 Subject: [PATCH 1/5] feat: Eyebrow CTA bar with email-based auth flow - Add EyebrowBar component (fixed above nav for unauthenticated users) - Email input + Continue CTA - Handles 4 email states: registered, not_requested, requested_pending, approved_no_password - Magic link login and password login options for registered users - Auto-submits invite request for brand new emails - Hides entirely when user is authenticated - Mobile-responsive layout - CSS var --eyebrow-height to offset AppBar and content - Add MagicLoginPage (/auth/magic-login?token=...) - Validates token via verifyUserPasswordResetToken query - Dispatches USER_LOGIN_SUCCESS and redirects to /search - Add checkEmailStatus GraphQL query (server) - Returns status: registered | not_requested | requested_pending | approved_no_password - Public (no auth required) - Add sendMagicLoginLink GraphQL mutation (server) - Generates 15-min JWT token via addCreatorToUser - Sends email via SendGrid MAGIC_LOGIN template - Public (no auth required) - Update GraphQL type definitions and resolver registrations - Mark checkEmailStatus and sendMagicLoginLink as public in requireAuth - Add MAGIC_LOGIN template ID to SendGrid constants Closes #295 --- .../layouts/adminStyle.jsx | 6 +- .../src/components/EyebrowBar/EyebrowBar.jsx | 330 ++++++++++++++++++ client/src/components/Navbars/MainNavBar.jsx | 2 +- client/src/graphql/mutations.jsx | 7 + client/src/graphql/query.jsx | 7 + client/src/main.jsx | 2 + client/src/mui-pro/mui-routes.jsx | 8 + .../views/MagicLoginPage/MagicLoginPage.jsx | 135 +++++++ server/app/data/resolvers/mutations.js | 1 + .../data/resolvers/mutations/user/index.js | 1 + .../mutations/user/sendMagicLoginLink.js | 57 +++ server/app/data/resolvers/queries.js | 3 + .../queries/user/checkEmailStatus.js | 48 +++ .../app/data/resolvers/queries/user/index.js | 1 + .../type_definition/mutation_definition.js | 3 + .../data/type_definition/query_definition.js | 4 + server/app/data/utils/requireAuth.js | 2 + server/app/data/utils/send-grid-mail.js | 1 + 18 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 client/src/components/EyebrowBar/EyebrowBar.jsx create mode 100644 client/src/views/MagicLoginPage/MagicLoginPage.jsx create mode 100644 server/app/data/resolvers/mutations/user/sendMagicLoginLink.js create mode 100644 server/app/data/resolvers/queries/user/checkEmailStatus.js 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..47d89b72 --- /dev/null +++ b/client/src/components/EyebrowBar/EyebrowBar.jsx @@ -0,0 +1,330 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react' +import { makeStyles } from '@material-ui/core/styles' +import { Button, Input, Typography } 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 } 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), + }, + }, + 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 [email, setEmail] = useState('') + const [phase, setPhase] = useState('input') // input | loading | result + const [status, setStatus] = useState(null) + const [feedback, setFeedback] = useState('') + const [error, setError] = useState('') + + const [requestUserAccess] = useMutation(REQUEST_USER_ACCESS_MUTATION) + const [sendMagicLink] = useMutation(SEND_MAGIC_LOGIN_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(() => { + if (loggedIn) { + document.documentElement.style.setProperty('--eyebrow-height', '0px') + return + } + // Measure after render + updateHeight() + window.addEventListener('resize', updateHeight) + return () => { + window.removeEventListener('resize', updateHeight) + document.documentElement.style.setProperty('--eyebrow-height', '0px') + } + }, [loggedIn, phase, updateHeight]) + + if (loggedIn) return null + + const handleContinue = async () => { + setError('') + + if (!EMAIL_VALIDATION_PATTERN.test(email)) { + setError('Please enter a valid email address.') + return + } + + setPhase('loading') + + try { + const { data } = await client.query({ + query: CHECK_EMAIL_STATUS, + variables: { email }, + fetchPolicy: 'network-only', + }) + + const emailStatus = data?.checkEmailStatus?.status + setStatus(emailStatus) + + switch (emailStatus) { + case 'not_requested': + // Auto-submit invite request + await requestUserAccess({ + variables: { requestUserAccessInput: { email } }, + }) + setFeedback("Invite requested! We'll email you when a spot opens up.") + setPhase('result') + break + + case 'requested_pending': + setFeedback('Your invite request is pending approval. Hang tight!') + setPhase('result') + break + + case 'approved_no_password': + setFeedback('Your invite is approved! Complete your signup to get started.') + setPhase('result') + break + + case 'registered': + setPhase('result') + break + + default: + setFeedback('Something went wrong. Please try again.') + setPhase('result') + break + } + } catch (err) { + setError(err.message || 'Something went wrong. Please try again.') + setPhase('input') + } + } + + const handleSendMagicLink = async () => { + try { + setFeedback('Sending login link...') + await sendMagicLink({ variables: { email } }) + setFeedback('Login link sent! Check your email.') + } catch (err) { + setError(err.message || 'Failed to send login link.') + } + } + + const handleReset = () => { + setEmail('') + setPhase('input') + setStatus(null) + setFeedback('') + setError('') + } + + return ( +
+ {phase === 'input' && ( + <> + + Join Quote Vote + +
+ setEmail(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleContinue()} + className={classes.emailInput} + /> + +
+ {error && ( + {error} + )} + + )} + + {phase === 'loading' && ( + Checking... + )} + + {phase === 'result' && status === 'registered' && ( +
+ + Welcome back! How would you like to sign in? + + + +
+ )} + + {phase === 'result' && status === 'approved_no_password' && ( +
+ {feedback} + +
+ )} + + {phase === 'result' && status !== 'registered' && status !== 'approved_no_password' && ( +
+ {feedback} + +
+ )} + + {error && phase === 'result' && ( + {error} + )} +
+ ) +} 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/graphql/mutations.jsx b/client/src/graphql/mutations.jsx index fb0d7bda..0d0e7d66 100644 --- a/client/src/graphql/mutations.jsx +++ b/client/src/graphql/mutations.jsx @@ -448,6 +448,13 @@ export const REMOVE_BUDDY = gql` } ` +// ===== Eyebrow CTA ===== +export const SEND_MAGIC_LOGIN_LINK = gql` + mutation sendMagicLoginLink($email: String!) { + sendMagicLoginLink(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..0477c5cc --- /dev/null +++ b/client/src/views/MagicLoginPage/MagicLoginPage.jsx @@ -0,0 +1,135 @@ +import React, { 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 { VERIFY_PASSWORD_RESET_TOKEN } from '@/graphql/query' +import { USER_LOGIN_SUCCESS } from 'store/user' + +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/server/app/data/resolvers/mutations.js b/server/app/data/resolvers/mutations.js index 5e0880e3..402f5467 100644 --- a/server/app/data/resolvers/mutations.js +++ b/server/app/data/resolvers/mutations.js @@ -62,6 +62,7 @@ export const resolver_mutations = function () { // User mutations sendPasswordResetEmail: userMutations.sendPasswordResetEmail(), + sendMagicLoginLink: userMutations.sendMagicLoginLink(), 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..153a21f8 100644 --- a/server/app/data/resolvers/mutations/user/index.js +++ b/server/app/data/resolvers/mutations/user/index.js @@ -5,6 +5,7 @@ export * from './updateUserAdminRight'; export * from './sendPasswordResetEmail'; export * from './updateUserPassword'; export * from './updateUserAvatar'; +export * from './sendMagicLoginLink'; // Import default exports and re-export as named exports import sendUserInvite from './sendUserInvite'; 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..180a3360 --- /dev/null +++ b/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js @@ -0,0 +1,57 @@ +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) => { + try { + const { email } = args; + const user = await UserModel.findOne({ + email: { $regex: new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') }, + }); + + if (!user) { + // 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) { + 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 = { + 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}`, + }, + }; + + await sendGridEmail(mailOptions); + + return { success: true, message: 'If an account exists with that email, a login link has been sent.' }; + } catch (err) { + logger.error('Error in sendMagicLoginLink', { error: err.message }); + throw new Error(`Failed to send magic login link: ${err.message}`); + } + }; +}; 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..63e5b4b9 --- /dev/null +++ b/server/app/data/resolvers/queries/user/checkEmailStatus.js @@ -0,0 +1,48 @@ +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) => { + try { + const { email } = args; + + if (!email) { + throw new Error('Email parameter is required'); + } + + const user = await UserModel.findOne({ + email: { $regex: new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') }, + }); + + if (!user) { + 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) { + return { status: 'requested_pending' }; + } + + // Status 4 = Approved but hasn't set up password yet + if (user.status === 4 && !user.hash_password) { + return { status: 'approved_no_password' }; + } + + // User has a password set — they are a registered user + if (user.hash_password) { + return { status: 'registered' }; + } + + // Fallback for any other state + return { status: 'not_requested' }; + } catch (err) { + logger.error('Error in checkEmailStatus', { error: err.message, email: args.email }); + 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..0bd50afa 100644 --- a/server/app/data/type_definition/mutation_definition.js +++ b/server/app/data/type_definition/mutation_definition.js @@ -64,6 +64,9 @@ 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 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..3e421122 100644 --- a/server/app/data/utils/requireAuth.js +++ b/server/app/data/utils/requireAuth.js @@ -21,6 +21,8 @@ const PUBLIC_QUERIES = [ 'getUserFollowInfo', 'group', 'groups', + 'checkEmailStatus', + 'sendMagicLoginLink', // add more public queries/mutations ]; diff --git a/server/app/data/utils/send-grid-mail.js b/server/app/data/utils/send-grid-mail.js index 6321c382..97212dbf 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', }; /** From ea302e00b384eacfe3f6c3f6c3eba77ab1384183 Mon Sep 17 00:00:00 2001 From: Otavio Ximarelli Date: Sun, 15 Mar 2026 23:57:07 -0300 Subject: [PATCH 2/5] feat(auth): add eyebrow parity flow and onboarding link support --- PR_DESCRIPTION.md | 68 ++-- .../src/components/EyebrowBar/EyebrowBar.jsx | 314 ++++++++++++------ .../components/EyebrowBar/EyebrowBar.test.jsx | 191 +++++++++++ client/src/graphql/mutations.jsx | 6 + .../views/MagicLoginPage/MagicLoginPage.jsx | 22 +- .../MagicLoginPage/MagicLoginPage.test.jsx | 99 ++++++ server/app/data/resolvers/mutations.js | 1 + .../data/resolvers/mutations/user/index.js | 26 +- .../user/sendOnboardingCompletionLink.js | 68 ++++ .../type_definition/mutation_definition.js | 3 + server/app/data/utils/requireAuth.js | 1 + .../mutations/user/sendMagicLoginLink.test.js | 96 ++++++ .../user/sendOnboardingCompletionLink.test.js | 99 ++++++ .../queries/user/checkEmailStatus.test.js | 77 +++++ 14 files changed, 933 insertions(+), 138 deletions(-) create mode 100644 client/src/components/EyebrowBar/EyebrowBar.test.jsx create mode 100644 client/src/views/MagicLoginPage/MagicLoginPage.test.jsx create mode 100644 server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js create mode 100644 server/app/tests/mutations/user/sendMagicLoginLink.test.js create mode 100644 server/app/tests/mutations/user/sendOnboardingCompletionLink.test.js create mode 100644 server/app/tests/queries/user/checkEmailStatus.test.js 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/components/EyebrowBar/EyebrowBar.jsx b/client/src/components/EyebrowBar/EyebrowBar.jsx index 47d89b72..e6c8713c 100644 --- a/client/src/components/EyebrowBar/EyebrowBar.jsx +++ b/client/src/components/EyebrowBar/EyebrowBar.jsx @@ -1,15 +1,28 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react' +import { + useState, useRef, useEffect, useCallback, +} from 'react' import { makeStyles } from '@material-ui/core/styles' -import { Button, Input, Typography } from '@material-ui/core' +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 } from '@/graphql/mutations' +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', @@ -17,7 +30,8 @@ const useStyles = makeStyles((theme) => ({ left: 0, right: 0, zIndex: theme.zIndex.appBar + 1, - background: 'linear-gradient(135deg, #2AE6B2 0%, #27C4E1 50%, #178BE1 100%)', + background: + 'linear-gradient(135deg, #2AE6B2 0%, #27C4E1 50%, #178BE1 100%)', padding: theme.spacing(1, 2), display: 'flex', alignItems: 'center', @@ -30,6 +44,22 @@ const useStyles = makeStyles((theme) => ({ 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', @@ -135,196 +165,280 @@ export default function EyebrowBar() { 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 [phase, setPhase] = useState('input') // input | loading | result - const [status, setStatus] = useState(null) + 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`) + document.documentElement.style.setProperty( + '--eyebrow-height', + `${height}px`, + ) } }, []) // Set/clear the CSS variable based on visibility useEffect(() => { - if (loggedIn) { + const isVisible = !(loggedIn || isDismissed) + + if (!isVisible) { document.documentElement.style.setProperty('--eyebrow-height', '0px') - return + } else { + updateHeight() + window.addEventListener('resize', updateHeight) } - // Measure after render - updateHeight() - window.addEventListener('resize', updateHeight) + return () => { - window.removeEventListener('resize', updateHeight) + if (isVisible) { + window.removeEventListener('resize', updateHeight) + } document.documentElement.style.setProperty('--eyebrow-height', '0px') } - }, [loggedIn, phase, updateHeight]) + }, [loggedIn, isDismissed, isLoading, feedback, updateHeight]) - if (loggedIn) return null + if (loggedIn || isDismissed) return null const handleContinue = async () => { + const normalizedEmail = email.trim() + setError('') + setFeedback('') + setIsLoginOptionsModalOpen(false) + setIsOnboardingModalOpen(false) - if (!EMAIL_VALIDATION_PATTERN.test(email)) { + if (!EMAIL_VALIDATION_PATTERN.test(normalizedEmail)) { setError('Please enter a valid email address.') return } - setPhase('loading') + if (normalizedEmail !== email) { + setEmail(normalizedEmail) + } + + setIsLoading(true) try { const { data } = await client.query({ query: CHECK_EMAIL_STATUS, - variables: { email }, + variables: { email: normalizedEmail }, fetchPolicy: 'network-only', }) const emailStatus = data?.checkEmailStatus?.status - setStatus(emailStatus) switch (emailStatus) { case 'not_requested': // Auto-submit invite request await requestUserAccess({ - variables: { requestUserAccessInput: { email } }, + variables: { requestUserAccessInput: { email: normalizedEmail } }, }) - setFeedback("Invite requested! We'll email you when a spot opens up.") - setPhase('result') + setFeedback( + "Your request has been received! You'll be notified once approved.", + ) break case 'requested_pending': - setFeedback('Your invite request is pending approval. Hang tight!') - setPhase('result') + setFeedback('Your invite request is still waiting for approval.') break case 'approved_no_password': - setFeedback('Your invite is approved! Complete your signup to get started.') - setPhase('result') + setFeedback('') + setIsOnboardingModalOpen(true) break case 'registered': - setPhase('result') + setFeedback('') + setIsLoginOptionsModalOpen(true) break default: - setFeedback('Something went wrong. Please try again.') - setPhase('result') + setFeedback('An error has occurred') break } } catch (err) { - setError(err.message || 'Something went wrong. Please try again.') - setPhase('input') + setError(err.message || 'An error has occurred') + } finally { + setIsLoading(false) } } const handleSendMagicLink = async () => { + setError('') + try { setFeedback('Sending login link...') - await sendMagicLink({ variables: { email } }) - setFeedback('Login link sent! Check your email.') + 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('') - setPhase('input') - setStatus(null) setFeedback('') setError('') + setIsLoginOptionsModalOpen(false) + setIsOnboardingModalOpen(false) } return (
- {phase === 'input' && ( - <> - - Join Quote Vote - -
- setEmail(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleContinue()} - className={classes.emailInput} - /> - -
- {error && ( - {error} - )} - - )} + + Join Quote Vote +
+ { + setEmail(e.target.value) + if (error) setError('') + }} + onKeyDown={(e) => e.key === 'Enter' && handleContinue()} + className={classes.emailInput} + /> + +
- {phase === 'loading' && ( - Checking... - )} + {error && {error}} - {phase === 'result' && status === 'registered' && ( + {!!feedback && (
- - Welcome back! How would you like to sign in? - - + {feedback}
)} - {phase === 'result' && status === 'approved_no_password' && ( -
- {feedback} + setIsLoginOptionsModalOpen(false)} + maxWidth="xs" + fullWidth + > + We recognize this email. + + Choose how you'd like to log in + + + -
- )} + + - {phase === 'result' && status !== 'registered' && status !== 'approved_no_password' && ( -
- {feedback} - + -
- )} - - {error && phase === 'result' && ( - {error} - )} + + +
) } 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/graphql/mutations.jsx b/client/src/graphql/mutations.jsx index 0d0e7d66..872966c4 100644 --- a/client/src/graphql/mutations.jsx +++ b/client/src/graphql/mutations.jsx @@ -455,6 +455,12 @@ export const SEND_MAGIC_LOGIN_LINK = gql` } ` +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/views/MagicLoginPage/MagicLoginPage.jsx b/client/src/views/MagicLoginPage/MagicLoginPage.jsx index 0477c5cc..a6ffbfe7 100644 --- a/client/src/views/MagicLoginPage/MagicLoginPage.jsx +++ b/client/src/views/MagicLoginPage/MagicLoginPage.jsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from 'react' +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 { VERIFY_PASSWORD_RESET_TOKEN } from '@/graphql/query' import { USER_LOGIN_SUCCESS } from 'store/user' +import { VERIFY_PASSWORD_RESET_TOKEN } from '@/graphql/query' const useStyles = makeStyles((theme) => ({ root: { @@ -74,11 +74,13 @@ export default function MagicLoginPage() { } // Dispatch login success with user data - dispatch(USER_LOGIN_SUCCESS({ - data: user, - loading: false, - loginError: null, - })) + dispatch( + USER_LOGIN_SUCCESS({ + data: user, + loading: false, + loginError: null, + }), + ) setStatus('success') @@ -90,9 +92,9 @@ export default function MagicLoginPage() { 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.', + err.message?.includes('expired') ? + 'This login link has expired. Please request a new one.' : + 'Invalid or expired login link.', ) } } 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 402f5467..0c9e44fa 100644 --- a/server/app/data/resolvers/mutations.js +++ b/server/app/data/resolvers/mutations.js @@ -63,6 +63,7 @@ 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 153a21f8..755b42f7 100644 --- a/server/app/data/resolvers/mutations/user/index.js +++ b/server/app/data/resolvers/mutations/user/index.js @@ -1,3 +1,11 @@ +// Import default exports and re-export as named exports +import sendUserInvite from './sendUserInvite'; +import reportUser from './reportUser'; +import recalculateReputation from './recalculateReputation'; +import reportBot from './reportBot'; +import disableUser from './disableUser'; +import enableUser from './enableUser'; + export * from './addUser'; export * from './followUser'; export * from './updateUser'; @@ -6,13 +14,13 @@ export * from './sendPasswordResetEmail'; export * from './updateUserPassword'; export * from './updateUserAvatar'; export * from './sendMagicLoginLink'; +export * from './sendOnboardingCompletionLink'; -// Import default exports and re-export as named exports -import sendUserInvite from './sendUserInvite'; -import reportUser from './reportUser'; -import recalculateReputation from './recalculateReputation'; -import reportBot from './reportBot'; -import disableUser from './disableUser'; -import enableUser from './enableUser'; - -export { sendUserInvite, reportUser, recalculateReputation, reportBot, disableUser, enableUser }; +export { + sendUserInvite, + reportUser, + recalculateReputation, + reportBot, + disableUser, + enableUser, +}; 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..ff79f0ff --- /dev/null +++ b/server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js @@ -0,0 +1,68 @@ +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) => { + try { + const { email } = args; + const user = await UserModel.findOne({ + email: { + $regex: new RegExp( + `^${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, + 'i', + ), + }, + }); + + if (!user || user.hash_password || user.status !== 4) { + 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 = { + 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}`, + }, + }; + + await sendGridEmail(mailOptions); + + 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', { + error: err.message, + }); + throw new Error( + `Failed to send onboarding completion link: ${err.message}`, + ); + } + }; +}; diff --git a/server/app/data/type_definition/mutation_definition.js b/server/app/data/type_definition/mutation_definition.js index 0bd50afa..89846111 100644 --- a/server/app/data/type_definition/mutation_definition.js +++ b/server/app/data/type_definition/mutation_definition.js @@ -67,6 +67,9 @@ export const Mutation = `type Mutation { # 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/utils/requireAuth.js b/server/app/data/utils/requireAuth.js index 3e421122..30ec8517 100644 --- a/server/app/data/utils/requireAuth.js +++ b/server/app/data/utils/requireAuth.js @@ -23,6 +23,7 @@ const PUBLIC_QUERIES = [ 'groups', 'checkEmailStatus', 'sendMagicLoginLink', + 'sendOnboardingCompletionLink', // add more public queries/mutations ]; 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/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); + } + }); +}); From 09a072a019ed3b917dec8cc5330a0a200d5b3101 Mon Sep 17 00:00:00 2001 From: Otavio Ximarelli Date: Tue, 17 Mar 2026 00:40:23 -0300 Subject: [PATCH 3/5] add some dobbug logs to understand whats happen in prod, only for test --- client/src/config/apollo.js | 3 + .../mutations/user/sendMagicLoginLink.js | 41 ++++++++++++- .../user/sendOnboardingCompletionLink.js | 35 ++++++++++- .../mutations/userInvite/requestUserAccess.js | 28 ++++++++- .../queries/user/checkEmailStatus.js | 52 +++++++++++++++- server/app/data/utils/send-grid-mail.js | 23 ++++++- server/app/server.js | 61 ++++++++++++++++++- 7 files changed, 228 insertions(+), 15 deletions(-) diff --git a/client/src/config/apollo.js b/client/src/config/apollo.js index 8ad87034..cb3a5de9 100644 --- a/client/src/config/apollo.js +++ b/client/src/config/apollo.js @@ -36,12 +36,15 @@ const httpLink = createHttpLink({ // Create an auth link that dynamically adds the authorization header const authLink = new ApolloLink((operation, forward) => { + const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + // Get the token from localStorage for each request const token = localStorage.getItem('token') // Build headers object const headers = { 'Content-Type': 'application/json', + 'x-request-id': requestId, ...operation.getContext().headers, } diff --git a/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js b/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js index 180a3360..04d48fbd 100644 --- a/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js +++ b/server/app/data/resolvers/mutations/user/sendMagicLoginLink.js @@ -6,19 +6,35 @@ import sendGridEmail, { } from '../../../utils/send-grid-mail'; export const sendMagicLoginLink = () => { - return async (_, args) => { + 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.' }; } @@ -38,6 +54,7 @@ export const sendMagicLoginLink = () => { 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, @@ -46,11 +63,29 @@ export const sendMagicLoginLink = () => { }, }; - await sendGridEmail(mailOptions); + 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', { error: err.message }); + 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 index ff79f0ff..8ab3ab4b 100644 --- a/server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js +++ b/server/app/data/resolvers/mutations/user/sendOnboardingCompletionLink.js @@ -6,9 +6,15 @@ import sendGridEmail, { } from '../../../utils/send-grid-mail'; export const sendOnboardingCompletionLink = () => { - return async (_, args) => { + 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( @@ -19,6 +25,13 @@ export const sendOnboardingCompletionLink = () => { }); 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: @@ -41,6 +54,7 @@ export const sendOnboardingCompletionLink = () => { 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, @@ -49,7 +63,23 @@ export const sendOnboardingCompletionLink = () => { }, }; - await sendGridEmail(mailOptions); + 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, @@ -58,6 +88,7 @@ export const sendOnboardingCompletionLink = () => { }; } catch (err) { logger.error('Error in sendOnboardingCompletionLink', { + requestId: context && context.requestId, error: err.message, }); throw new Error( 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/user/checkEmailStatus.js b/server/app/data/resolvers/queries/user/checkEmailStatus.js index 63e5b4b9..76aaf6d9 100644 --- a/server/app/data/resolvers/queries/user/checkEmailStatus.js +++ b/server/app/data/resolvers/queries/user/checkEmailStatus.js @@ -6,9 +6,15 @@ import { logger } from '../../../utils/logger'; * Returns one of: "registered", "not_requested", "requested_pending", "approved_no_password" */ export const checkEmailStatus = () => { - return async (_, args) => { + 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'); @@ -19,29 +25,71 @@ export const checkEmailStatus = () => { }); 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 }); + 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/utils/send-grid-mail.js b/server/app/data/utils/send-grid-mail.js index 97212dbf..95cc22ad 100644 --- a/server/app/data/utils/send-grid-mail.js +++ b/server/app/data/utils/send-grid-mail.js @@ -23,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 @@ -61,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, @@ -69,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, @@ -80,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, }); @@ -90,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 }; }, }); From 51cdcf70e22e068e781747c36ad40c0ca3ee0721 Mon Sep 17 00:00:00 2001 From: Otavio Ximarelli Date: Tue, 17 Mar 2026 00:49:43 -0300 Subject: [PATCH 4/5] more debugging --- client/src/config/apollo.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/config/apollo.js b/client/src/config/apollo.js index cb3a5de9..d5da218c 100644 --- a/client/src/config/apollo.js +++ b/client/src/config/apollo.js @@ -36,7 +36,14 @@ const httpLink = createHttpLink({ // Create an auth link that dynamically adds the authorization header const authLink = new ApolloLink((operation, forward) => { - const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + 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') @@ -44,10 +51,13 @@ const authLink = new ApolloLink((operation, forward) => { // Build headers object const headers = { 'Content-Type': 'application/json', - 'x-request-id': requestId, ...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 From ff43b4342cedbf5c7357e65163c8a8deb84d09e2 Mon Sep 17 00:00:00 2001 From: Otavio Ximarelli Date: Tue, 17 Mar 2026 01:31:34 -0300 Subject: [PATCH 5/5] feat(auth): enhance requireAuth logic and add tests for user access mutation --- server/app/data/utils/requireAuth.js | 48 +++++++- .../userInvite/requestUserAccess.test.js | 113 ++++++++++++++++++ server/app/tests/utils/requireAuth.test.js | 54 +++++++++ 3 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 server/app/tests/mutations/userInvite/requestUserAccess.test.js create mode 100644 server/app/tests/utils/requireAuth.test.js diff --git a/server/app/data/utils/requireAuth.js b/server/app/data/utils/requireAuth.js index 30ec8517..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', @@ -27,15 +28,50 @@ const PUBLIC_QUERIES = [ // 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/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/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); + }); +});