Merged in feature/IO-2477 (pull request #1207)

Feature/IO-2477 - EULA

Approved-by: Patrick Fic
This commit is contained in:
Dave Richer
2024-01-23 23:23:56 +00:00
25 changed files with 1580 additions and 41 deletions

1133
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@
"react-icons": "^5.0.1",
"react-image-lightbox": "^5.1.4",
"react-intersection-observer": "^9.5.3",
"react-markdown": "^9.0.1",
"react-number-format": "^5.1.4",
"react-redux": "^9.1.0",
"react-resizable": "^3.0.5",
@@ -92,6 +93,7 @@
"buildcra": "REACT_APP_GIT_SHA=`git rev-parse --short HEAD` craco build",
"test": "cypress open",
"eject": "react-scripts eject",
"eulaize": "node src/utils/eulaize.js",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular ."
},
"eslintConfig": {

View File

@@ -8,6 +8,7 @@ import {Route, Routes} from "react-router-dom";
import {createStructuredSelector} from "reselect";
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
import ErrorBoundary from "../components/error-boundary/error-boundary.component";
//Component Imports
import LoadingSpinner from "../components/loading-spinner/loading-spinner.component";
import DisclaimerPage from "../pages/disclaimer/disclaimer.page";
@@ -20,6 +21,7 @@ import {selectBodyshop, selectCurrentUser,} from "../redux/user/user.selectors";
import PrivateRoute from "../components/PrivateRoute";
import "./App.styles.scss";
import handleBeta from "../utils/betaHandler";
import Eula from "../components/eula/eula.component";
const ResetPassword = lazy(() =>
import("../pages/reset-password/reset-password.component")
@@ -42,7 +44,6 @@ const mapDispatchToProps = (dispatch) => ({
});
export function App({bodyshop, checkUserSession, currentUser, online, setOnline}) {
const client = useSplitClient().client;
const [listenersAdded, setListenersAdded] = useState(false)
const {t} = useTranslation();
@@ -121,6 +122,10 @@ export function App({bodyshop, checkUserSession, currentUser, online, setOnline}
/>
);
if (!currentUser.eulaIsAccepted) {
return <Eula/>
}
// Any route that is not assigned and matched will default to the Landing Page component
return (
<Suspense fallback={<LoadingSpinner message="ImEX Online"/>}>
@@ -131,10 +136,12 @@ export function App({bodyshop, checkUserSession, currentUser, online, setOnline}
<Route path="/csi/:surveyId" element={<ErrorBoundary><CsiPage/></ErrorBoundary>}/>
<Route path="/disclaimer" element={<ErrorBoundary><DisclaimerPage/></ErrorBoundary>}/>
<Route path="/mp/:paymentIs" element={<ErrorBoundary><MobilePaymentContainer/></ErrorBoundary>}/>
<Route path="/manage/*" element={<ErrorBoundary><PrivateRoute isAuthorized={currentUser.authorized}/></ErrorBoundary>}>
<Route path="/manage/*"
element={<ErrorBoundary><PrivateRoute isAuthorized={currentUser.authorized}/></ErrorBoundary>}>
<Route path="*" element={<ManagePage/>}/>
</Route>
<Route path="/tech/*" element={<ErrorBoundary><PrivateRoute isAuthorized={currentUser.authorized}/></ErrorBoundary>}>
<Route path="/tech/*"
element={<ErrorBoundary><PrivateRoute isAuthorized={currentUser.authorized}/></ErrorBoundary>}>
<Route path="*" element={<TechPageContainer/>}/>
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized}/>}>

View File

@@ -0,0 +1,226 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {Button, Card, Checkbox, Col, Form, Input, Modal, notification, Row, Space} from "antd";
import Markdown from "react-markdown";
import { createStructuredSelector } from "reselect";
import { selectCurrentEula, selectCurrentUser } from "../../redux/user/user.selectors";
import { connect } from "react-redux";
import { FormDatePicker } from "../form-date-picker/form-date-picker.component";
import { INSERT_EULA_ACCEPTANCE } from "../../graphql/user.queries";
import { useMutation } from "@apollo/client";
import { acceptEula } from "../../redux/user/user.actions";
import { useTranslation } from "react-i18next";
import day from '../../utils/day';
import './eula.styles.scss';
const Eula = ({ currentEula, currentUser, acceptEula }) => {
const [formReady, setFormReady] = useState(false);
const [hasEverScrolledToBottom, setHasEverScrolledToBottom] = useState(false);
const [insertEulaAcceptance] = useMutation(INSERT_EULA_ACCEPTANCE);
const [form] = Form.useForm();
const markdownCardRef = useRef(null);
const { t } = useTranslation();
const [api, contextHolder] = notification.useNotification();
const handleScroll = (e) => {
const bottom = e.target.scrollHeight - 100 <= e.target.scrollTop + e.target.clientHeight;
if (bottom && !hasEverScrolledToBottom) {
setHasEverScrolledToBottom(true);
}
};
const handleChange = useCallback(() => {
form.validateFields({ validateOnly: true })
.then(() => setFormReady(hasEverScrolledToBottom))
.catch(() => setFormReady(false));
}, [form, hasEverScrolledToBottom]);
useEffect(() => {
handleChange();
}, [handleChange, hasEverScrolledToBottom, form]);
const onFinish = async ({ acceptTerms, ...formValues }) => {
const eulaId = currentEula.id;
const useremail = currentUser.email;
try {
const { accepted_terms, ...otherFormValues } = formValues;
await insertEulaAcceptance({
variables: {
eulaAcceptance: {
eulaid: eulaId,
useremail,
...otherFormValues,
date_accepted: new Date(),
}
}
});
acceptEula();
} catch (err) {
api.error({
message: t('eula.errors.acceptance.message'),
description: t('eula.errors.acceptance.description'),
placement: 'bottomRight',
duration: 5000,
});
console.log(`${t('eula.errors.acceptance.message')}`);
console.dir({
message: err.message,
stack: err.stack,
});
}
};
return (
<>
{contextHolder}
<Modal
title={t('eula.titles.modal')}
className='eula-modal'
width={'100vh'}
open={currentEula}
footer={() => (
<Button
className='eula-accept-button'
form='tosForm'
type="primary"
size='large'
htmlType="submit"
disabled={!formReady}
children={t('eula.buttons.accept')}
/>
)}
closable={false}
>
<Space direction='vertical'>
<Card type='inner' className='eula-markdown-card' onScroll={handleScroll} ref={markdownCardRef}>
<div id='markdowndiv' className='eula-markdown-div'>
<Markdown children={currentEula?.content?.replace(/\\n/g, '\n')} />
</div>
</Card>
<EulaFormComponent form={form} handleChange={handleChange} onFinish={onFinish} t={t} />
{!hasEverScrolledToBottom && (
<Card className='eula-never-scrolled' type='inner'>
<h3>{t('eula.content.never_scrolled')}</h3>
</Card>
)}
</Space>
</Modal>
</>
)
}
const EulaFormComponent = ({ form, handleChange, onFinish, t }) => (
<Card type='inner' title={t('eula.titles.upper_card')}>
<Form id='tosForm' onChange={handleChange} onFinish={onFinish} form={form}>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('eula.labels.first_name')}
name="first_name"
rules={[{ required: true, message: t('eula.messages.first_name') }]}
>
<Input placeholder={t('eula.labels.first_name')}
aria-label={t('eula.labels.first_name')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('eula.labels.last_name')}
name="last_name"
rules={[{ required: true, message: t('eula.messages.last_name') }]}
>
<Input placeholder={t('eula.labels.last_name')}
aria-label={t('eula.labels.last_name')} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('eula.labels.business_name')}
name="business_name"
rules={[{ required: true, message: t('eula.messages.business_name') }]}
>
<Input placeholder={t('eula.labels.business_name')}
aria-label={t('eula.labels.business_name')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('eula.labels.phone_number')}
name="phone_number"
rules={[
{
pattern: /^(\+\d{1,2}\s?)?1?-?\.?\s?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/,
message: t('eula.messages.phone_number'),
}
]}
>
<Input placeholder={t('eula.labels.phone_number')}
aria-label={t('eula.labels.phone_number')} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label={t('eula.labels.address')}
name="address"
>
<Input placeholder={t('eula.labels.address')} aria-label={t('eula.labels.address')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label={t('eula.labels.date_accepted')}
name="date_accepted"
rules={[
{ required: true },
{
validator: (_, value) => {
if (day(value).isSame(day(), 'day')) {
return Promise.resolve();
}
return Promise.reject(new Error(t('eula.messages.date_accepted')));
}
},
]}
>
<FormDatePicker onChange={handleChange} onlyToday
aria-label={t('eula.labels.date_accepted')} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={24}>
<Form.Item
name="accepted_terms"
valuePropName="checked"
rules={[
{
validator: (_, value) =>
value ? Promise.resolve() : Promise.reject(new Error(t('eula.messages.accepted_terms'))),
},
]}
>
<Checkbox
aria-label={t('eula.labels.accepted_terms')}>{t('eula.labels.accepted_terms')}</Checkbox>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
);
const mapStateToProps = createStructuredSelector({
currentEula: selectCurrentEula,
currentUser: selectCurrentUser,
});
const mapDispatchToProps = (dispatch) => ({
acceptEula: () => dispatch(acceptEula()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Eula);

View File

@@ -0,0 +1,21 @@
.eula-modal {
top: 20px;
}
.eula-markdown-card {
max-height: 50vh;
overflow-y: auto;
background-color: lightgray;
}
.eula-markdown-div {
padding: 0 10px 0 10px;
}
.eula-never-scrolled {
text-align: center;
}
.eula-accept-button {
width: 100%;
}

View File

@@ -16,7 +16,16 @@ export default connect(mapStateToProps, mapDispatchToProps)(FormDatePicker);
const dateFormat = "MM/DD/YYYY";
export function FormDatePicker({bodyshop, value, onChange, onBlur, onlyFuture, isDateOnly = true, ...restProps }) {
export function FormDatePicker({
bodyshop,
value,
onChange,
onBlur,
onlyFuture,
onlyToday,
isDateOnly = true,
...restProps
}) {
const ref = useRef();
const handleChange = (newDate) => {
@@ -87,9 +96,13 @@ export function FormDatePicker({bodyshop, value, onChange, onBlur, onlyFuture, i
onBlur={onBlur || handleBlur}
showToday={false}
disabledTime
{...(onlyFuture && {
disabledDate: (d) => dayjs().subtract(1, "day").isAfter(d),
})}
disabledDate={(d) => {
if (onlyToday) {
return !dayjs().isSame(d, 'day');
} else if (onlyFuture) {
return dayjs().subtract(1, "day").isAfter(d);
}
}}
{...restProps}
/>
</div>

View File

@@ -8,6 +8,21 @@ export const INTROSPECTION = gql`
}
}
`;
export const QUERY_EULA = gql`
query QUERY_EULA($now: timestamptz!) {
eulas(where: {effective_date: {_lte: $now}, _or: [{end_date: {_is_null: true}}, {end_date: {_gt: $now}}]}) {
id
content
eula_acceptances {
id
date_accepted
}
}
}
`;
export const QUERY_BODYSHOP = gql`
query QUERY_BODYSHOP {
bodyshops(where: { associations: { active: { _eq: true } } }) {

View File

@@ -31,6 +31,14 @@ export const UPDATE_ASSOCIATION = gql`
}
`;
export const INSERT_EULA_ACCEPTANCE = gql`
mutation INSERT_EULA_ACCEPTANCE($eulaAcceptance:eula_acceptances_insert_input!) {
insert_eula_acceptances_one(object: $eulaAcceptance){
id
}
}
`;
export const UPSERT_USER = gql`
mutation UPSERT_USER($authEmail: String!, $authToken: String!) {
insert_users(

View File

@@ -109,3 +109,13 @@ export const setAuthlevel = (authlevel) => ({
type: UserActionTypes.SET_AUTH_LEVEL,
payload: authlevel,
});
export const setCurrentEula = (eula) => ({
type: UserActionTypes.SET_CURRENT_EULA,
payload: eula,
});
export const acceptEula = () => ({
type: UserActionTypes.EULA_ACCEPTED,
});

View File

@@ -3,6 +3,7 @@ import UserActionTypes from "./user.types";
const INITIAL_STATE = {
currentUser: {
authorized: null,
eulaIsAccepted: false,
//language: "en-US"
},
bodyshop: null,
@@ -17,6 +18,7 @@ const INITIAL_STATE = {
loading: false,
},
authLevel: 0,
currentEula: null,
};
const userReducer = (state = INITIAL_STATE, action) => {
@@ -63,11 +65,19 @@ const userReducer = (state = INITIAL_STATE, action) => {
loading: false,
},
};
case UserActionTypes.EULA_ACCEPTED:
return {
...state,
currentUser:{...state.currentUser, eulaIsAccepted: true},
currentEula: null,
};
case UserActionTypes.SIGN_IN_SUCCESS:
const{ currentEula,...currentUser} = action.payload
return {
...state,
loginLoading: false,
currentUser: action.payload,
currentUser: currentUser,
currentEula,
error: null,
};
case UserActionTypes.SIGN_OUT_SUCCESS:

View File

@@ -43,6 +43,9 @@ import {
validatePasswordResetSuccess,
} from "./user.actions";
import UserActionTypes from "./user.types";
import client from "../../utils/GraphQLClient";
import {QUERY_EULA} from "../../graphql/bodyshop.queries";
import day from "../../utils/day";
const fpPromise = FingerprintJS.load();
@@ -73,6 +76,8 @@ export function* signInWithEmail({ payload: { email, password } }) {
export function* onCheckUserSession() {
yield takeLatest(UserActionTypes.CHECK_USER_SESSION, isUserAuthenticated);
}
export function* isUserAuthenticated() {
try {
logImEXEvent("redux_auth_check");
@@ -85,6 +90,15 @@ export function* isUserAuthenticated() {
LogRocket.identify(user.email);
const eulaQuery = yield client.query({
query: QUERY_EULA,
variables: {
now: day()
},
});
const eulaIsAccepted = eulaQuery.data.eulas.length > 0 && eulaQuery.data.eulas[0].eula_acceptances.length > 0;
yield put(
signInSuccess({
uid: user.uid,
@@ -92,6 +106,8 @@ export function* isUserAuthenticated() {
displayName: user.displayName,
photoURL: user.photoURL,
authorized: true,
eulaIsAccepted,
currentEula: eulaIsAccepted ? null : eulaQuery.data.eulas[0],
})
);
} catch (error) {

View File

@@ -36,3 +36,8 @@ export const selectLoginLoading = createSelector(
[selectUser],
(user) => user.loginLoading
);
export const selectCurrentEula = createSelector(
[selectUser],
(user) => user.currentEula
);

View File

@@ -32,5 +32,7 @@ const UserActionTypes = {
CHECK_ACTION_CODE_START: "CHECK_ACTION_CODE_START",
CHECK_ACTION_CODE_SUCCESS: "CHECK_ACTION_CODE_SUCCESS",
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
SET_CURRENT_EULA: "SET_CURRENT_EULA",
EULA_ACCEPTED : "EULA_ACCEPTED",
};
export default UserActionTypes;

View File

@@ -933,6 +933,41 @@
"updated": "Document updated successfully. "
}
},
"eula": {
"titles": {
"modal": "Terms and Conditions",
"upper_card": "Acknowledgement"
},
"messages": {
"first_name": "Please enter your first name.",
"last_name": "Please enter your last name.",
"business_name": "Please enter your legal business name.",
"phone_number": "Please enter your phone number.",
"date_accepted": "Please enter Today's Date.",
"accepted_terms": "Please accept the terms and conditions of this agreement."
},
"buttons": {
"accept": "Accept EULA"
},
"labels": {
"first_name": "First Name",
"last_name": "Last Name",
"business_name": "Legal Business Name",
"phone_number": "Phone Number",
"address": "Address",
"date_accepted": "Date Accepted",
"accepted_terms": "I accept the terms and conditions of this agreement."
},
"content": {
"never_scrolled": "You must scroll to the bottom of the Terms and Conditions before accepting."
},
"errors": {
"acceptance": {
"message": "Eula Acceptance Error",
"description": "Something went wrong while accepting the EULA. Please try again."
}
}
},
"emails": {
"errors": {
"notsent": "Email not sent. Error encountered while sending {{message}}"

View File

@@ -933,6 +933,41 @@
"updated": ""
}
},
"eula": {
"titles": {
"modal": "Terms and Conditions",
"upper_card": "Acknowledgement"
},
"messages": {
"first_name": "Please enter your first name.",
"last_name": "Please enter your last name.",
"business_name": "Please enter your legal business name.",
"phone_number": "Please enter your phone number.",
"date_accepted": "Please enter Today's Date.",
"accepted_terms": "Please accept the terms and conditions of this agreement."
},
"buttons": {
"accept": "Accept EULA"
},
"labels": {
"first_name": "First Name",
"last_name": "Last Name",
"business_name": "Legal Business Name",
"phone_number": "Phone Number",
"address": "Address",
"date_accepted": "Date Accepted",
"accepted_terms": "I accept the terms and conditions of this agreement."
},
"content": {
"never_scrolled": "You must scroll to the bottom of the Terms and Conditions before accepting."
},
"errors": {
"acceptance": {
"message": "Eula Acceptance Error",
"description": "Something went wrong while accepting the EULA. Please try again."
}
}
},
"emails": {
"errors": {
"notsent": "Correo electrónico no enviado Se encontró un error al enviar {{message}}"

View File

@@ -933,6 +933,41 @@
"updated": ""
}
},
"eula": {
"titles": {
"modal": "Terms and Conditions",
"upper_card": "Acknowledgement"
},
"messages": {
"first_name": "Please enter your first name.",
"last_name": "Please enter your last name.",
"business_name": "Please enter your legal business name.",
"phone_number": "Please enter your phone number.",
"date_accepted": "Please enter Today's Date.",
"accepted_terms": "Please accept the terms and conditions of this agreement."
},
"buttons": {
"accept": "Accept EULA"
},
"labels": {
"first_name": "First Name",
"last_name": "Last Name",
"business_name": "Legal Business Name",
"phone_number": "Phone Number",
"address": "Address",
"date_accepted": "Date Accepted",
"accepted_terms": "I accept the terms and conditions of this agreement."
},
"content": {
"never_scrolled": "You must scroll to the bottom of the Terms and Conditions before accepting."
},
"errors": {
"acceptance": {
"message": "Eula Acceptance Error",
"description": "Something went wrong while accepting the EULA. Please try again."
}
}
},
"emails": {
"errors": {
"notsent": "Courriel non envoyé. Erreur rencontrée lors de l'envoi de {{message}}"

View File

@@ -32,11 +32,6 @@ import objectSupport from 'dayjs/plugin/objectSupport';
import toArray from 'dayjs/plugin/toArray';
import toObject from 'dayjs/plugin/toObject';
// import badMutable from 'dayjs/plugin/badMutable';
// import preParsePostFormat from 'dayjs/plugin/preParsePostFormat';
// dayjs.extend(badMutable); // TODO: Client Update - This is not advised, scoreboard page
dayjs.extend(toObject);
dayjs.extend(toArray);
dayjs.extend(objectSupport);
@@ -46,7 +41,6 @@ dayjs.extend(isToday);
dayjs.extend(localeData);
dayjs.extend(quarterOfYear);
dayjs.extend(localizedFormat);
// dayjs.extend(preParsePostFormat); // TODO: This should not be needed
dayjs.extend(isLeapYear);
dayjs.extend(isoWeeksInYear);
dayjs.extend(isoWeek);

View File

@@ -0,0 +1,16 @@
const fs = require('fs');
const filename = process.argv[2];
fs.readFile(filename, 'utf8', (err, data) => {
if (err) {
console.error(`Error reading file ${filename}:`, err);
return;
}
const filteredData = JSON.stringify(data);
console.log('Select the content between the quotes below and paste it into the EULA Content field in the EULA Content table in the database.')
console.log('--------------------------------------------------')
console.log(filteredData);
console.log('--------------------------------------------------')
});

View File

@@ -2442,7 +2442,7 @@
_eq: X-Hasura-User-Id
columns:
- address
- buisness_name
- business_name
- date_accepted
- eulaid
- first_name
@@ -2454,7 +2454,7 @@
permission:
columns:
- address
- buisness_name
- business_name
- first_name
- last_name
- phone_number

View File

@@ -0,0 +1 @@
alter table "public"."eula_acceptances" rename column "business_name" to "buisness_name";

View File

@@ -0,0 +1 @@
alter table "public"."eula_acceptances" rename column "buisness_name" to "business_name";

View File

@@ -0,0 +1 @@
alter table "public"."eula_acceptances" alter column "phone_number" set not null;

View File

@@ -0,0 +1 @@
alter table "public"."eula_acceptances" alter column "phone_number" drop not null;

View File

@@ -0,0 +1 @@
alter table "public"."eula_acceptances" alter column "address" set not null;

View File

@@ -0,0 +1 @@
alter table "public"."eula_acceptances" alter column "address" drop not null;