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..03085e6a7c6 --- /dev/null +++ b/src/components/HGNForm/access/QuestionnaireAccessGate.jsx @@ -0,0 +1,92 @@ +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.', + NOT_HGN_TEAM_FOLDER: + 'This questionnaire is only available to people invited through the Highest Good Network Dropbox. Your Dropbox invite is for a different team folder.', +}; +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 flash the spinner. +// It only decides what to show while the check runs: access is re-checked on every mount, so +// revoked access takes effect on the next page or link opened. +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) return undefined; + let cancelled = false; + + axios + .get(ENDPOINTS.HGN_FORM_ACCESS) + .then(({ data }) => { + if (data?.eligible) eligibleUserIds.add(userId); + else eligibleUserIds.delete(userId); + if (!cancelled) setStatus({ eligible: !!data?.eligible, reason: data?.reason }); + }) + .catch(() => { + // A failed re-check is not a revocation, so don't take the form away from someone who + // is mid-way through it. Submitting is still checked by the server. + if (!cancelled && !eligibleUserIds.has(userId)) 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..658096e30e6 --- /dev/null +++ b/src/components/HGNForm/access/__tests__/QuestionnaireAccessGate.test.jsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor } 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('blocks people invited to a Dropbox team folder other than Highest Good Network', async () => { + axios.get.mockResolvedValue({ data: { eligible: false, reason: 'NOT_HGN_TEAM_FOLDER' } }); + + renderGate(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/different team folder/); + 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(); + }); + + describe('when the user already passed a check', () => { + const passCheckThenLeavePage = async () => { + axios.get.mockResolvedValueOnce({ data: { eligible: true } }); + const { unmount } = renderGate(); + await screen.findByText('Questionnaire content'); + unmount(); + }; + + it('keeps the form on screen while re-checking access', async () => { + await passCheckThenLeavePage(); + axios.get.mockResolvedValueOnce({ data: { eligible: true } }); + + renderGate(); + + expect(screen.getByText('Questionnaire content')).toBeInTheDocument(); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(screen.getByText('Questionnaire content')).toBeInTheDocument(); + }); + + it('blocks the questionnaire once Dropbox access has been revoked', async () => { + await passCheckThenLeavePage(); + axios.get.mockResolvedValue({ data: { eligible: false, reason: 'ACCESS_REVOKED' } }); + + const { unmount } = renderGate(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/has been revoked/); + expect(screen.queryByText('Questionnaire content')).not.toBeInTheDocument(); + + // The revocation also clears the remembered result, so the form never flashes again. + unmount(); + renderGate(); + + expect(screen.queryByText('Questionnaire content')).not.toBeInTheDocument(); + expect(await screen.findByRole('alert')).toHaveTextContent(/has been revoked/); + }); + + it('keeps the form on screen when the re-check fails', async () => { + await passCheckThenLeavePage(); + axios.get.mockRejectedValueOnce(new Error('network')); + + renderGate(); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('Questionnaire content')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); + + 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..58b14be239c --- /dev/null +++ b/src/components/HGNForm/access/questionnaireLink.js @@ -0,0 +1,12 @@ +// 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 Highest Good Network Dropbox (see QuestionnaireAccessGate). +export const QUESTIONNAIRE_PERMANENT_PATH = '/questionnaire'; +export const QUESTIONNAIRE_START_PATH = '/hgnform/page1'; + +export const getQuestionnaireLink = () => + `${window.location.origin}${QUESTIONNAIRE_PERMANENT_PATH}`; + +// Only invites into the Highest Good Network Dropbox team folder unlock the questionnaire. +// Invites into other team folders (e.g. Administration Team) do not. +export const QUESTIONNAIRE_DROPBOX_TEAM_FOLDER_KEY = 'HGN'; 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/Login/Login.jsx b/src/components/Login/Login.jsx index eb50d43c8c9..611fda25377 100644 --- a/src/components/Login/Login.jsx +++ b/src/components/Login/Login.jsx @@ -1,10 +1,11 @@ import Joi from 'joi'; import { connect } from 'react-redux'; -import { Link, withRouter } from 'react-router-dom'; +import { Link, matchPath, withRouter } from 'react-router-dom'; import NetlifyPoweredLink from '~/components/Footer/NetlifyPoweredLink'; import Form from '../common/Form/Form'; import { loginUser } from '../../actions/authActions'; import { clearErrors } from '../../actions/errorsActions'; +import { QUESTIONNAIRE_PERMANENT_PATH } from '../HGNForm/access/questionnaireLink'; export class Login extends Form { state = { @@ -36,7 +37,11 @@ export class Login extends Form { this.props.history.push(url); } else if (this.props.auth.isAuthenticated) { const redirectPath = this.props.location?.state?.from?.pathname; - if (redirectPath && redirectPath.includes('/hgnform')) { + const isQuestionnairePath = + redirectPath && + (redirectPath.includes('/hgnform') || + matchPath(redirectPath, { path: QUESTIONNAIRE_PERMANENT_PATH, exact: true })); + if (isQuestionnairePath) { this.props.history.push(redirectPath); } else { this.props.history.push('/dashboard'); diff --git a/src/components/Login/__tests__/Login.test.jsx b/src/components/Login/__tests__/Login.test.jsx index f3255f9e7f5..0cb28b6b1f8 100644 --- a/src/components/Login/__tests__/Login.test.jsx +++ b/src/components/Login/__tests__/Login.test.jsx @@ -279,4 +279,40 @@ describe('Login redirection logic', () => { expect(pushSpy).toHaveBeenCalledWith('/dashboard'); }); + + it.each([ + ['/questionnaire', '/questionnaire'], + ['/questionnaire/', '/questionnaire/'], + ['/hgnform/page3', '/hgnform/page3'], + ['/questionnaire-other', '/dashboard'], + ])('after logging in from %s, redirects to %s', (from, expected) => { + const history = createMemoryHistory(); + const pushSpy = vi.spyOn(history, 'push'); + const location = { state: { from: { pathname: from } } }; + + const { rerender } = renderWithProviders( + , + { history }, + ); + + rerender( + , + ); + + expect(pushSpy).toHaveBeenCalledWith(expected); + }); }); diff --git a/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx b/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx index ce027c7fef6..81034ee88b6 100644 --- a/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx +++ b/src/components/UserProfile/UserProfileModal/AccessManagementModal.jsx @@ -16,6 +16,8 @@ import { faInfoCircle, } from '@fortawesome/free-solid-svg-icons'; import { ENDPOINTS } from '../../../utils/URL'; +import CopyQuestionnaireLinkButton from '../../HGNForm/access/CopyQuestionnaireLinkButton'; +import { QUESTIONNAIRE_DROPBOX_TEAM_FOLDER_KEY } from '../../HGNForm/access/questionnaireLink'; import styles from './AccessManagementModal.module.css'; const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false }) => { @@ -426,6 +428,9 @@ const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false } await axios.post(endpoint, payload); toast.success(`${appConfigs[appName].name} invitation sent successfully`); + if (appName === 'dropbox' && selectedTeamFolder === QUESTIONNAIRE_DROPBOX_TEAM_FOLDER_KEY) { + toast.info('The HGN Questionnaire link is now active for this person.'); + } await fetchAccessData(); // Refresh data } catch (error) { //console.error(`Error inviting to ${appName}:`, error.response); @@ -489,6 +494,12 @@ const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false const touched = inputTouched[appName]; const isGithub = appName === 'github'; const isDropbox = appName === 'dropbox'; + // Only the Highest Good Network Dropbox team folder unlocks the HGN Questionnaire. + // Older invites have no teamFolderKey; the backend resolves those on the user's first visit. + const hasQuestionnaireAccess = + isDropbox && + status === 'invited' && + (!app?.teamFolderKey || app.teamFolderKey === QUESTIONNAIRE_DROPBOX_TEAM_FOLDER_KEY); const isCredentialValid = credentialValue.trim().length > 0; const isInviteLoading = inviteLoading[appName] || false; const isRevokeLoading = revokeLoading[appName] || false; @@ -508,7 +519,7 @@ const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false }; return ( -
+
@@ -538,6 +549,11 @@ const AccessManagementModal = ({ isOpen, onClose, userProfile, darkMode = false
)} + {/* Highest Good Network Dropbox invite unlocks the permanent HGN questionnaire link */} + {hasQuestionnaireAccess && ( + + )} + {(status === 'active' || status === 'invited') && appName !== 'slack' && (