From 5cfeeda9b222de27740c5277f2a4b2ba61278ee5 Mon Sep 17 00:00:00 2001 From: Sireesha Kunchala Date: Fri, 23 Oct 2026 20:37:28 -0400 Subject: [PATCH 1/3] Add permanent HGN questionnaire link with Dropbox-invite access check Add /questionnaire as a permanent, shareable link to the HGN questionnaire. The link and all questionnaire pages only open for users invited through the Dropbox process. Admins can copy the link from Access Management and the Community Members dashboard. Co-Authored-By: Claude Opus 5.5 --- .../access/CopyQuestionnaireLinkButton.jsx | 35 +++++++ .../access/QuestionnaireAccessGate.jsx | 85 ++++++++++++++++ .../CopyQuestionnaireLinkButton.test.jsx | 33 +++++++ .../QuestionnaireAccessGate.test.jsx | 96 +++++++++++++++++++ .../HGNForm/access/questionnaireLink.js | 8 ++ .../questionpages/FollowupQuestions.jsx | 4 +- .../styles/QuestionnaireAccessGate.module.css | 39 ++++++++ .../CommunityMembersPage.jsx | 6 ++ .../style/CommunityMembersPage.module.css | 10 ++ .../AccessManagementModal.jsx | 6 ++ src/routes.jsx | 33 +++++-- src/utils/URL.js | 1 + 12 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 src/components/HGNForm/access/CopyQuestionnaireLinkButton.jsx create mode 100644 src/components/HGNForm/access/QuestionnaireAccessGate.jsx create mode 100644 src/components/HGNForm/access/__tests__/CopyQuestionnaireLinkButton.test.jsx create mode 100644 src/components/HGNForm/access/__tests__/QuestionnaireAccessGate.test.jsx create mode 100644 src/components/HGNForm/access/questionnaireLink.js create mode 100644 src/components/HGNForm/styles/QuestionnaireAccessGate.module.css diff --git a/src/components/HGNForm/access/CopyQuestionnaireLinkButton.jsx b/src/components/HGNForm/access/CopyQuestionnaireLinkButton.jsx new file mode 100644 index 00000000000..ad861df8ec2 --- /dev/null +++ b/src/components/HGNForm/access/CopyQuestionnaireLinkButton.jsx @@ -0,0 +1,35 @@ +import { Button } from 'reactstrap'; +import { toast } from 'react-toastify'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLink } from '@fortawesome/free-solid-svg-icons'; +import { getQuestionnaireLink } from './questionnaireLink'; + +// Copies the permanent questionnaire link so admins can share it with eligible people. +function CopyQuestionnaireLinkButton({ className = '', size = 'sm', color = 'secondary' }) { + const link = getQuestionnaireLink(); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(link); + toast.success('Questionnaire link copied'); + } catch { + toast.error(`Could not copy the link. Please copy it manually: ${link}`); + } + }; + + return ( + + ); +} + +export default CopyQuestionnaireLinkButton; diff --git a/src/components/HGNForm/access/QuestionnaireAccessGate.jsx b/src/components/HGNForm/access/QuestionnaireAccessGate.jsx new file mode 100644 index 00000000000..b97056083e2 --- /dev/null +++ b/src/components/HGNForm/access/QuestionnaireAccessGate.jsx @@ -0,0 +1,85 @@ +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Spinner } from 'reactstrap'; +import { ENDPOINTS } from '~/utils/URL'; +import styles from '../styles/QuestionnaireAccessGate.module.css'; + +const DENIED_MESSAGES = { + INACTIVE: 'Your account is not active, so the questionnaire is not available to you.', + ACCESS_REVOKED: + 'Your Highest Good Network Dropbox access has been revoked, so the questionnaire is no longer available to you.', +}; +const DEFAULT_DENIED_MESSAGE = + 'This questionnaire becomes available after you have been set up and invited through the Highest Good Network Dropbox process.'; + +// Remember a successful check so moving between questionnaire pages does not refetch. +const eligibleUserIds = new Set(); + +export const resetQuestionnaireAccessCache = () => eligibleUserIds.clear(); + +function QuestionnaireAccessGate({ children }) { + const userId = useSelector(state => state.auth.user?.userid); + const darkMode = useSelector(state => state.theme.darkMode); + const [status, setStatus] = useState(() => + eligibleUserIds.has(userId) ? { eligible: true } : null, + ); + + useEffect(() => { + if (!userId || eligibleUserIds.has(userId)) return undefined; + let cancelled = false; + + axios + .get(ENDPOINTS.HGN_FORM_ACCESS) + .then(({ data }) => { + if (data?.eligible) eligibleUserIds.add(userId); + if (!cancelled) setStatus({ eligible: !!data?.eligible, reason: data?.reason }); + }) + .catch(() => { + if (!cancelled) setStatus({ eligible: false, error: true }); + }); + + return () => { + cancelled = true; + }; + }, [userId]); + + if (status?.eligible) return children; + + return ( +
+ {!status ? ( + + ) : ( +
+

HGN Questionnaire

+

+ {status.error + ? 'We could not check your access to the questionnaire. Please refresh the page or try again later.' + : DENIED_MESSAGES[status.reason] || DEFAULT_DENIED_MESSAGE} +

+ {!status.error && ( +

If you think this is a mistake, please contact your manager or an administrator.

+ )} +
+ )} +
+ ); +} + +export const withQuestionnaireAccess = Component => { + function WithQuestionnaireAccess(props) { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); + } + WithQuestionnaireAccess.displayName = `withQuestionnaireAccess(${Component.displayName || + Component.name || + 'Component'})`; + return WithQuestionnaireAccess; +}; + +export default QuestionnaireAccessGate; diff --git a/src/components/HGNForm/access/__tests__/CopyQuestionnaireLinkButton.test.jsx b/src/components/HGNForm/access/__tests__/CopyQuestionnaireLinkButton.test.jsx new file mode 100644 index 00000000000..17af782cd61 --- /dev/null +++ b/src/components/HGNForm/access/__tests__/CopyQuestionnaireLinkButton.test.jsx @@ -0,0 +1,33 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { toast } from 'react-toastify'; +import CopyQuestionnaireLinkButton from '../CopyQuestionnaireLinkButton'; + +vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +describe('CopyQuestionnaireLinkButton', () => { + const link = `${window.location.origin}/questionnaire`; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('copies the permanent questionnaire link', async () => { + const writeText = vi.fn().mockResolvedValue(); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + fireEvent.click(screen.getByRole('button', { name: /copy questionnaire link/i })); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(toast.success).toHaveBeenCalled(); + }); + + it('shows the link when copying fails', async () => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error()) } }); + + render(); + fireEvent.click(screen.getByRole('button', { name: /copy questionnaire link/i })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining(link))); + }); +}); diff --git a/src/components/HGNForm/access/__tests__/QuestionnaireAccessGate.test.jsx b/src/components/HGNForm/access/__tests__/QuestionnaireAccessGate.test.jsx new file mode 100644 index 00000000000..3ac8c153ad1 --- /dev/null +++ b/src/components/HGNForm/access/__tests__/QuestionnaireAccessGate.test.jsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Redirect } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import axios from 'axios'; +import QuestionnaireAccessGate, { + resetQuestionnaireAccessCache, + withQuestionnaireAccess, +} from '../QuestionnaireAccessGate'; +import { ENDPOINTS } from '~/utils/URL'; + +vi.mock('axios'); + +const mockStore = configureMockStore([]); +const store = mockStore({ auth: { user: { userid: 'user1' } }, theme: { darkMode: false } }); + +const renderGate = () => + render( + + +
Questionnaire content
+
+
, + ); + +describe('QuestionnaireAccessGate', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetQuestionnaireAccessCache(); + }); + + it('shows the questionnaire to people invited through the Dropbox process', async () => { + axios.get.mockResolvedValue({ data: { eligible: true, reason: null } }); + + renderGate(); + + expect(await screen.findByText('Questionnaire content')).toBeInTheDocument(); + expect(axios.get).toHaveBeenCalledWith(ENDPOINTS.HGN_FORM_ACCESS); + }); + + it('blocks people who were not invited through the Dropbox process', async () => { + axios.get.mockResolvedValue({ data: { eligible: false, reason: 'NOT_INVITED' } }); + + renderGate(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /Highest Good Network Dropbox process/, + ); + expect(screen.queryByText('Questionnaire content')).not.toBeInTheDocument(); + }); + + it('explains when Dropbox access was revoked', async () => { + axios.get.mockResolvedValue({ data: { eligible: false, reason: 'ACCESS_REVOKED' } }); + + renderGate(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/has been revoked/); + }); + + it('blocks the questionnaire when the access check fails', async () => { + axios.get.mockRejectedValue(new Error('network')); + + renderGate(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not check your access/); + expect(screen.queryByText('Questionnaire content')).not.toBeInTheDocument(); + }); + + it('does not re-check access once a user is known to be eligible', async () => { + axios.get.mockResolvedValue({ data: { eligible: true } }); + + const { unmount } = renderGate(); + await screen.findByText('Questionnaire content'); + unmount(); + renderGate(); + + expect(screen.getByText('Questionnaire content')).toBeInTheDocument(); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('sends eligible people from the permanent link to the first questionnaire page', async () => { + axios.get.mockResolvedValue({ data: { eligible: true } }); + const LinkRedirect = withQuestionnaireAccess(() => ); + + render( + + + +
Page 1
} /> +
+
, + ); + + expect(await screen.findByText('Page 1')).toBeInTheDocument(); + }); +}); diff --git a/src/components/HGNForm/access/questionnaireLink.js b/src/components/HGNForm/access/questionnaireLink.js new file mode 100644 index 00000000000..08f9f0ea7e4 --- /dev/null +++ b/src/components/HGNForm/access/questionnaireLink.js @@ -0,0 +1,8 @@ +// Permanent, reusable questionnaire link. Administrators share this same link with +// everyone; it only opens the questionnaire for people who were set up and invited +// through the HGN Dropbox process (see QuestionnaireAccessGate). +export const QUESTIONNAIRE_PERMANENT_PATH = '/questionnaire'; +export const QUESTIONNAIRE_START_PATH = '/hgnform/page1'; + +export const getQuestionnaireLink = () => + `${window.location.origin}${QUESTIONNAIRE_PERMANENT_PATH}`; diff --git a/src/components/HGNForm/questionpages/FollowupQuestions.jsx b/src/components/HGNForm/questionpages/FollowupQuestions.jsx index de597ae1115..677b6e6c345 100644 --- a/src/components/HGNForm/questionpages/FollowupQuestions.jsx +++ b/src/components/HGNForm/questionpages/FollowupQuestions.jsx @@ -220,7 +220,9 @@ function FollowupQuestions() { if (res.status === 201) toast.success('Form submitted successfully!'); }) .catch(error => { - if (error.response.status === 500) toast.error('Error submitting form. Please try again.'); + if (error.response?.status === 403) + toast.error('You are not authorized to submit this questionnaire.'); + if (error.response?.status === 500) toast.error('Error submitting form. Please try again.'); }); navigate.push('/hgnform/page6'); }; diff --git a/src/components/HGNForm/styles/QuestionnaireAccessGate.module.css b/src/components/HGNForm/styles/QuestionnaireAccessGate.module.css new file mode 100644 index 00000000000..a3522261155 --- /dev/null +++ b/src/components/HGNForm/styles/QuestionnaireAccessGate.module.css @@ -0,0 +1,39 @@ +.container { + display: flex; + justify-content: center; + max-width: 900px; + margin: 0 auto; + padding: 48px 16px; +} + +.card { + width: 100%; + padding: 24px; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; +} + +.card h1 { + font-size: 1.5rem; + margin-bottom: 12px; +} + +.card p:last-child { + margin-bottom: 0; +} + +.darkMode { + color: #f0f0f0; +} + +.darkMode .card { + background: #2a2a2a; + border-color: #555; +} + +@media (width <= 600px) { + .container { + padding: 24px 12px; + } +} diff --git a/src/components/HGNHelpSkillsDashboard/CommunityMembersPage.jsx b/src/components/HGNHelpSkillsDashboard/CommunityMembersPage.jsx index 708fb536819..f44a28d8c17 100644 --- a/src/components/HGNHelpSkillsDashboard/CommunityMembersPage.jsx +++ b/src/components/HGNHelpSkillsDashboard/CommunityMembersPage.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; +import CopyQuestionnaireLinkButton from '../HGNForm/access/CopyQuestionnaireLinkButton'; import Accordion from './Accordion'; import { PreferenceFilterButtons, SkillFilterButtons } from './FilterButtons'; import RankedUserList from './RankedUserList'; @@ -29,6 +30,11 @@ function CommunityMembersPage() {

