Add notifications to home page

This commit is contained in:
pikiou 2022-01-07 14:23:33 +01:00
parent adde4f366e
commit 5cbb5811ec
37 changed files with 661 additions and 215 deletions

View File

@ -74,6 +74,8 @@
"@loadable/server": "^5.15.0",
"@reduxjs/toolkit": "^1.6.0",
"@sendgrid/mail": "^7.6.0",
"@types/cookie-parser": "^1.4.2",
"@types/js-cookie": "^3.0.1",
"@types/lodash": "^4.14.177",
"autoprefixer": "^10.2.6",
"axios": "^0.21.1",
@ -81,6 +83,7 @@
"chalk": "^4.1.1",
"compression": "^1.7.4",
"connected-react-router": "^6.9.1",
"cookie-parser": "^1.4.6",
"core-js": "^3.15.2",
"cross-env": "^7.0.3",
"express": "^4.17.1",
@ -93,6 +96,7 @@
"hpp": "^0.2.3",
"html-minifier": "^4.0.0",
"https": "^1.0.0",
"js-cookie": "^3.0.1",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"morgan": "^1.10.0",

View File

@ -3,13 +3,19 @@ import { Provider } from "react-redux"
import { ConnectedRouter } from "connected-react-router"
import { RouteConfig, renderRoutes } from "react-router-config"
import { loadableReady } from "@loadable/component"
import Cookies from "js-cookie"
import createStore from "../store"
import routes from "../routes"
const storage: any = localStorage
// Get the initial state from server-side rendering
const initialState = window.__INITIAL_STATE__
const { store, history } = createStore({ initialState })
const id = +(Cookies.get("id") || storage?.getItem("id"))
const jwt = Cookies.get("jwt") || storage?.getItem("jwt")
const { store, history } = createStore({ initialState, id, jwt })
const render = (Routes: RouteConfig[]) =>
ReactDOM.hydrate(

View File

@ -28,7 +28,7 @@ const LoginForm = ({ dispatch, error }: Props): JSX.Element => {
return (
<form onSubmit={onSubmit}>
<div className={styles.loginIntro} key="login-intro">
Connectez-vous pour accéder à votre espace.
Si vous êtes bénévole, connectez-vous pour accéder à votre espace.
</div>
<div className={styles.formLine} key="line-email">
<label htmlFor="email">Email</label>

View File

@ -0,0 +1,162 @@
import _ from "lodash"
import React, { memo, useCallback, useState } from "react"
import { AppDispatch } from "../../store"
import { fetchVolunteerNotifsSet } from "../../store/volunteerNotifsSet"
import { VolunteerNotifs } from "../../services/volunteers"
import styles from "./styles.module.scss"
import { logoutUser } from "../../store/auth"
import { unsetJWT } from "../../services/auth"
interface Props {
dispatch: AppDispatch
jwt: string
// eslint-disable-next-line react/require-default-props
volunteerNotifs?: VolunteerNotifs
}
const Notifications = ({ dispatch, jwt, volunteerNotifs }: Props): JSX.Element => {
const hidden = volunteerNotifs?.hiddenNotifs || []
const notifs: JSX.Element[] = []
const onSubmit1 = useCallback(
(event: React.SyntheticEvent): void => {
event.preventDefault()
dispatch(
fetchVolunteerNotifsSet(jwt, 0, {
hiddenNotifs: [...(volunteerNotifs?.hiddenNotifs || []), 1],
})
)
},
[dispatch, jwt, volunteerNotifs]
)
if (!_.includes(hidden, 1)) {
notifs.push(
<div key="1">
<div className={styles.notificationsPage}>
<div className={styles.notificationsContent}>
<form onSubmit={onSubmit1}>
Salut {volunteerNotifs?.firstname} !
<div className={styles.notifIntro} key="login-intro">
Ici tu seras notifié(e) des nouvelles importantes et des questions
pour lesquelles il nous faudrait absolument ta réponse.
<div className={styles.formButtons}>
<button type="submit">Ok, continuer</button>
</div>
</div>
</form>
</div>
</div>
</div>
)
}
const [participation, setParticipation] = useState(volunteerNotifs?.active || "inconnu")
const onChangeValue2 = (e: React.ChangeEvent<HTMLInputElement>) =>
setParticipation(e.target.value)
const onSubmit2 = useCallback(
(event: React.SyntheticEvent): void => {
event.preventDefault()
dispatch(
fetchVolunteerNotifsSet(jwt, 0, {
hiddenNotifs: [...(volunteerNotifs?.hiddenNotifs || []), 2],
active: participation,
})
)
},
[dispatch, jwt, volunteerNotifs, participation]
)
if (!_.includes(hidden, 2)) {
notifs.push(
<div key="2">
<div className={styles.notificationsPage}>
<div className={styles.notificationsContent}>
<div className={styles.formLine} key="line-participation">
<form onSubmit={onSubmit2}>
Si les conditions sanitaires te le permettent, souhaites-tu être
bénévole à PeL 2022 ?<br />
<input
type="radio"
value="inconnu"
name="gender"
checked={participation === "inconnu"}
onChange={onChangeValue2}
/>{" "}
-<br />
<input
type="radio"
value="oui"
name="gender"
checked={participation === "oui"}
onChange={onChangeValue2}
/>{" "}
Oui
<br />
<input
type="radio"
value="non"
name="gender"
checked={participation === "non"}
onChange={onChangeValue2}
/>{" "}
Non
<br />
<input
type="radio"
value="peut-etre"
name="gender"
checked={participation === "peut-etre"}
onChange={onChangeValue2}
/>{" "}
Je ne sais pas encore
<br />
{participation === "peut-etre" ? (
<div>
On te redemandera dans quelques temps. Si tu as des
questions
</div>
) : null}
<div className={styles.formButtons}>
<button type="submit">Confirmer</button>
</div>
</form>
</div>
</div>
</div>
</div>
)
}
/* DISCORD
Discord nous donne à tous la parole via nos téléphone ou navigateurs, pour organiser le meilleur des festivals !
Il permet de discuter sujet par sujet entre tous les bénévoles, entre les membres d'une même équipe, ou avec ton référent.
Il permet de choisir les sujets spécifiques sur lesquels être notifié de nouveaux messages.
Rejoindre les 86 bénévoles déjà présents sur le serveur se fait en cliquant ici.
Tu n'y es absolument pas obligé(e) ! C'est juste plus pratique.
*/
const onClick = useCallback(
(event: React.SyntheticEvent): void => {
event.preventDefault()
unsetJWT()
dispatch(logoutUser())
},
[dispatch]
)
notifs.push(
<div key="logout" className={styles.formButtons}>
<button type="button" onClick={onClick}>
Se déconnecter
</button>
</div>
)
return <div>{notifs.map<React.ReactNode>((t) => t).reduce((prev, curr) => [prev, curr])}</div>
}
export default memo(Notifications)

View File

@ -0,0 +1,41 @@
@import "../../theme/variables";
@import "../../theme/mixins";
.notificationsPage {
@include page-wrapper-center;
}
.notificationsContent {
@include page-content-wrapper;
}
.notifIntro {
margin-bottom: 10px;
}
.formLine {
padding: 5px 0;
label {
display: block;
margin-left: 5px;
}
select,
input {
width: 10%;
border: 1px solid #333;
border-radius: 4px;
}
}
.formButtons {
margin-top: 10px;
padding: 5px 0;
text-align: center;
}
.error {
margin-top: 10px;
color: rgb(255, 0, 0);
text-align: center;
}

View File

@ -12,7 +12,7 @@ interface Props {
preVolunteerCount: number | undefined
}
const RegisterForm = ({ dispatch, preVolunteerCount }: Props): JSX.Element => {
const PreRegisterForm = ({ dispatch, preVolunteerCount }: Props): JSX.Element => {
const [firstname, setFirstname] = useState("")
const [lastname, setLastname] = useState("")
const [email, setEmail] = useState("")
@ -97,7 +97,7 @@ const RegisterForm = ({ dispatch, preVolunteerCount }: Props): JSX.Element => {
return (
<form onSubmit={onSubmit}>
<dl className={styles.registerIntro} key="register-intro">
<dl className={styles.preRegisterIntro} key="preRegister-intro">
<dt>Qu&apos;est-ce que Paris est Ludique ?</dt>
<dd>
<p>
@ -253,4 +253,4 @@ const RegisterForm = ({ dispatch, preVolunteerCount }: Props): JSX.Element => {
)
}
export default memo(RegisterForm)
export default memo(PreRegisterForm)

View File

@ -1,7 +1,7 @@
@import "../../theme/variables";
@import "../../theme/mixins";
.registerIntro {
.preRegisterIntro {
dt {
font-weight: bold;
margin-top: 10px;

View File

@ -3,6 +3,7 @@
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { volunteerExample } from "../../../services/volunteers"
import VolunteerInfo from "../index"
@ -10,23 +11,7 @@ describe("<VolunteerInfo />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
<VolunteerInfo
item={{
id: 1,
firstname: "Aupeix",
lastname: "Amélie",
email: "pakouille.lakouille@yahoo.fr",
mobile: "0675650392",
photo: "images/volunteers/$taille/amélie_aupeix.jpg",
food: "Végétarien",
adult: 1,
privileges: 0,
active: 0,
created: new Date(0),
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
}}
/>
<VolunteerInfo item={volunteerExample} />
</MemoryRouter>
).container.firstChild

View File

@ -4,33 +4,14 @@
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { volunteerExample } from "../../../services/volunteers"
import VolunteerList from "../index"
describe("<VolunteerList />", () => {
it("renders", () => {
const tree = render(
<MemoryRouter>
<VolunteerList
items={[
{
id: 1,
firstname: "Aupeix",
lastname: "Amélie",
email: "pakouille.lakouille@yahoo.fr",
mobile: "0675650392",
photo: "images/volunteers/$taille/amélie_aupeix.jpg",
food: "Végétarien",
adult: 1,
privileges: 0,
active: 0,
created: new Date(0),
password1:
"$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
password2:
"$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
},
]}
/>
<VolunteerList items={[volunteerExample]} />
</MemoryRouter>
).container.firstChild

View File

@ -3,6 +3,7 @@
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { volunteerExample } from "../../../services/volunteers"
import VolunteerSet from "../index"
@ -11,24 +12,7 @@ describe("<SetVolunteer />", () => {
const dispatch = jest.fn()
const tree = render(
<MemoryRouter>
<VolunteerSet
dispatch={dispatch}
volunteer={{
id: 1,
firstname: "Aupeix",
lastname: "Amélie",
email: "pakouille.lakouille@yahoo.fr",
mobile: "0675650392",
photo: "images/volunteers/$taille/amélie_aupeix.jpg",
food: "Végétarien",
adult: 1,
privileges: 0,
active: 0,
created: new Date(0),
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
}}
/>
<VolunteerSet dispatch={dispatch} volunteer={volunteerExample} />
</MemoryRouter>
).container.firstChild

View File

@ -1,3 +1,5 @@
import LoginForm from "./LoginForm"
import Notifications from "./Notifications"
import VolunteerList from "./VolunteerList"
import JavGameList from "./JavGameList"
import VolunteerInfo from "./VolunteerInfo"
@ -5,9 +7,11 @@ import VolunteerSet from "./VolunteerSet"
import ErrorBoundary from "./ErrorBoundary"
import Loading from "./Loading"
import WishAdd from "./WishAdd"
import RegisterForm from "./RegisterForm"
import PreRegisterForm from "./PreRegisterForm"
export {
LoginForm,
Notifications,
VolunteerList,
JavGameList,
VolunteerInfo,
@ -15,5 +19,5 @@ export {
ErrorBoundary,
Loading,
WishAdd,
RegisterForm,
PreRegisterForm,
}

50
src/pages/Home/Home.tsx Normal file
View File

@ -0,0 +1,50 @@
import { FC, memo } from "react"
import { RouteComponentProps, Link } from "react-router-dom"
import { useDispatch, useSelector, shallowEqual } from "react-redux"
import { Helmet } from "react-helmet"
import { AppState, AppThunk } from "../../store"
import { LoginForm, Notifications } from "../../components"
import styles from "./styles.module.scss"
import { fetchVolunteerNotifsSetIfNeed } from "../../store/volunteerNotifsSet"
export type Props = RouteComponentProps
const HomePage: FC<Props> = (): JSX.Element => {
const dispatch = useDispatch()
const readyStatus = useSelector((state: AppState) => state.volunteerNotifsSet.readyStatus)
const volunteerNotifs = useSelector(
(state: AppState) => state.volunteerNotifsSet.entity,
shallowEqual
)
const loginError = useSelector((state: AppState) => state.volunteerLogin.error, shallowEqual)
const jwt = useSelector((state: AppState) => state.auth.jwt, shallowEqual)
if (!readyStatus || readyStatus === "idle" || readyStatus === "request")
return <p>Loading...</p>
if (jwt) {
return <Notifications dispatch={dispatch} jwt={jwt} volunteerNotifs={volunteerNotifs} />
}
return (
<div>
<div className={styles.homePage}>
<div className={styles.loginContent}>
<Helmet title="LoginPage" />
<LoginForm dispatch={dispatch} error={loginError || ""} />
</div>
</div>
<div className={styles.homePage}>
<div className={styles.preRegisterContent}>
<Link to="/preRegister"> S&apos;informer sur le bénévolat </Link>
</div>
</div>
</div>
)
}
// Fetch server-side data here
export const loadData = (): AppThunk[] => [fetchVolunteerNotifsSetIfNeed()]
export default memo(HomePage)

View File

@ -1,27 +0,0 @@
import { FC, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { Helmet } from "react-helmet"
import { AppThunk } from "../../store"
import styles from "./styles.module.scss"
export type Props = RouteComponentProps
const fetchUserData = () => () => Promise.resolve()
const HomePage: FC<Props> = (): JSX.Element => (
<div className={styles.homePage}>
<div className={styles.homeContent}>
<Helmet title="Home" />
<div>Tableau de bord</div>
</div>
</div>
)
// Fetch server-side data here
export const loadData = (): AppThunk[] => [
fetchUserData(),
// More pre-fetched actions...
]
export default memo(HomePage)

View File

@ -1,15 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../components"
import { Props, loadData } from "./HomePage"
import { Props, loadData } from "./Home"
const Home = loadable(() => import("./HomePage"), {
const HomePage = loadable(() => import("./Home"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<Home {...props} />
<HomePage {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -4,6 +4,14 @@
@include page-wrapper-center;
}
.homeContent {
@include page-content-wrapper(600px);
.notificationsContent {
@include page-content-wrapper;
}
.loginContent {
@include page-content-wrapper;
}
.preRegisterContent {
@include page-content-wrapper;
}

View File

@ -4,7 +4,7 @@ import React, { memo } from "react"
import { Helmet } from "react-helmet"
import { AppState } from "../../store"
import LoginForm from "../../components/LoginForm/LoginForm"
import { LoginForm } from "../../components"
import styles from "./styles.module.scss"
export type Props = RouteComponentProps

View File

@ -5,7 +5,7 @@ import { Helmet } from "react-helmet"
import { AppState, AppThunk, ValueRequest } from "../../store"
import { fetchPreVolunteerCountIfNeed } from "../../store/preVolunteerCount"
import { RegisterForm } from "../../components"
import { PreRegisterForm } from "../../components"
import styles from "./styles.module.scss"
export type Props = RouteComponentProps
@ -29,14 +29,14 @@ function useList(
if (readyStatus === "failure") return <p>Oops, Failed to load!</p>
return <RegisterForm dispatch={dispatch} preVolunteerCount={value} />
return <PreRegisterForm dispatch={dispatch} preVolunteerCount={value} />
}
}
const RegisterPage: FC<Props> = (): JSX.Element => (
<div className={styles.registerPage}>
<div className={styles.registerContent}>
<Helmet title="RegisterPage" />
const PreRegisterPage: FC<Props> = (): JSX.Element => (
<div className={styles.preRegisterPage}>
<div className={styles.preRegisterContent}>
<Helmet title="PreRegisterPage" />
{useList((state: AppState) => state.preVolunteerCount, fetchPreVolunteerCountIfNeed)()}
</div>
</div>
@ -45,4 +45,4 @@ const RegisterPage: FC<Props> = (): JSX.Element => (
// Fetch server-side data here
export const loadData = (): AppThunk[] => [fetchPreVolunteerCountIfNeed()]
export default memo(RegisterPage)
export default memo(PreRegisterPage)

View File

@ -1,14 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../components"
import { Props } from "./RegisterPage"
import { Props, loadData } from "./PreRegister"
const RegisterPage = loadable(() => import("./RegisterPage"), {
const PreRegister = loadable(() => import("./PreRegister"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<RegisterPage {...props} />
<PreRegister {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -1,9 +1,9 @@
@import "../../theme/mixins";
.registerPage {
.preRegisterPage {
@include page-wrapper-center;
}
.registerContent {
.preRegisterContent {
@include page-content-wrapper(600px);
}

View File

@ -2,11 +2,11 @@ import { RouteConfig } from "react-router-config"
import App from "../app"
import AsyncHome, { loadData as loadHomeData } from "../pages/Home"
import AsyncPreRegisterPage, { loadData as loadPreRegisterPage } from "../pages/PreRegister"
import AsyncWish, { loadData as loadWishData } from "../pages/Wish"
import AsyncVolunteerPage, { loadData as loadVolunteerPageData } from "../pages/VolunteerPage"
import Login from "../pages/Login"
import Forgot from "../pages/Forgot"
import Register from "../pages/Register"
import NotFound from "../pages/NotFound"
export default [
@ -16,7 +16,13 @@ export default [
{
path: "/",
exact: true,
component: Register,
component: AsyncHome,
loadData: loadHomeData,
},
{
path: "/preRegister",
component: AsyncPreRegisterPage,
loadData: loadPreRegisterPage,
},
{
path: "/VolunteerPage/:id",
@ -31,11 +37,6 @@ export default [
path: "/forgot",
component: Forgot,
},
{
path: "/home",
component: AsyncHome,
loadData: loadHomeData,
},
{
path: "/wish",
component: AsyncWish,

View File

@ -37,7 +37,7 @@ export default class ExpressAccessors<
}
// custom can be async
get(custom?: (list: Element[], body: Request["body"]) => Promise<any> | any) {
get(custom?: (list: Element[], body: Request["body"], id: number) => Promise<any> | any) {
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
try {
const list = (await this.sheet.getList()) || []
@ -46,7 +46,12 @@ export default class ExpressAccessors<
const id = parseInt(request.query.id as string, 10) || -1
toCaller = list.find((e: Element) => e.id === id)
} else {
toCaller = await custom(list, request.body)
const memberId = response?.locals?.jwt?.id || -1
toCaller = await custom(list, request.body, memberId)
if (toCaller?.jwt && toCaller?.id) {
response.cookie("jwt", toCaller.jwt, { maxAge: 365 * 24 * 60 * 60 })
response.cookie("id", toCaller.id, { maxAge: 365 * 24 * 60 * 60 })
}
}
response.status(200).json(toCaller)
} catch (e: any) {
@ -72,7 +77,8 @@ export default class ExpressAccessors<
set(
custom?: (
list: Element[],
body: RequestBody
body: RequestBody,
id: number
) => Promise<CustomSetReturn<Element>> | CustomSetReturn<Element>
) {
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
@ -81,8 +87,9 @@ export default class ExpressAccessors<
await this.sheet.set(request.body)
response.status(200)
} else {
const memberId = response?.locals?.jwt?.id || -1
const list = (await this.sheet.getList()) || []
const { toDatabase, toCaller } = await custom(list, request.body)
const { toDatabase, toCaller } = await custom(list, request.body, memberId)
if (toDatabase !== undefined) {
await this.sheet.set(toDatabase)
}

View File

@ -3,7 +3,13 @@ import bcrypt from "bcrypt"
import sgMail from "@sendgrid/mail"
import ExpressAccessors, { RequestBody } from "./expressAccessors"
import { Volunteer, VolunteerWithoutId, translationVolunteer } from "../../services/volunteers"
import {
Volunteer,
VolunteerWithoutId,
VolunteerLogin,
VolunteerNotifs,
translationVolunteer,
} from "../../services/volunteers"
import { canonicalEmail } from "../../utils/standardization"
import { getJwt } from "../secure"
@ -14,38 +20,39 @@ const expressAccessor = new ExpressAccessors<VolunteerWithoutId, Volunteer>(
)
export const volunteerListGet = expressAccessor.listGet()
export const volunteerGet = expressAccessor.get()
export const volunteerAdd = expressAccessor.add()
export const volunteerSet = expressAccessor.set()
export const volunteerLogin = expressAccessor.get(async (list: Volunteer[], body: RequestBody) => {
const volunteer = getByEmail(list, body.email)
if (!volunteer) {
throw Error("Il n'y a aucun bénévole avec cet email")
}
export const volunteerLogin = expressAccessor.get(
async (list: Volunteer[], body: RequestBody): Promise<VolunteerLogin> => {
const volunteer = getByEmail(list, body.email)
if (!volunteer) {
throw Error("Il n'y a aucun bénévole avec cet email")
}
const password = body.password || ""
const password1Match = await bcrypt.compare(
password,
volunteer.password1.replace(/^\$2y/, "$2a")
)
if (!password1Match) {
const password2Match = await bcrypt.compare(
const password = body.password || ""
const password1Match = await bcrypt.compare(
password,
volunteer.password2.replace(/^\$2y/, "$2a")
volunteer.password1.replace(/^\$2y/, "$2a")
)
if (!password2Match) {
throw Error("Mauvais mot de passe pour cet email")
if (!password1Match) {
const password2Match = await bcrypt.compare(
password,
volunteer.password2.replace(/^\$2y/, "$2a")
)
if (!password2Match) {
throw Error("Mauvais mot de passe pour cet email")
}
}
const jwt = await getJwt(volunteer.id)
return {
id: volunteer.id,
jwt,
}
}
const jwt = await getJwt(volunteer.email)
return {
firstname: volunteer.firstname,
jwt,
}
})
)
const lastForgot: { [id: string]: number } = {}
export const volunteerForgot = expressAccessor.set(async (list: Volunteer[], body: RequestBody) => {
@ -78,6 +85,29 @@ export const volunteerForgot = expressAccessor.set(async (list: Volunteer[], bod
}
})
export const volunteerNotifsSet = expressAccessor.set(
async (list: Volunteer[], body: RequestBody, id: number) => {
const volunteer = list.find((v) => v.id === id)
if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${id}`)
}
const newVolunteer = _.cloneDeep(volunteer)
_.assign(newVolunteer, _.pick(body[1], _.keys(newVolunteer)))
return {
toDatabase: newVolunteer,
toCaller: {
id: newVolunteer.id,
firstname: newVolunteer.firstname,
adult: newVolunteer.adult,
active: newVolunteer.active,
hiddenNotifs: newVolunteer.hiddenNotifs,
} as VolunteerNotifs,
}
}
)
function getByEmail(list: Volunteer[], rawEmail: string): Volunteer | undefined {
const email = canonicalEmail(rawEmail || "")
const volunteer = list.find((v) => canonicalEmail(v.email) === email)

View File

@ -1,6 +1,7 @@
import path from "path"
import express, { RequestHandler } from "express"
import logger from "morgan"
import cookieParser from "cookie-parser"
import compression from "compression"
import helmet from "helmet"
import hpp from "hpp"
@ -19,7 +20,12 @@ import { secure } from "./secure"
import { javGameListGet } from "./gsheets/javGames"
import { wishListGet, wishAdd } from "./gsheets/wishes"
import { preVolunteerAdd, preVolunteerCountGet } from "./gsheets/preVolunteers"
import { volunteerGet, volunteerSet, volunteerLogin, volunteerForgot } from "./gsheets/volunteers"
import {
volunteerNotifsSet,
volunteerSet,
volunteerLogin,
volunteerForgot,
} from "./gsheets/volunteers"
import config from "../config"
const app = express()
@ -45,6 +51,7 @@ app.use(express.static(path.resolve(process.cwd(), "public")))
if (__DEV__) devServer(app)
app.use(express.json())
app.use(cookieParser())
/**
* APIs
@ -59,8 +66,9 @@ app.post("/VolunteerLogin", volunteerLogin)
app.post("/VolunteerForgot", volunteerForgot)
// Secured APIs
app.get("/VolunteerGet", secure as RequestHandler, volunteerGet)
app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
// UNSAFE app.post("/VolunteerGet", secure as RequestHandler, volunteerGet)
app.post("/VolunteerNotifsSet", secure as RequestHandler, volunteerNotifsSet)
// Use React server-side rendering middleware
app.get("*", ssr)

View File

@ -2,7 +2,6 @@ import { NextFunction, Request, Response } from "express"
import path from "path"
import { promises as fs } from "fs"
import { verify, sign } from "jsonwebtoken"
import { canonicalEmail } from "../utils/standardization"
import config from "../config"
@ -20,16 +19,17 @@ export function secure(request: AuthorizedRequest, response: Response, next: Nex
}
const rawToken = request.headers.authorization
const token = rawToken && rawToken.split(/\s/)[1]
const token1 = rawToken && rawToken.split(/\s/)[1]
const token2 = request.cookies?.jwt
const token = token1 || token2
verify(token, cachedSecret, (tokenError: any, decoded: any) => {
if (tokenError) {
response.status(403).json({
error: "Invalid token, please Log in first, criterion auth",
response.status(200).json({
error: "Accès interdit sans identification",
})
return
}
decoded.user.replace(/@gmailcom$/, "@gmail.com")
response.locals.jwt = decoded
next()
})
@ -50,9 +50,9 @@ async function getSecret() {
return cachedSecret
}
export async function getJwt(email: string): Promise<string> {
export async function getJwt(id: number): Promise<string> {
const jwt = sign(
{ user: canonicalEmail(email), permissions: [] },
{ id },
await getSecret()
// __TEST__
// ? undefined

View File

@ -12,9 +12,11 @@ import { Action } from "@reduxjs/toolkit"
import createStore from "../store"
import renderHtml from "./renderHtml"
import routes from "../routes"
import { getCookieJWT } from "../services/auth"
export default async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { store } = createStore({ url: req.url })
const { jwt, id } = getCookieJWT(req.headers.cookie)
const { store } = createStore({ url: req.url, jwt, id })
// The method for loading data from server-side
const loadBranchData = (): Promise<any> => {

View File

@ -1,4 +1,5 @@
import axios from "axios"
import _ from "lodash"
import config from "../config"
import { axiosConfig } from "./auth"
@ -123,7 +124,9 @@ export default class ServiceAccessors<
}
}
customPost(apiName: string): (params: any) => Promise<{
customPost<InputElements extends Array<any>>(
apiName: string
): (...params: InputElements) => Promise<{
data?: any
error?: Error
}> {
@ -131,7 +134,7 @@ export default class ServiceAccessors<
data?: any
error?: Error
}
return async (params: any): Promise<ElementGetResponse> => {
return async (...params: InputElements): Promise<ElementGetResponse> => {
try {
const { data } = await axios.post(
`${config.API_URL}/${this.elementName}${apiName}`,
@ -147,4 +150,36 @@ export default class ServiceAccessors<
}
}
}
securedCustomPost<InputElements extends Array<any>>(
apiName: string
): (
jwt: string,
...params: InputElements
) => Promise<{
data?: any
error?: Error
}> {
interface ElementGetResponse {
data?: any
error?: Error
}
return async (jwt: string, ...params: InputElements): Promise<ElementGetResponse> => {
try {
const auth = { headers: { Authorization: `Bearer ${jwt}` } }
const fullAxiosConfig = _.defaultsDeep(auth, axiosConfig)
const { data } = await axios.post(
`${config.API_URL}/${this.elementName}${apiName}`,
params,
fullAxiosConfig
)
if (data.error) {
throw Error(data.error)
}
return { data }
} catch (error) {
return { error: error as Error }
}
}
}
}

View File

@ -1,4 +1,7 @@
import { AxiosRequestConfig } from "axios"
import Cookies from "js-cookie"
import { VolunteerLogin } from "./volunteers"
const storage: any = localStorage
@ -6,17 +9,30 @@ export const axiosConfig: AxiosRequestConfig = {
headers: {},
}
const jwt: string | null = storage?.getItem("id_token")
if (jwt) {
setJWT(jwt)
}
export function setJWT(token: string): void {
export function setJWT(token: string, id: number): void {
axiosConfig.headers.Authorization = `Bearer ${token}`
storage?.setItem("id_token", token)
storage?.setItem("jwt", token)
storage?.setItem("id", id)
Cookies.set("jwt", token)
Cookies.set("id", `${id}`)
}
export function unsetJWT(): void {
delete axiosConfig.headers.Authorization
storage?.removeItem("id_token")
storage?.removeItem("jwt")
storage?.removeItem("id")
Cookies.remove("jwt")
Cookies.remove("id")
}
export function getCookieJWT(cookie = ""): VolunteerLogin {
const cookies = cookie
.split(";")
.reduce((res: { [cookieName: string]: string }, el: string) => {
const [k, v] = el.split("=")
res[k.trim()] = v
return res
}, {})
return { jwt: cookies.jwt, id: +cookies.id }
}

View File

@ -19,7 +19,9 @@ export class Volunteer {
privileges = 0
active = 0
active = ""
hiddenNotifs: number[] = []
created = new Date()
@ -39,6 +41,7 @@ export const translationVolunteer: { [k in keyof Volunteer]: string } = {
adult: "majeur",
privileges: "privilege",
active: "actif",
hiddenNotifs: "notifsCachees",
created: "creation",
password1: "passe1",
password2: "passe2",
@ -46,6 +49,23 @@ export const translationVolunteer: { [k in keyof Volunteer]: string } = {
const elementName = "Volunteer"
export const volunteerExample: Volunteer = {
id: 1,
firstname: "Aupeix",
lastname: "Amélie",
email: "pakouille.lakouille@yahoo.fr",
mobile: "0675650392",
photo: "images/volunteers/$taille/amélie_aupeix.jpg",
food: "Végétarien",
adult: 1,
privileges: 0,
active: "inconnu",
hiddenNotifs: [],
created: new Date(0),
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
}
export const emailRegexp =
/^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i
export const passwordMinLength = 4
@ -60,12 +80,23 @@ export const volunteerAdd = serviceAccessors.add()
export const volunteerSet = serviceAccessors.set()
export interface VolunteerLogin {
firstname: string
id: number
jwt: string
}
export const volunteerLogin = serviceAccessors.customPost("Login")
export const volunteerLogin =
serviceAccessors.customPost<[{ email: string; password: string }]>("Login")
export interface VolunteerForgot {
message: string
}
export const volunteerForgot = serviceAccessors.customPost("Forgot")
export const volunteerForgot = serviceAccessors.customPost<[{ email: string }]>("Forgot")
export interface VolunteerNotifs {
id: number
firstname: string
adult: number
active: string
hiddenNotifs: number[]
}
export const volunteerNotifsSet =
serviceAccessors.securedCustomPost<[number, Partial<VolunteerNotifs>]>("NotifsSet")

View File

@ -8,25 +8,11 @@ import volunteer, {
fetchVolunteer,
initialState,
} from "../volunteer"
import { Volunteer } from "../../services/volunteers"
import { Volunteer, volunteerExample } from "../../services/volunteers"
jest.mock("axios")
const mockData: Volunteer = {
id: 1,
lastname: "Aupeix",
firstname: "Amélie",
email: "pakouille.lakouille@yahoo.fr",
mobile: "0675650392",
photo: "images/volunteers/$taille/amélie_aupeix.jpg",
food: "Végétarien",
adult: 1,
privileges: 0,
active: 0,
created: new Date(0),
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
}
const mockData: Volunteer = volunteerExample
const { id } = mockData
const mockError = "Oops! Something went wrong."

View File

@ -9,27 +9,11 @@ import volunteerList, {
getFailure,
fetchVolunteerList,
} from "../volunteerList"
import { Volunteer } from "../../services/volunteers"
import { Volunteer, volunteerExample } from "../../services/volunteers"
jest.mock("axios")
const mockData: Volunteer[] = [
{
id: 1,
lastname: "Aupeix",
firstname: "Amélie",
email: "pakouille.lakouille@yahoo.fr",
mobile: "0675650392",
photo: "images/volunteers/$taille/amélie_aupeix.jpg",
food: "Végétarien",
adult: 1,
privileges: 0,
active: 0,
created: new Date(0),
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
},
]
const mockData: Volunteer[] = [volunteerExample]
const mockError = "Oops! Something went wrong."
describe("volunteerList reducer", () => {

35
src/store/auth.ts Normal file
View File

@ -0,0 +1,35 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import { AppState } from "."
// Define a type for the slice state
interface AuthState {
id: number
jwt: string
}
// Define the initial state using that type
const initialState: AuthState = {
id: 0,
jwt: "",
}
export const auth = createSlice({
name: "auth",
initialState,
reducers: {
setCurrentUser: (state, action: PayloadAction<AuthState>) => {
state.id = action.payload.id
state.jwt = action.payload.jwt
},
logoutUser: (state) => {
state.id = 0
state.jwt = ""
},
},
})
export const { setCurrentUser, logoutUser } = auth.actions
export const selectCount = (state: AppState): AuthState => state.auth
export default auth.reducer

View File

@ -2,18 +2,22 @@ import { createMemoryHistory, createBrowserHistory } from "history"
import { Action, configureStore, EntityState } from "@reduxjs/toolkit"
import { ThunkAction } from "redux-thunk"
import { routerMiddleware } from "connected-react-router"
import Cookies from "js-cookie"
import createRootReducer from "./rootReducer"
import { StateRequest } from "./utils"
import { setCurrentUser, logoutUser } from "./auth"
interface Arg {
initialState?: typeof window.__INITIAL_STATE__
url?: string
jwt?: string
id?: number
}
// Use inferred return type for making correctly Redux types
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
const createStore = ({ initialState, url }: Arg = {}) => {
const createStore = ({ initialState, url, jwt, id }: Arg = {}) => {
const history = __SERVER__
? createMemoryHistory({ initialEntries: [url || "/"] })
: createBrowserHistory()
@ -28,10 +32,20 @@ const createStore = ({ initialState, url }: Arg = {}) => {
devTools: __DEV__,
})
if (jwt && id) {
store.dispatch(setCurrentUser({ jwt, id }))
} else {
store.dispatch(logoutUser())
}
return { store, history }
}
const { store } = createStore()
const storage: any = localStorage
const id = +(Cookies.get("id") || storage?.getItem("id"))
const jwt = Cookies.get("jwt") || storage?.getItem("jwt")
const { store } = createStore({ id, jwt })
export type AppState = ReturnType<typeof store.getState>

View File

@ -1,6 +1,7 @@
import { History } from "history"
import { connectRouter } from "connected-react-router"
import auth from "./auth"
import wishAdd from "./wishAdd"
import wishList from "./wishList"
import javGameList from "./javGameList"
@ -10,12 +11,14 @@ import volunteerList from "./volunteerList"
import volunteerSet from "./volunteerSet"
import volunteerLogin from "./volunteerLogin"
import volunteerForgot from "./volunteerForgot"
import volunteerNotifsSet from "./volunteerNotifsSet"
import preVolunteerAdd from "./preVolunteerAdd"
import preVolunteerCount from "./preVolunteerCount"
// Use inferred return type for making correctly Redux types
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export default (history: History) => ({
auth,
wishAdd,
wishList,
javGameList,
@ -25,6 +28,7 @@ export default (history: History) => ({
volunteerSet,
volunteerLogin,
volunteerForgot,
volunteerNotifsSet,
preVolunteerAdd,
preVolunteerCount,
router: connectRouter(history) as any,

View File

@ -1,7 +1,7 @@
import { ActionCreatorWithoutPayload, ActionCreatorWithPayload } from "@reduxjs/toolkit"
import { toast } from "react-toastify"
import { AppThunk } from "."
import { AppThunk, AppDispatch } from "."
export interface StateRequest {
readyStatus: "idle" | "request" | "success" | "failure"
@ -32,8 +32,8 @@ export function toastSuccess(message: string): void {
})
}
export function elementFetch<Element>(
elementService: (...idArgs: any[]) => Promise<{
export function elementFetch<Element, ServiceInput extends Array<any>>(
elementService: (...idArgs: ServiceInput) => Promise<{
data?: Element | undefined
error?: Error | undefined
}>,
@ -41,9 +41,9 @@ export function elementFetch<Element>(
getSuccess: ActionCreatorWithPayload<Element, string>,
getFailure: ActionCreatorWithPayload<string, string>,
errorMessage?: (error: Error) => void,
successMessage?: (data: Element) => void
): (...idArgs: any[]) => AppThunk {
return (...idArgs: any[]): AppThunk =>
successMessage?: (data: Element, dispatch: AppDispatch) => void
): (...idArgs: ServiceInput) => AppThunk {
return (...idArgs: ServiceInput): AppThunk =>
async (dispatch) => {
dispatch(getRequesting())
@ -54,7 +54,7 @@ export function elementFetch<Element>(
errorMessage?.(error)
} else {
dispatch(getSuccess(data as Element))
successMessage?.(data as Element)
successMessage?.(data as Element, dispatch)
}
}
}

View File

@ -3,6 +3,9 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { StateRequest, elementFetch } from "./utils"
import { VolunteerLogin, volunteerLogin } from "../services/volunteers"
import { setJWT } from "../services/auth"
import { AppDispatch } from "."
import { setCurrentUser } from "./auth"
import { fetchVolunteerNotifsSet } from "./volunteerNotifsSet"
type StateVolunteer = { entity?: VolunteerLogin } & StateRequest
@ -31,13 +34,15 @@ const volunteerLoginSlice = createSlice({
export default volunteerLoginSlice.reducer
export const { getRequesting, getSuccess, getFailure } = volunteerLoginSlice.actions
export const fetchVolunteerLogin = elementFetch(
export const fetchVolunteerLogin = elementFetch<VolunteerLogin, Parameters<typeof volunteerLogin>>(
volunteerLogin,
getRequesting,
getSuccess,
getFailure,
undefined,
(login: VolunteerLogin) => {
setJWT(login.jwt)
(login: VolunteerLogin, dispatch: AppDispatch) => {
setJWT(login.jwt, login.id)
dispatch(setCurrentUser(login))
dispatch(fetchVolunteerNotifsSet(login.jwt, login.id, {}))
}
)

View File

@ -0,0 +1,57 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { StateRequest, toastError, elementFetch } from "./utils"
import { VolunteerNotifs, volunteerNotifsSet } from "../services/volunteers"
import { AppThunk, AppState } from "."
type StateVolunteerNotifsSet = { entity?: VolunteerNotifs } & StateRequest
export const initialState: StateVolunteerNotifsSet = {
readyStatus: "idle",
}
const volunteerNotifsSetSlice = createSlice({
name: "volunteerNotifsSet",
initialState,
reducers: {
getRequesting: (_) => ({
readyStatus: "request",
}),
getSuccess: (_, { payload }: PayloadAction<VolunteerNotifs>) => ({
readyStatus: "success",
entity: payload,
}),
getFailure: (_, { payload }: PayloadAction<string>) => ({
readyStatus: "failure",
error: payload,
}),
},
})
export default volunteerNotifsSetSlice.reducer
export const { getRequesting, getSuccess, getFailure } = volunteerNotifsSetSlice.actions
export const fetchVolunteerNotifsSet = elementFetch(
volunteerNotifsSet,
getRequesting,
getSuccess,
getFailure,
(error: Error) => toastError(`Erreur lors du chargement des notifications: ${error.message}`)
)
const shouldFetchVolunteerNotifsSet = (state: AppState, id: number) =>
state.volunteerNotifsSet.readyStatus !== "success" ||
(state.volunteerNotifsSet.entity && state.volunteerNotifsSet.entity.id !== id)
export const fetchVolunteerNotifsSetIfNeed =
(id = 0, notif: Partial<VolunteerNotifs> = {}): AppThunk =>
(dispatch, getState) => {
let jwt = ""
if (!id) {
;({ id, jwt } = getState().auth)
}
if (shouldFetchVolunteerNotifsSet(getState(), id))
return dispatch(fetchVolunteerNotifsSet(jwt, id, notif))
return null
}

View File

@ -1566,6 +1566,13 @@
dependencies:
"@types/node" "*"
"@types/cookie-parser@^1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5"
integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==
dependencies:
"@types/express" "*"
"@types/css-minimizer-webpack-plugin@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.0.2.tgz#ed58bbbde8a7b7591118aa93d8f8d0cdc0cc6173"
@ -1725,6 +1732,11 @@
jest-diff "^26.0.0"
pretty-format "^26.0.0"
"@types/js-cookie@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.1.tgz#04aa743e2e0a85a22ee9aa61f6591a8bc19b5d68"
integrity sha512-7wg/8gfHltklehP+oyJnZrz9XBuX5ZPP4zB6UsI84utdlkRYLnOm2HfpLXazTwZA+fpGn0ir8tGNgVnMEleBGQ==
"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
version "7.0.9"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
@ -3500,6 +3512,14 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
dependencies:
safe-buffer "~5.1.1"
cookie-parser@^1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.6.tgz#3ac3a7d35a7a03bbc7e365073a26074824214594"
integrity sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==
dependencies:
cookie "0.4.1"
cookie-signature "1.0.6"
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
@ -3510,6 +3530,11 @@ cookie@0.4.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
cookie@0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
core-js-compat@^3.18.0, core-js-compat@^3.19.1:
version "3.19.1"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.19.1.tgz#fe598f1a9bf37310d77c3813968e9f7c7bb99476"
@ -6790,6 +6815,11 @@ js-base64@^2.1.8:
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4"
integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==
js-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414"
integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"