diff --git a/README.rst b/README.rst index 66dcc6dc5..32a1e5feb 100644 --- a/README.rst +++ b/README.rst @@ -43,6 +43,14 @@ This component requires that the following environment variable be set by the co * ``LMS_BASE_URL`` - The URL of the LMS of your Open edX instance. * ``LOGO_TRADEMARK_URL`` - This is a URL to a logo for use in the footer. This is a different environment variable than ``LOGO_URL`` (used in frontend-component-header) to accommodate sites that would like to have additional trademark information on a logo in the footer, such as a (tm) or (r) symbol. +Optionally, use the following variables to configure the Terms of Service Modal for the MFEs: + +* ``MODAL_UPDATE_TERMS_OF_SERVICE`` - Object that reppresent the text and checkbox configured for the TOS Modal +* ``PRIVACY_POLICY_URL`` - The URL for the privacy policy. +* ``TERMS_OF_SERVICE_URL`` - The URL for the terms of service. +* ``TOS_AND_HONOR_CODE`` - The URL for the honor code. + + Installation ============ @@ -93,6 +101,46 @@ This library has the following exports: language from its dropdown. * supportedLanguages: An array of objects representing available languages. See example below for object shape. +Terms of Service Modal +======================= + +The Terms of Service Modal allows configuring a modal that prompts users to accept updated terms and conditions, +including Data Authorization, Terms of Service, and/or an Honor Code. + +To configure this modal, use either the MFE build-time configuration (via ``.env``, ``.env.config.js``) or the +runtime MFE Config API to set the MODAL_UPDATE_TERMS_OF_SERVICE object. Example: + +.. code-block:: python + + MFE_CONFIG["MODAL_UPDATE_TERMS_OF_SERVICE"] = { + "date_iso_8601": "2025-06-08", + "title": { + "en": "English modal title", + "pt-pt": "Portuguese modal title" + }, + "body": { + "en": "English modal text", + "pt-pt": "Portuguese modal text" + }, + "data_authorization": true, + "terms_of_service": true, + "honor_code": true, + } + +Where: + +* **date_iso_8601** *(required)*: This is a required field representing the date of the terms of service update in ISO 8601 format. It is used to track whether the user has accepted the new terms since the last update. +* **title** *(optional)*: It is an object that provides the modal title text for different languages. +* **body** *(optional)*: It is an object that provides the body content of the modal for different languages. +* **data_authorization** *(optional)*: Boolean that determines whether the Privacy Policy checkbox should be displayed in the modal. +* **terms_of_service** *(optional)*: Boolean that controls whether the Terms of Service checkbox should be shown in the modal. +* **honor_code** *(optional)*: Boolean that specifies whether the Honor Code checkbox should be displayed in the modal. + +The modal conditions: + +* The modal will be displayed if the user has not yet accepted the latest terms and conditions as defined by date_iso_8601. +* If any of the optional fields (data_authorization, terms_of_service, honor_code) are not specified, the corresponding checkboxes will not appear in the modal. The modal is multilingual, and the content for both the title and body can be customized for different locales using language keys like en (English), pt-pt (Portuguese), etc. + Plugin ====== The footer can be replaced using using `Frontend Plugin Framework `_. diff --git a/package.json b/package.json index 92d2d7d61..37fb0bcc0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "build": "make build", "i18n_extract": "fedx-scripts formatjs extract", - "lint": "fedx-scripts eslint --ext .js --ext .jsx .", + "lint": "fedx-scripts eslint --ext .js --ext .jsx . --fix", "snapshot": "fedx-scripts jest --updateSnapshot", "start": "fedx-scripts webpack-dev-server --progress", "start:with-theme": "paragon install-theme && npm start && npm install", diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 4e0f7a70c..7c290f2b1 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -2,14 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { + APP_CONFIG_INITIALIZED, ensureConfig, getConfig, subscribe, +} from '@edx/frontend-platform'; import { AppContext } from '@edx/frontend-platform/react'; +import { hydrateAuthenticatedUser } from '@edx/frontend-platform/auth'; import messages from './Footer.messages'; import LanguageSelector from './LanguageSelector'; import FooterLinks from './footer-links/FooterNavLinks'; import FooterSocial from './footer-links/FooterSocialLinks'; import parseEnvSettings from '../utils/parseData'; +import ModalToS from './modal-tos'; ensureConfig([ 'LMS_BASE_URL', @@ -134,6 +138,9 @@ class SiteFooter extends React.Component { + { + config.MODAL_UPDATE_TERMS_OF_SERVICE && + } ); } @@ -157,5 +164,9 @@ SiteFooter.defaultProps = { supportedLanguages: [], }; +subscribe(APP_CONFIG_INITIALIZED, async () => { + await hydrateAuthenticatedUser(); +}); + export default injectIntl(SiteFooter); export { EVENT_NAMES }; diff --git a/src/components/modal-tos/ModalToS.jsx b/src/components/modal-tos/ModalToS.jsx new file mode 100644 index 000000000..0390e4b30 --- /dev/null +++ b/src/components/modal-tos/ModalToS.jsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from 'react'; + +import { convertKeyNames, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { FormattedMessage, getLocale, injectIntl } from '@edx/frontend-platform/i18n'; +import { + Button, Form, Hyperlink, ModalDialog, useToggle, useCheckboxSetValues, + ActionRow, + useWindowSize, +} from '@openedx/paragon'; + +import { getUserTOSPreference, updateUserTOSPreference } from './data/api'; +import { CAMEL_CASE_KEYS } from './data/constants'; +import parseEnvSettings from '../../utils/parseData'; + +const createTOSLink = (chunks, url) => ( + {chunks} + +); + +const ModalToS = () => { + const [tosPreference, setTosPreference] = useState(undefined); + const [isOpen, open, close] = useToggle(false); + const { width } = useWindowSize(); + const checkboxLabelStyle = (width < 768) ? 'd-inline-block' : null; + const { + MODAL_UPDATE_TERMS_OF_SERVICE, + PRIVACY_POLICY_URL, + SITE_NAME, + TERMS_OF_SERVICE_URL, + TOS_AND_HONOR_CODE, + } = getConfig(); + + const modalSettings = parseEnvSettings(MODAL_UPDATE_TERMS_OF_SERVICE) || MODAL_UPDATE_TERMS_OF_SERVICE || {}; + const { + body = {}, + title = {}, + dateIso8601, + dataAuthorization = false, + honorCode = false, + termsOfService = false, + } = convertKeyNames(modalSettings, CAMEL_CASE_KEYS); + + const { + dateJoined, + username, + } = getAuthenticatedUser(); + + const lang = getLocale() || 'en'; + const tosKey = `update_terms_of_service_${dateIso8601?.replaceAll('-', '_')}`; + const [checkboxValues, { add, remove }] = useCheckboxSetValues([]); + + useEffect(() => { + if (username && dateIso8601) { + getUserTOSPreference(username, tosKey).then(userTos => { + setTosPreference(userTos); + if (userTos === null) { + open(); + } + }); + } + }, [dateIso8601, tosKey, username, open]); + + const setAcceptance = () => { + updateUserTOSPreference(username, tosKey); + close(); + }; + + const numCheckBox = [dataAuthorization, termsOfService, honorCode] + .reduce((prev, curr) => (curr ? prev + 1 : prev), 0); + + const handleChange = e => { + if (e.target.checked) { + add(e.target.value); + } else { + remove(e.target.value); + } + }; + + if (tosPreference || !dateIso8601 || !username + || new Date(dateIso8601) < new Date(dateJoined)) { + return null; + } + + return ( + + {title[lang] && ( + + + {title[lang]} + + + )} + + {body[lang]} +
+ + {dataAuthorization + && ( + + createTOSLink(chunks, PRIVACY_POLICY_URL), + }} + /> + + )} + {termsOfService + && ( + + createTOSLink(chunks, TERMS_OF_SERVICE_URL), + platformName: SITE_NAME, + }} + /> + + )} + {honorCode + && ( + + createTOSLink(chunks, TOS_AND_HONOR_CODE), + platformName: SITE_NAME, + }} + /> + + )} + +
+ + + +
+
+ ); +}; + +export default injectIntl(ModalToS); diff --git a/src/components/modal-tos/ModalToS.test.jsx b/src/components/modal-tos/ModalToS.test.jsx new file mode 100644 index 000000000..21292e235 --- /dev/null +++ b/src/components/modal-tos/ModalToS.test.jsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { mergeConfig } from '@edx/frontend-platform'; +import { IntlProvider, getLocale } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import '@testing-library/jest-dom'; + +import { + render, fireEvent, screen, waitFor, act, +} from '@testing-library/react'; +import { getUserTOSPreference, updateUserTOSPreference } from './data/api'; + +import ModalToS from '.'; + +jest.mock('./data/api'); +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + getLocale: jest.fn(), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedUser: jest.fn(), +})); + +const mockUser = { + username: 'test_user', + dateJoined: '2023-10-01T00:00:00Z', +}; + +const messagesPt = { + 'modalToS.dataAuthorization.checkbox.label': 'Li e compreendi a Política de Privacidade', + 'modalToS.termsOfService.checkbox.label': 'Li e compreendi o {platformName} Termos e Condições', + 'modalToS.honorCode.checkbox.label': 'Li e compreendi o {platformName} Honor Code', + 'modalToS.acceptance.button': 'Aceito os novos termos de serviço', +}; +// eslint-disable-next-line react/prop-types +const Component = ({ locale = 'en', messages }) => (); + +describe('ModalTOS Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + getAuthenticatedUser.mockReturnValue(mockUser); + + mergeConfig({ + MODAL_UPDATE_TERMS_OF_SERVICE: { + title: { + 'pt-pt': 'Atenção', + en: 'Attention', + }, + body: { + 'pt-pt': 'A informação legal para uso do serviço foi atualizada.', + en: 'The legal information for using the service has been updated.', + }, + date_iso_8601: '2023-11-10', + data_authorization: true, + terms_of_service: true, + honor_code: false, + }, + TERMS_OF_SERVICE_URL: '/terms', + PRIVACY_POLICY_URL: '/privacy', + TOS_AND_HONOR_CODE: '/honor-code', + }); + }); + + test('does not render the modal if MODAL_UPDATE_TERMS_OF_SERVICE is not configured', async () => { + mergeConfig({ + MODAL_UPDATE_TERMS_OF_SERVICE: '', + }); + + getUserTOSPreference.mockResolvedValue(null); // Simulate user hasn't accepted yet + + render(); + + // Wait for any possible modal render (if it were to render) + await waitFor(() => { + expect(screen.queryByText(/Attention/i)).toBeNull(); + }); + + // Assert that the modal does not appear + expect(screen.queryByText(/Attention/i)).toBeNull(); + }); + + test('renders the modal with configured checkboxes when user have not accept the new terms of service', async () => { + getUserTOSPreference.mockResolvedValue(null); + render(); + + await waitFor(() => screen.getByText(/Attention/i)); + + // Check if the modal is rendered with the correct checkboxes + expect(screen.getByText(/Attention/i)).toBeInTheDocument(); + expect(document.querySelectorAll('input[type="checkbox"]').length).toBe(2); + }); + + test('renders the modal in the correct language based on the user’s cookie', async () => { + getUserTOSPreference.mockResolvedValue(null); + getLocale.mockReturnValue('pt-pt'); + + render(); + + // Wait until the modal renders + await waitFor(() => screen.getByText(/Atenção/i)); + + // Check that the title and body are rendered in Portuguese + expect(screen.getByText(/Atenção/i)).toBeInTheDocument(); + expect(screen.getByText(/Aceito os novos termos de serviço/i)).toBeInTheDocument(); + }); + + test('disables the button until all checkboxes are checked', async () => { + getUserTOSPreference.mockResolvedValue(null); + + render(); + + await waitFor(() => screen.getByText(/Attention/i)); + + const button = screen.getByRole('button', { name: /Accept new terms of service/i }); + expect(button).toBeDisabled(); + + const privacyCheckbox = screen.getByLabelText(/Privacy Policy/i); + const termsCheckbox = screen.getByLabelText(/Terms of Service/i, { selector: 'input' }); + + fireEvent.click(privacyCheckbox); // Click first checkbox + expect(button).toBeDisabled(); // Button should still be disabled + + fireEvent.click(termsCheckbox); // Click second checkbox + expect(button).toBeEnabled(); // Button should now be enabled + }); + + test('calls updateUserTOSPreference and closes modal when clicking Accept', async () => { + getUserTOSPreference.mockResolvedValue(null); + + render(); + + await waitFor(() => screen.getByText(/Attention/i)); + + const privacyCheckbox = screen.getByLabelText(/Privacy Policy/i); + const termsCheckbox = screen.getByLabelText(/Terms of Service/i, { selector: 'input' }); + + // Check all checkboxes + fireEvent.click(privacyCheckbox); + fireEvent.click(termsCheckbox); + + const button = screen.getByRole('button', { name: /Accept new terms of service/i }); + fireEvent.click(button); // Click the "Accept" button + + // Check that the API call is made with the correct arguments + expect(updateUserTOSPreference).toHaveBeenCalledWith(mockUser.username, 'update_terms_of_service_2023_11_10'); + + // Wait until modal closes (assert that the modal content is removed from the DOM) + await waitFor(() => expect(screen.queryByText(/Attention/i)).not.toBeInTheDocument()); + }); + + it('does not render the modal if the user already accept the terms of service', async () => { + getUserTOSPreference.mockResolvedValue('True'); // Simulate user has already accepted TOS + + await act(async () => { + render(); + }); + + // Assert that the modal does not appear + expect(screen.queryByText(/Attention/i)).toBeNull(); + }); +}); diff --git a/src/components/modal-tos/data/api.js b/src/components/modal-tos/data/api.js new file mode 100644 index 000000000..748da513c --- /dev/null +++ b/src/components/modal-tos/data/api.js @@ -0,0 +1,28 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { ACCEPTANCE_TOS } from './constants'; + +const preferencesUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/`; + +export const getUserTOSPreference = async (username, tosKey) => { + try { + const { data } = await getAuthenticatedHttpClient().get(`${preferencesUrl()}${username}/${tosKey}`); + return data; + } catch (error) { + if (error.customAttributes.httpErrorStatus === 404) { return null; } + throw error; + } +}; + +export const updateUserTOSPreference = async (username, tosKey) => { + try { + const response = await getAuthenticatedHttpClient().put(`${preferencesUrl()}${username}/${tosKey}`, ACCEPTANCE_TOS, { + headers: { + 'Content-Type': 'application/json', + }, + }); + return response; + } catch (error) { + return null; + } +}; diff --git a/src/components/modal-tos/data/constants.js b/src/components/modal-tos/data/constants.js new file mode 100644 index 000000000..8291615cd --- /dev/null +++ b/src/components/modal-tos/data/constants.js @@ -0,0 +1,13 @@ +const ACCEPTANCE_TOS = 'True'; + +const CAMEL_CASE_KEYS = { + date_iso_8601: 'dateIso8601', + data_authorization: 'dataAuthorization', + honor_code: 'honorCode', + terms_of_service: 'termsOfService', +}; + +export { + ACCEPTANCE_TOS, + CAMEL_CASE_KEYS, +}; diff --git a/src/components/modal-tos/index.jsx b/src/components/modal-tos/index.jsx new file mode 100644 index 000000000..bd52b89d8 --- /dev/null +++ b/src/components/modal-tos/index.jsx @@ -0,0 +1,3 @@ +import ModalToS from './ModalToS'; + +export default ModalToS; diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index be3f12816..e3efa774b 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -1,5 +1,9 @@ { "footer.languageForm.select.label": "Escolha a língua", "footer.languageForm.submit.label": "Aplicar", - "footer.copyright.message": "Todos os direitos reservados." -} \ No newline at end of file + "footer.copyright.message": "Todos os direitos reservados.", + "modalToS.dataAuthorization.checkbox.label": "Li e compreendi a Política de Privacidade", + "modalToS.termsOfService.checkbox.label": "Li e compreendi o {platformName} Termos e Condições", + "modalToS.honorCode.checkbox.label": "Li e compreendi o {platformName} Honor Code", + "modalToS.acceptance.button": "Aceito os novos termos de serviço" +}