Community Member Filters

+
+ + Share this link with people invited through the Dropbox setup process. +
+
diff --git a/src/components/HGNHelpSkillsDashboard/style/CommunityMembersPage.module.css b/src/components/HGNHelpSkillsDashboard/style/CommunityMembersPage.module.css index 66053151e1d..59058d6a671 100644 --- a/src/components/HGNHelpSkillsDashboard/style/CommunityMembersPage.module.css +++ b/src/components/HGNHelpSkillsDashboard/style/CommunityMembersPage.module.css @@ -154,6 +154,16 @@ color: #000; } +.questionnaireLink { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + .toolbar { display: flex; align-items: center; diff --git a/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx b/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx index ce027c7fef6..90cd8149d5a 100644 --- a/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx +++ b/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx @@ -16,6 +16,7 @@ import { faInfoCircle, } from '@fortawesome/free-solid-svg-icons'; import { ENDPOINTS } from '../../../utils/URL'; +import CopyQuestionnaireLinkButton from '../../HGNForm/access/CopyQuestionnaireLinkButton'; import styles from './AccessManagementModal.module.css'; const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false }) => { @@ -538,6 +539,11 @@ const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false
)} + {/* Dropbox invite unlocks the permanent HGN questionnaire link for this user */} + {status === 'invited' && isDropbox && ( + + )} + {(status === 'active' || status === 'invited') && appName !== 'slack' && (