Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/components/HGNForm/access/CopyQuestionnaireLinkButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
type="button"
color={color}
size={size}
className={className}
onClick={handleCopy}
title={link}
>
<FontAwesomeIcon icon={faLink} className="mr-1" />
Copy questionnaire link
</Button>
);
}

export default CopyQuestionnaireLinkButton;
92 changes: 92 additions & 0 deletions src/components/HGNForm/access/QuestionnaireAccessGate.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className={`${styles.container} ${darkMode ? styles.darkMode : ''}`}>
{!status ? (
<Spinner color="primary" aria-label="Checking questionnaire access" />
) : (
<section className={styles.card} role="alert">
<h1>HGN Questionnaire</h1>
<p>
{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}
</p>
{!status.error && (
<p>If you think this is a mistake, please contact your manager or an administrator.</p>
)}
</section>
)}
</main>
);
}

export const withQuestionnaireAccess = Component => {
function WithQuestionnaireAccess(props) {
return (
<QuestionnaireAccessGate>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<Component {...props} />
</QuestionnaireAccessGate>
);
}
WithQuestionnaireAccess.displayName = `withQuestionnaireAccess(${Component.displayName ||
Component.name ||
'Component'})`;
return WithQuestionnaireAccess;
};

export default QuestionnaireAccessGate;
Original file line number Diff line number Diff line change
@@ -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(<CopyQuestionnaireLinkButton />);
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(<CopyQuestionnaireLinkButton />);
fireEvent.click(screen.getByRole('button', { name: /copy questionnaire link/i }));

await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining(link)));
});
});
Original file line number Diff line number Diff line change
@@ -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(
<Provider store={store}>
<QuestionnaireAccessGate>
<div>Questionnaire content</div>
</QuestionnaireAccessGate>
</Provider>,
);

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(() => <Redirect to="/hgnform/page1" />);

render(
<Provider store={store}>
<MemoryRouter initialEntries={['/questionnaire']}>
<Route path="/questionnaire" component={LinkRedirect} />
<Route path="/hgnform/page1" render={() => <div>Page 1</div>} />
</MemoryRouter>
</Provider>,
);

expect(await screen.findByText('Page 1')).toBeInTheDocument();
});
});
12 changes: 12 additions & 0 deletions src/components/HGNForm/access/questionnaireLink.js
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 3 additions & 1 deletion src/components/HGNForm/questionpages/FollowupQuestions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
};
Expand Down
39 changes: 39 additions & 0 deletions src/components/HGNForm/styles/QuestionnaireAccessGate.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,6 +30,11 @@ function CommunityMembersPage() {
<div className={`${styles.container} ${darkMode ? styles.darkMode : ''}`}>
<h1 className={`${styles.title}`}>Community Member Filters</h1>

<div className={styles.questionnaireLink}>
<CopyQuestionnaireLinkButton />
<span>Share this link with people invited through the Dropbox setup process.</span>
</div>

<SearchBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} darkMode={darkMode} />

<div className={styles.toolbar}>
Expand Down
Loading
Loading