diff --git a/package.json b/package.json index 1750ad5..89001b6 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "react-router-dom": "^5.3.0", "react-toastify": "^8.1.0", "readline": "^1.3.0", + "redux-devtools-extension": "^2.13.9", "redux-thunk": "^2.3.0", "serialize-javascript": "^6.0.0", "serve-favicon": "^2.5.0" diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index 1819808..46d6b0a 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -1,20 +1,28 @@ import React, { memo, useCallback } from "react" import styles from "./styles.module.scss" +import { AppDispatch } from "../../store" +import { fetchVolunteerLogin } from "../../store/volunteerLogin" -const LoginForm = (): JSX.Element => { - const onSubmit = useCallback((event: React.SyntheticEvent): void => { - event.preventDefault() - const target = event.target as typeof event.target & { - email: { value: string } - password: { value: string } - } - const email = target.email.value - const password = target.password.value +interface Props { + dispatch: AppDispatch + error: string +} - console.log("email and password checked", email, password) +const LoginForm = ({ dispatch, error }: Props): JSX.Element => { + const onSubmit = useCallback( + (event: React.SyntheticEvent): void => { + event.preventDefault() + const target = event.target as typeof event.target & { + email: { value: string } + password: { value: string } + } + const email = target.email.value + const password = target.password.value - // call service with email & password - }, []) + dispatch(fetchVolunteerLogin({ email, password })) + }, + [dispatch] + ) return (
@@ -23,15 +31,16 @@ const LoginForm = (): JSX.Element => {
- +
- +
+
{error}
) } diff --git a/src/components/LoginForm/styles.module.scss b/src/components/LoginForm/styles.module.scss index b7ec2d9..ffcaf11 100755 --- a/src/components/LoginForm/styles.module.scss +++ b/src/components/LoginForm/styles.module.scss @@ -24,3 +24,7 @@ padding: 5px 0; text-align: center; } + +.error { + color: rgb(255, 0, 0); +} diff --git a/src/components/VolunteerInfo/__tests__/VolunteerInfo.tsx b/src/components/VolunteerInfo/__tests__/VolunteerInfo.tsx index 7c40c87..55db3e8 100755 --- a/src/components/VolunteerInfo/__tests__/VolunteerInfo.tsx +++ b/src/components/VolunteerInfo/__tests__/VolunteerInfo.tsx @@ -23,7 +23,7 @@ describe("", () => { privileges: 0, active: 0, comment: "", - timestamp: "0000-00-00", + timestamp: new Date(0), password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O", }} /> diff --git a/src/components/VolunteerList/__tests__/VolunteerList.tsx b/src/components/VolunteerList/__tests__/VolunteerList.tsx index e53a168..a7c50da 100755 --- a/src/components/VolunteerList/__tests__/VolunteerList.tsx +++ b/src/components/VolunteerList/__tests__/VolunteerList.tsx @@ -24,7 +24,7 @@ describe("", () => { privileges: 0, active: 0, comment: "", - timestamp: "0000-00-00", + timestamp: new Date(0), password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O", }, diff --git a/src/components/VolunteerSet/__tests__/VolunteerSet.tsx b/src/components/VolunteerSet/__tests__/VolunteerSet.tsx index f857def..3962b14 100644 --- a/src/components/VolunteerSet/__tests__/VolunteerSet.tsx +++ b/src/components/VolunteerSet/__tests__/VolunteerSet.tsx @@ -25,7 +25,7 @@ describe("", () => { privileges: 0, active: 0, comment: "", - timestamp: "0000-00-00", + timestamp: new Date(0), password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O", }} /> diff --git a/src/components/WishAdd/index.tsx b/src/components/WishAdd/index.tsx index b9afcce..e35247e 100644 --- a/src/components/WishAdd/index.tsx +++ b/src/components/WishAdd/index.tsx @@ -43,7 +43,7 @@ const WishAdd = ({ dispatch }: Props) => { setTeams([""]) setAddedDate("") } else { - toast.warning("Il faut au moins préciser un domain et l'wish", { + toast.warning("Il faut au moins préciser un domaine et l'envie", { position: "top-center", autoClose: 6000, hideProgressBar: true, diff --git a/src/pages/Login/LoginPage.tsx b/src/pages/Login/LoginPage.tsx index e4e0765..46b15c0 100644 --- a/src/pages/Login/LoginPage.tsx +++ b/src/pages/Login/LoginPage.tsx @@ -1,18 +1,26 @@ import { RouteComponentProps } from "react-router-dom" +import { useDispatch, useSelector, shallowEqual } from "react-redux" import React, { memo } from "react" import { Helmet } from "react-helmet" -import styles from "./styles.module.scss" + +import { AppState } from "../../store" import LoginForm from "../../components/LoginForm/LoginForm" +import styles from "./styles.module.scss" export type Props = RouteComponentProps -const RegisterPage: React.FC = (): JSX.Element => ( -
-
- - +const RegisterPage: React.FC = (): JSX.Element => { + const dispatch = useDispatch() + const loginError = useSelector((state: AppState) => state.volunteerLogin.error, shallowEqual) + + return ( +
+
+ + +
-
-) + ) +} export default memo(RegisterPage) diff --git a/src/server/gsheets/accessors.ts b/src/server/gsheets/accessors.ts index 60efa5e..8575e3b 100644 --- a/src/server/gsheets/accessors.ts +++ b/src/server/gsheets/accessors.ts @@ -68,12 +68,15 @@ export class Sheet< frenchSpecimen: Element + invertedTranslation: { [k: string]: string } + // eslint-disable-next-line no-useless-constructor constructor( readonly name: keyof SheetNames, readonly specimen: Element, readonly translation: { [k in keyof Element]: string } ) { + this.invertedTranslation = _.invert(this.translation) this.sheetName = sheetNames[name] this.frenchSpecimen = _.mapValues( _.invert(translation), @@ -175,7 +178,7 @@ export class Sheet< } // eslint-disable-next-line @typescript-eslint/ban-types const elements = this._state as Element[] - const types = _.pick(rows[0], Object.keys(elements[0] || {})) as Record< + const types = _.pick(rows[0], Object.values(this.translation)) as Record< keyof Element, string > @@ -185,17 +188,22 @@ export class Sheet< // eslint-disable-next-line no-restricted-syntax for (const element of elements) { const row = rows[rowid] - const stringifiedRow = this.stringifyElement(element, types) + const frenchElement = _.mapValues( + this.invertedTranslation, + (englishProp: string) => (element as any)[englishProp] + ) as Element + const stringifiedRow = this.stringifyElement(frenchElement, types) if (!row) { // eslint-disable-next-line no-await-in-loop await sheet.addRow(stringifiedRow) } else { const keys = Object.keys(stringifiedRow) - const sameCells = _.every( - keys, - (key: keyof Element) => row[key as string] === stringifiedRow[key] - ) + const sameCells = _.every(keys, (key: keyof Element) => { + const rawVal = row[key as string] + const val: string = rawVal === undefined ? "" : rawVal + return val === stringifiedRow[key] + }) if (!sameCells) { keys.forEach((key) => { row[key] = stringifiedRow[key as keyof Element] @@ -238,9 +246,13 @@ export class Sheet< keyof Element, string > - const element = this.parseElement(stringifiedElement, types) - if (element !== undefined) { - elements.push(element) + const frenchData: any = this.parseElement(stringifiedElement, types) + if (frenchData !== undefined) { + const englishElement = _.mapValues( + this.translation, + (frenchProp: string) => frenchData[frenchProp] + ) as Element + elements.push(englishElement) } }) @@ -293,18 +305,13 @@ export class Sheet< if (rawProp === undefined) { element[prop] = undefined } else { - // eslint-disable-next-line no-case-declarations - const matchDate = rawProp.match(/^([0-9]+)\/([0-9]+)\/([0-9]+)$/) - if (!matchDate) { + try { + element[prop] = parseDate(rawProp) + } catch (e: any) { throw new Error( - `Unable to read date from val ${rawProp} in sheet ${this.name} at prop ${prop}` + `${e.message} in sheet ${this.name} at prop ${prop}` ) } - element[prop] = new Date( - +matchDate[3], - +matchDate[2] - 1, - +matchDate[1] - ) } break @@ -345,27 +352,15 @@ export class Sheet< const rawDates = rawProp.split(delimiter) element[prop] = [] // eslint-disable-next-line no-case-declarations - const rightFormat = rawDates.every((rawDate) => { - const matchDateArray = rawDate.match( - /^([0-9]+)\/([0-9]+)\/([0-9]+)$/ - ) - if (!matchDateArray) { - return false - } - element[prop].push( - new Date( - +matchDateArray[3], - +matchDateArray[2] - 1, - +matchDateArray[1] + rawDates.forEach((rawDate) => { + try { + element[prop].push(parseDate(rawDate)) + } catch (e: any) { + throw new Error( + `${e.message} in sheet ${this.name} at prop ${prop}` ) - ) - return true + } }) - if (!rightFormat) { - throw new Error( - `One array item is not a date for val ${rawProp} in sheet ${this.name} at prop ${prop}` - ) - } break default: throw new Error( @@ -391,7 +386,7 @@ export class Sheet< const value = element[prop as keyof Element] switch (type) { case "string": - stringifiedElement[prop as keyof Element] = Sheet.formulaSafe(`${value}`) + stringifiedElement[prop as keyof Element] = formulaSafe(`${value}`) break case "number": @@ -403,7 +398,7 @@ export class Sheet< break case "date": - stringifiedElement[prop as keyof Element] = Sheet.stringifiedDate(value) + stringifiedElement[prop as keyof Element] = stringifiedDate(value) break default: @@ -426,7 +421,7 @@ export class Sheet< if (!_.every(value, _.isString)) { throw new Error(`Each date of ${value} is not a string`) } - stringifiedElement[prop as keyof Element] = Sheet.formulaSafe( + stringifiedElement[prop as keyof Element] = formulaSafe( value.join(delimiter) ) break @@ -453,10 +448,7 @@ export class Sheet< } stringifiedElement[prop as keyof Element] = _.map( value, - (val) => - `${val.getDate()}/${ - val.getMonth() + 1 - }/${val.getFullYear()}` + stringifiedDate ).join(delimiter) break @@ -472,24 +464,33 @@ export class Sheet< return rawElement } - - private static formulaSafe(value: string): string { - return value.replace(/^=+/, "") - } - - private static stringifiedDate(value: unknown): string { - let date: Date - if (value instanceof Date) { - date = value - } else if (typeof value === "string") { - try { - date = new Date(value) - } catch (e) { - throw new Error("Wrong date string format in stringifyElement") - } - } else { - throw new Error("Wrong date format in stringifyElement") - } - return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}` - } +} + +function formulaSafe(value: string): string { + return value === undefined ? "" : value.replace(/^=+/, "") +} + +function stringifiedDate(value: unknown): string { + let date: Date + if (value instanceof Date) { + date = value + } else if (typeof value === "string") { + try { + date = new Date(value) + } catch (e) { + throw new Error("Wrong date string format in stringifyElement") + } + } else { + throw new Error("Wrong date format in stringifyElement") + } + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` +} + +function parseDate(value: string): Date { + // eslint-disable-next-line no-case-declarations + const matchDate = value.match(/^([0-9]+)\/([0-9]+)\/([0-9]+)$/) + if (!matchDate) { + throw new Error(`Unable to read date from val ${value}`) + } + return new Date(+matchDate[1], +matchDate[2] - 1, +matchDate[3]) } diff --git a/src/server/gsheets/expressAccessors.ts b/src/server/gsheets/expressAccessors.ts index 6e19a08..7a64d57 100644 --- a/src/server/gsheets/expressAccessors.ts +++ b/src/server/gsheets/expressAccessors.ts @@ -51,11 +51,7 @@ export default class ExpressAccessors< add() { return async (request: Request, response: Response, _next: NextFunction): Promise => { try { - this.sheet.add(request.body) - const elements: Element[] = (await this.sheet.getList()) || [] - const element: Element = { id: await this.sheet.nextId(), ...request.body } - elements.push(element) - await this.sheet.setList(elements) + const element: Element = await this.sheet.add(request.body) if (element) { response.status(200).json(element) } @@ -76,17 +72,16 @@ export default class ExpressAccessors< } } - customGet(transformer: (list?: Element[]) => any) { - return async ( - _request: Request, - response: Response, - _next: NextFunction - ): Promise => { + // transformer can be an async function + customGet( + transformer: (list: Element[] | undefined, body?: Request["body"]) => Promise | any + ) { + return async (request: Request, response: Response, _next: NextFunction): Promise => { try { const elements = await this.sheet.getList() - response.status(200).json(transformer(elements)) - } catch (e: unknown) { - response.status(400).json(e) + response.status(200).json(await transformer(elements, request.body)) + } catch (e: any) { + response.status(200).json({ error: e.message }) } } } diff --git a/src/server/gsheets/volunteers.ts b/src/server/gsheets/volunteers.ts index 6043b67..3b567a4 100644 --- a/src/server/gsheets/volunteers.ts +++ b/src/server/gsheets/volunteers.ts @@ -1,5 +1,10 @@ +import { Request } from "express" +import bcrypt from "bcrypt" + import ExpressAccessors from "./expressAccessors" import { Volunteer, VolunteerWithoutId, translationVolunteer } from "../../services/volunteers" +import { canonicalEmail } from "../../utils/standardization" +import { getJwt } from "../secure" const expressAccessor = new ExpressAccessors( "Volunteers", @@ -14,3 +19,32 @@ export const volunteerGet = expressAccessor.get() export const volunteerAdd = expressAccessor.add() export const volunteerSet = expressAccessor.set() + +export const volunteerLogin = expressAccessor.customGet( + async (list: Volunteer[] | undefined, body: Request["body"]) => { + if (!list) { + throw Error("Il n'y a aucun bénévole avec cet email") + } + const email = canonicalEmail(body.email || "") + const volunteer = list.find((v) => canonicalEmail(v.email) === email) + if (!volunteer) { + throw Error("Il n'y a aucun bénévole avec cet email") + } + + const password = body.password || "" + const passwordMatch = await bcrypt.compare( + password, + volunteer.password.replace(/^\$2y/, "$2a") + ) + if (!passwordMatch) { + throw Error("Mauvais mot de passe pour cet email") + } + + const jwt = await getJwt(email) + + return { + firstname: volunteer.firstname, + jwt, + } + } +) diff --git a/src/server/index.ts b/src/server/index.ts index 9d79f7b..a21d181 100755 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -19,8 +19,7 @@ import { secure } from "./secure" import { javGameListGet } from "./gsheets/javGames" import { wishListGet, wishAdd } from "./gsheets/wishes" import { preVolunteerAdd, preVolunteerCountGet } from "./gsheets/preVolunteers" -import { volunteerGet, volunteerSet } from "./gsheets/volunteers" -import loginHandler from "./userManagement/login" +import { volunteerGet, volunteerSet, volunteerLogin } from "./gsheets/volunteers" import config from "../config" const app = express() @@ -47,9 +46,6 @@ if (__DEV__) devServer(app) app.use(express.json()) -// Sign in & up API -app.post("/api/user/login", loginHandler) - /** * APIs */ @@ -59,6 +55,7 @@ app.get("/WishListGet", wishListGet) app.post("/WishAdd", wishAdd) app.post("/PreVolunteerAdd", preVolunteerAdd) app.get("/PreVolunteerCountGet", preVolunteerCountGet) +app.post("/VolunteerLogin", volunteerLogin) // Secured APIs app.get("/VolunteerGet", secure as RequestHandler, volunteerGet) diff --git a/src/server/secure.ts b/src/server/secure.ts index 96b1e95..e0481fe 100644 --- a/src/server/secure.ts +++ b/src/server/secure.ts @@ -53,12 +53,12 @@ async function getSecret() { export async function getJwt(email: string): Promise { const jwt = sign( { user: canonicalEmail(email), permissions: [] }, - await getSecret(), - __TEST__ - ? undefined - : { - expiresIn: "365d", - } + await getSecret() + // __TEST__ + // ? undefined + // : { + // expiresIn: "365d", + // } ) return jwt } diff --git a/src/server/userManagement/__tests__/login.tsx b/src/server/userManagement/__tests__/login.tsx deleted file mode 100755 index 712eca4..0000000 --- a/src/server/userManagement/__tests__/login.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import _ from "lodash" -import { getSheet } from "../../gsheets/accessors" -import { login } from "../login" - -// Could do a full test with: wget --header='Content-Type:application/json' --post-data='{"email":"pikiou.sub@gmail.com","password":"mot de passe"}' http://localhost:3000/api/user/login - -// Full test with Bearer: wget --header='Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoicGlraW91c3ViQGdlYWlsLmNvbSIsInBlcm1pc3Npb25zIjpbXSwiaWF0IjoxNjM4MjUzODgzLCJleHAiOjE2Mzg4NTg2ODN9.MknJ4NfcVlgW2ODeimfwZI1a4z8asdEXtHwHgViy6c4' http://localhost:3000/VolunteerGet?id=1 - -const mockUser = { - email: "my.email@gmail.com", - password: "$2y$10$cuKFHEow2IVSZSPtoVsw6uZFNFOOP/v1V7fubbyvrxhZdsnxLHr.2", - firstname: "monPrénom", -} - -jest.mock("../../gsheets/accessors") - -describe("login with", () => { - beforeAll(() => { - ;(getSheet as jest.Mock).mockImplementation(() => ({ - getList: async () => [mockUser], - })) - }) - - it("right password", async () => { - const res = await login("my.email@gmail.com", "12345678") - expect(_.omit(res, "jwt")).toEqual({ - volunteer: { - firstname: mockUser.firstname, - }, - }) - expect(res.jwt).toBeDefined() - }) - - it("invalid password length", async () => { - await expect(login("my.email@gmail.com", "123")).rejects.toThrow("Mot de passe trop court") - }) - - it("empty password", async () => { - await expect(login("my.email@gmail.com", " ")).rejects.toThrow("Mot de passe nécessaire") - }) - - it("wrong password", async () => { - await expect(login("my.email@gmail.com", "1234567891011")).rejects.toThrow( - "Mauvais mot de passe pour cet email" - ) - }) - - it("invalid email format", async () => { - await expect(login("my.email@gmail", "12345678")).rejects.toThrow("Email invalid") - }) - - it("empty email", async () => { - await expect(login(" ", "12345678")).rejects.toThrow("Email invalid") - }) - - it("unknown email", async () => { - await expect(login("mon.emailBidon@gmail.com", "12345678")).rejects.toThrow( - "Cet email ne correspond à aucun utilisateur" - ) - }) -}) diff --git a/src/server/userManagement/login.ts b/src/server/userManagement/login.ts deleted file mode 100644 index ae9752e..0000000 --- a/src/server/userManagement/login.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Request, Response, NextFunction } from "express" -import bcrypt from "bcrypt" -import { - Volunteer, - VolunteerWithoutId, - VolunteerLogin, - emailRegexp, - passwordMinLength, - translationVolunteer, -} from "../../services/volunteers" -import { getSheet } from "../gsheets/accessors" -import { getJwt } from "../secure" - -export default async function loginHandler( - request: Request, - response: Response, - _next: NextFunction -): Promise { - try { - if (typeof request.body.email !== "string" || typeof request.body.password !== "string") { - throw Error() - } - const res = await login(request.body.email, request.body.password) - response.status(200).json(res) - } catch (e: any) { - if (e.message) { - response.status(200).json({ error: e.message }) - } else { - response.status(400).json(e) - } - } -} - -export async function login(rawEmail: string, rawPassword: string): Promise { - const sheet = getSheet( - "Volunteers", - new Volunteer(), - translationVolunteer - ) - - const email = rawEmail.replace(/^\s*/, "").replace(/\s*$/, "") - if (!emailRegexp.test(email)) { - throw Error("Email invalid") - } - - const password = rawPassword.replace(/^\s*/, "").replace(/\s*$/, "") - if (password.length === 0) { - throw Error("Mot de passe nécessaire") - } - if (password.length < passwordMinLength) { - throw Error("Mot de passe trop court") - } - - const volunteers: Volunteer[] | undefined = await sheet.getList() - const volunteer = volunteers && volunteers.find((m) => m.email === email) - if (!volunteer) { - throw Error("Cet email ne correspond à aucun utilisateur") - } - - const passwordMatch = await bcrypt.compare(password, volunteer.password.replace(/^\$2y/, "$2a")) - if (!passwordMatch) { - throw Error("Mauvais mot de passe pour cet email") - } - - const jwt = await getJwt(email) - - return { - volunteer: { - firstname: volunteer.firstname, - }, - jwt, - } -} diff --git a/src/services/accessors.ts b/src/services/accessors.ts index 0b9f714..8b28597 100644 --- a/src/services/accessors.ts +++ b/src/services/accessors.ts @@ -1,5 +1,4 @@ import axios from "axios" -import _ from "lodash" import config from "../config" import { axiosConfig } from "./auth" @@ -12,7 +11,7 @@ export default function getServiceAccessors< // eslint-disable-next-line @typescript-eslint/ban-types ElementNoId extends object, Element extends ElementNoId & ElementWithId ->(elementName: string, translation: { [k in keyof Element]: string }): any { +>(elementName: string): any { function get(): (id: number) => Promise<{ data?: Element error?: Error @@ -27,14 +26,7 @@ export default function getServiceAccessors< ...axiosConfig, params: { id }, }) - if (!data) { - return { data } - } - const englishData = _.mapValues( - translation, - (frenchProp: string) => data[frenchProp] - ) as Element - return { data: englishData } + return { data } } catch (error) { return { error: error as Error } } @@ -55,18 +47,7 @@ export default function getServiceAccessors< `${config.API_URL}/${elementName}ListGet`, axiosConfig ) - if (!data) { - return { data } - } - - const englishDataList = data.map( - (frenchData: any) => - _.mapValues( - translation, - (frenchProp: string) => frenchData[frenchProp] - ) as Element - ) - return { data: englishDataList } + return { data } } catch (error) { return { error: error as Error } } @@ -84,27 +65,12 @@ export default function getServiceAccessors< } return async (volunteerWithoutId: ElementNoId): Promise => { try { - const invertedTranslationWithoutId = _.invert(_.omit(translation, "id")) - const frenchDataWithoutId = _.mapValues( - invertedTranslationWithoutId, - (englishProp: string, _frenchProp: string) => - (volunteerWithoutId as any)[englishProp] - ) - const { data } = await axios.post( `${config.API_URL}/${elementName}Add`, - frenchDataWithoutId, + volunteerWithoutId, axiosConfig ) - if (!data) { - return { data } - } - - const englishData = _.mapValues( - translation, - (frenchProp: string) => data[frenchProp] - ) as Element - return { data: englishData } + return { data } } catch (error) { return { error: error as Error } } @@ -121,26 +87,12 @@ export default function getServiceAccessors< } return async (volunteer: Element): Promise => { try { - const invertedTranslation = _.invert(translation) - const frenchData = _.mapValues( - invertedTranslation, - (englishProp: string) => (volunteer as any)[englishProp] - ) - const { data } = await axios.post( `${config.API_URL}/${elementName}Set`, - frenchData, + volunteer, axiosConfig ) - if (!data) { - return { data } - } - - const englishData = _.mapValues( - translation, - (frenchProp: string) => data[frenchProp] - ) as Element - return { data: englishData } + return { data } } catch (error) { return { error: error as Error } } @@ -168,5 +120,30 @@ export default function getServiceAccessors< } } - return { listGet, get, set, add, countGet } + function customPost(apiName: string): (params: any) => Promise<{ + data?: Element + error?: Error + }> { + interface ElementGetResponse { + data?: Element + error?: Error + } + return async (params: any): Promise => { + try { + const { data } = await axios.post( + `${config.API_URL}/${elementName}${apiName}`, + params, + axiosConfig + ) + if (data.error) { + throw Error(data.error) + } + return { data } + } catch (error) { + return { error: error as Error } + } + } + } + + return { listGet, get, set, add, countGet, customPost } } diff --git a/src/services/auth.ts b/src/services/auth.ts index 95b19a5..5fb7332 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -2,15 +2,15 @@ import { AxiosRequestConfig } from "axios" const storage: any = localStorage +export const axiosConfig: AxiosRequestConfig = { + headers: {}, +} + const jwt: string | null = storage?.getItem("id_token") if (jwt) { setJWT(jwt) } -export const axiosConfig: AxiosRequestConfig = { - headers: {}, -} - export function setJWT(token: string): void { axiosConfig.headers.Authorization = `Bearer ${token}` storage?.setItem("id_token", token) diff --git a/src/services/javGames.ts b/src/services/javGames.ts index af1f2c3..7a8325e 100644 --- a/src/services/javGames.ts +++ b/src/services/javGames.ts @@ -54,10 +54,7 @@ const elementName = "JavGame" export type JavGameWithoutId = Omit -const { listGet, get, set, add } = getServiceAccessors( - elementName, - translationJavGame -) +const { listGet, get, set, add } = getServiceAccessors(elementName) export const javGameListGet = listGet() export const javGameGet = get() diff --git a/src/services/preVolunteers.ts b/src/services/preVolunteers.ts index 54b28eb..da0d7ac 100644 --- a/src/services/preVolunteers.ts +++ b/src/services/preVolunteers.ts @@ -28,12 +28,16 @@ export const translationPreVolunteer: { [k in keyof PreVolunteer]: string } = { const elementName = "PreVolunteer" +export const emailRegexp = + /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i +export const passwordMinLength = 4 + export type PreVolunteerWithoutId = Omit const { listGet, get, set, add, countGet } = getServiceAccessors< PreVolunteerWithoutId, PreVolunteer ->(elementName, translationPreVolunteer) +>(elementName) export const preVolunteerListGet = listGet() export const preVolunteerGet = get() diff --git a/src/services/volunteers.ts b/src/services/volunteers.ts index 1ea0dbe..d7513f6 100644 --- a/src/services/volunteers.ts +++ b/src/services/volunteers.ts @@ -23,7 +23,7 @@ export class Volunteer { comment = "" - timestamp = "" + timestamp = new Date() password = "" } @@ -50,22 +50,18 @@ export const emailRegexp = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i export const passwordMinLength = 4 -export interface VolunteerLogin { - volunteer?: { - firstname: string - } - jwt?: string - error?: string -} - export type VolunteerWithoutId = Omit -const { listGet, get, set, add } = getServiceAccessors( - elementName, - translationVolunteer -) +const accessors = getServiceAccessors(elementName) +const { listGet, get, set, add } = accessors export const volunteerListGet = listGet() export const volunteerGet = get() export const volunteerAdd = add() export const volunteerSet = set() + +export interface VolunteerLogin { + firstname: string + jwt: string +} +export const volunteerLogin = accessors.customPost("Login") diff --git a/src/services/wishes.ts b/src/services/wishes.ts index 3e44404..e4265ce 100644 --- a/src/services/wishes.ts +++ b/src/services/wishes.ts @@ -27,10 +27,7 @@ const elementName = "Wish" export type WishWithoutId = Omit -const { listGet, get, set, add } = getServiceAccessors( - elementName, - translationWish -) +const { listGet, get, set, add } = getServiceAccessors(elementName) export const wishListGet = listGet() export const wishGet = get() diff --git a/src/store/__tests__/javGameList.ts b/src/store/__tests__/javGameList.ts index 803e23a..1489545 100644 --- a/src/store/__tests__/javGameList.ts +++ b/src/store/__tests__/javGameList.ts @@ -13,27 +13,7 @@ import { JavGame } from "../../services/javGames" jest.mock("axios") -const mockFrenchData: any[] = [ - { - id: 5, - titre: "6 qui prend!", - auteur: "Wolfgang Kramer", - editeur: "(uncredited) , Design Edge , B", - minJoueurs: 2, - maxJoueurs: 10, - duree: 45, - type: "Ambiance", - poufpaf: "0-9-2/6-qui-prend-6-nimmt", - bggPhoto: - "https://cf.geekdo-images.com/thumb/img/lzczxR5cw7an7tRWeHdOrRtLyes=/fit-in/200x150/pic772547.jpg", - bggId: 432, - exemplaires: 1, - dispoPret: 1, - nonRangee: 0, - ean: "3421272101313", - }, -] -const mockEnglishData: JavGame[] = [ +const mockData: JavGame[] = [ { id: 5, title: "6 qui prend!", @@ -70,14 +50,12 @@ describe("JavGameList reducer", () => { }) it("should handle success correctly", () => { - expect(JavGameList(undefined, { type: getSuccess.type, payload: mockEnglishData })).toEqual( - { - ...initialState, - readyStatus: "success", - ids: _.map(mockEnglishData, "id"), - entities: _.keyBy(mockEnglishData, "id"), - } - ) + expect(JavGameList(undefined, { type: getSuccess.type, payload: mockData })).toEqual({ + ...initialState, + readyStatus: "success", + ids: _.map(mockData, "id"), + entities: _.keyBy(mockData, "id"), + }) }) it("should handle failure correctly", () => { @@ -94,11 +72,11 @@ describe("JavGameList action", () => { const { dispatch, getActions } = mockStore() const expectedActions = [ { type: getRequesting.type, payload: undefined }, - { type: getSuccess.type, payload: mockEnglishData }, + { type: getSuccess.type, payload: mockData }, ] // @ts-expect-error - axios.get.mockResolvedValue({ data: mockFrenchData }) + axios.get.mockResolvedValue({ data: mockData }) await dispatch(fetchJavGameList()) expect(getActions()).toEqual(expectedActions) diff --git a/src/store/__tests__/volunteer.ts b/src/store/__tests__/volunteer.ts index 57fa263..6a35048 100644 --- a/src/store/__tests__/volunteer.ts +++ b/src/store/__tests__/volunteer.ts @@ -12,23 +12,7 @@ import { Volunteer } from "../../services/volunteers" jest.mock("axios") -const mockFrenchData: any = { - id: 1, - nom: "Aupeix", - prenom: "Amélie", - mail: "pakouille.lakouille@yahoo.fr", - telephone: "0675650392", - photo: "images/volunteers/$taille/amélie_aupeix.jpg", - alimentation: "Végétarien", - majeur: 1, - privilege: 0, - actif: 0, - commentaire: "", - horodatage: "0000-00-00", - passe: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O", -} - -const mockEnglishData: Volunteer = { +const mockData: Volunteer = { id: 1, lastname: "Aupeix", firstname: "Amélie", @@ -40,10 +24,10 @@ const mockEnglishData: Volunteer = { privileges: 0, active: 0, comment: "", - timestamp: "0000-00-00", + timestamp: new Date(0), password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O", } -const { id } = mockEnglishData +const { id } = mockData const mockError = "Oops! Something went wrong." describe("volunteer reducer", () => { @@ -62,9 +46,9 @@ describe("volunteer reducer", () => { expect( volunteer(undefined, { type: getSuccess.type, - payload: mockEnglishData, + payload: mockData, }) - ).toEqual({ readyStatus: "success", entity: mockEnglishData }) + ).toEqual({ readyStatus: "success", entity: mockData }) }) it("should handle failure correctly", () => { @@ -82,11 +66,11 @@ describe("volunteer action", () => { const { dispatch, getActions } = mockStore() const expectedActions = [ { type: getRequesting.type, payload: undefined }, - { type: getSuccess.type, payload: mockEnglishData }, + { type: getSuccess.type, payload: mockData }, ] // @ts-expect-error - axios.get.mockResolvedValue({ data: mockFrenchData }) + axios.get.mockResolvedValue({ data: mockData }) await dispatch(fetchVolunteer(id)) expect(getActions()).toEqual(expectedActions) diff --git a/src/store/__tests__/volunteerList.ts b/src/store/__tests__/volunteerList.ts index 540c156..27e954f 100644 --- a/src/store/__tests__/volunteerList.ts +++ b/src/store/__tests__/volunteerList.ts @@ -13,25 +13,7 @@ import { Volunteer } from "../../services/volunteers" jest.mock("axios") -const mockFrenchData: any[] = [ - { - id: 1, - nom: "Aupeix", - prenom: "Amélie", - mail: "pakouille.lakouille@yahoo.fr", - telephone: "0675650392", - photo: "images/volunteers/$taille/amélie_aupeix.jpg", - alimentation: "Végétarien", - majeur: 1, - privilege: 0, - actif: 0, - commentaire: "", - horodatage: "0000-00-00", - passe: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O", - }, -] - -const mockEnglishData: Volunteer[] = [ +const mockData: Volunteer[] = [ { id: 1, lastname: "Aupeix", @@ -44,7 +26,7 @@ const mockEnglishData: Volunteer[] = [ privileges: 0, active: 0, comment: "", - timestamp: "0000-00-00", + timestamp: new Date(0), password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O", }, ] @@ -65,13 +47,11 @@ describe("volunteerList reducer", () => { }) it("should handle success correctly", () => { - expect( - volunteerList(undefined, { type: getSuccess.type, payload: mockEnglishData }) - ).toEqual({ + expect(volunteerList(undefined, { type: getSuccess.type, payload: mockData })).toEqual({ ...initialState, readyStatus: "success", - ids: _.map(mockEnglishData, "id"), - entities: _.keyBy(mockEnglishData, "id"), + ids: _.map(mockData, "id"), + entities: _.keyBy(mockData, "id"), }) }) @@ -89,11 +69,11 @@ describe("volunteerList action", () => { const { dispatch, getActions } = mockStore() const expectedActions = [ { type: getRequesting.type, payload: undefined }, - { type: getSuccess.type, payload: mockEnglishData }, + { type: getSuccess.type, payload: mockData }, ] // @ts-expect-error - axios.get.mockResolvedValue({ data: mockFrenchData }) + axios.get.mockResolvedValue({ data: mockData }) await dispatch(fetchVolunteerList()) expect(getActions()).toEqual(expectedActions) diff --git a/src/store/preVolunteerCount.ts b/src/store/preVolunteerCount.ts index 268afd4..660aeb9 100644 --- a/src/store/preVolunteerCount.ts +++ b/src/store/preVolunteerCount.ts @@ -32,7 +32,8 @@ export const fetchPreVolunteerCount = elementValueFetch( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors du chargement des volunteers: ${error.message}`) + (error: Error) => + toastError(`Erreur lors du chargement des bénévoles potentiels: ${error.message}`) ) const shouldFetchPreVolunteerCount = (state: AppState) => diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index c8e70bc..3b613dc 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -8,6 +8,7 @@ import volunteer from "./volunteer" import volunteerAdd from "./volunteerAdd" import volunteerList from "./volunteerList" import volunteerSet from "./volunteerSet" +import volunteerLogin from "./volunteerLogin" import preVolunteerAdd from "./preVolunteerAdd" import preVolunteerCount from "./preVolunteerCount" @@ -21,6 +22,7 @@ export default (history: History) => ({ volunteerAdd, volunteerList, volunteerSet, + volunteerLogin, preVolunteerAdd, preVolunteerCount, router: connectRouter(history) as any, diff --git a/src/store/utils.ts b/src/store/utils.ts index 0db504d..5729229 100644 --- a/src/store/utils.ts +++ b/src/store/utils.ts @@ -33,32 +33,32 @@ export function toastSuccess(message: string): void { } export function elementFetch( - elementService: (id: number) => Promise<{ + elementService: (...idArgs: any[]) => Promise<{ data?: Element | undefined error?: Error | undefined }>, getRequesting: ActionCreatorWithoutPayload, getSuccess: ActionCreatorWithPayload, getFailure: ActionCreatorWithPayload, - errorMessage: (error: Error) => void = (_error) => { - /* Meant to be empty */ - }, - successMessage: () => void = () => { - /* Meant to be empty */ - } -): (id: number) => AppThunk { - return (id: number): AppThunk => + errorMessage?: (error: Error) => void, + successMessage?: (data: Element) => void +): (...idArgs: any[]) => AppThunk { + return (...idArgs: any[]): AppThunk => async (dispatch) => { dispatch(getRequesting()) - const { error, data } = await elementService(id) + const { error, data } = await elementService(...idArgs) if (error) { dispatch(getFailure(error.message)) - errorMessage(error) + if (errorMessage) { + errorMessage(error) + } } else { dispatch(getSuccess(data as Element)) - successMessage() + if (successMessage) { + successMessage(data as Element) + } } } } diff --git a/src/store/volunteer.ts b/src/store/volunteer.ts index 3e05c1d..081570c 100644 --- a/src/store/volunteer.ts +++ b/src/store/volunteer.ts @@ -36,7 +36,7 @@ export const fetchVolunteer = elementFetch( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors du chargement d'un volunteer: ${error.message}`) + (error: Error) => toastError(`Erreur lors du chargement d'un bénévole: ${error.message}`) ) const shouldFetchVolunteer = (state: AppState, id: number) => diff --git a/src/store/volunteerAdd.ts b/src/store/volunteerAdd.ts index 8e2a795..2a8fb92 100644 --- a/src/store/volunteerAdd.ts +++ b/src/store/volunteerAdd.ts @@ -33,6 +33,6 @@ export const fetchVolunteerAdd = elementAddFetch( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors de l'ajout d'une volunteer: ${error.message}`), + (error: Error) => toastError(`Erreur lors de l'ajout d'un bénévole: ${error.message}`), () => toastSuccess("Volunteer ajoutée !") ) diff --git a/src/store/volunteerList.ts b/src/store/volunteerList.ts index 314bb7f..b28c396 100644 --- a/src/store/volunteerList.ts +++ b/src/store/volunteerList.ts @@ -36,7 +36,7 @@ export const fetchVolunteerList = elementListFetch( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors du chargement des volunteers: ${error.message}`) + (error: Error) => toastError(`Erreur lors du chargement des bénévoles: ${error.message}`) ) const shouldFetchVolunteerList = (state: AppState) => state.volunteerList.readyStatus !== "success" diff --git a/src/store/volunteerLogin.ts b/src/store/volunteerLogin.ts new file mode 100644 index 0000000..953c766 --- /dev/null +++ b/src/store/volunteerLogin.ts @@ -0,0 +1,43 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit" + +import { StateRequest, elementFetch } from "./utils" +import { VolunteerLogin, volunteerLogin } from "../services/volunteers" +import { setJWT } from "../services/auth" + +type StateVolunteer = { entity?: VolunteerLogin } & StateRequest + +export const initialState: StateVolunteer = { + readyStatus: "idle", +} + +const volunteerLoginSlice = createSlice({ + name: "volunteer", + initialState, + reducers: { + getRequesting: (_) => ({ + readyStatus: "request", + }), + getSuccess: (_, { payload }: PayloadAction) => ({ + readyStatus: "success", + entity: payload, + }), + getFailure: (_, { payload }: PayloadAction) => ({ + readyStatus: "failure", + error: payload, + }), + }, +}) + +export default volunteerLoginSlice.reducer +export const { getRequesting, getSuccess, getFailure } = volunteerLoginSlice.actions + +export const fetchVolunteerLogin = elementFetch( + volunteerLogin, + getRequesting, + getSuccess, + getFailure, + undefined, + (login: VolunteerLogin) => { + setJWT(login.jwt) + } +) diff --git a/src/store/volunteerSet.ts b/src/store/volunteerSet.ts index d405eb8..1266107 100644 --- a/src/store/volunteerSet.ts +++ b/src/store/volunteerSet.ts @@ -33,6 +33,6 @@ export const fetchVolunteerSet = elementSet( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors de la modification d'un volunteer: ${error.message}`), - () => toastSuccess("Volunteer modifié !") + (error: Error) => toastError(`Erreur lors de la modification d'un bénévole: ${error.message}`), + () => toastSuccess("Bénévole modifié !") ) diff --git a/src/store/wishAdd.ts b/src/store/wishAdd.ts index 9ce9c99..f9e1a73 100644 --- a/src/store/wishAdd.ts +++ b/src/store/wishAdd.ts @@ -33,6 +33,6 @@ export const fetchWishAdd = elementAddFetch( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors de l'ajout d'une wish: ${error.message}`), - () => toastSuccess("Wish ajoutée !") + (error: Error) => toastError(`Erreur lors de l'ajout d'une envie: ${error.message}`), + () => toastSuccess("Envie ajoutée !") ) diff --git a/src/store/wishList.ts b/src/store/wishList.ts index 2c989f8..ee71595 100644 --- a/src/store/wishList.ts +++ b/src/store/wishList.ts @@ -34,7 +34,7 @@ export const fetchWishList = elementListFetch( getRequesting, getSuccess, getFailure, - (error: Error) => toastError(`Erreur lors du chargement des wishes: ${error.message}`) + (error: Error) => toastError(`Erreur lors du chargement des envies: ${error.message}`) ) const shouldFetchWishList = (state: AppState) => state.wishList.readyStatus !== "success" diff --git a/src/utils/standardization.ts b/src/utils/standardization.ts index e06c55b..bb4668d 100644 --- a/src/utils/standardization.ts +++ b/src/utils/standardization.ts @@ -16,3 +16,31 @@ export function validEmail(email: string): boolean { email ) } + +export function validMobile(mobile: string): boolean { + return ( + /^\(?\+?[-0-9. ()]+$/.test(trim(mobile)) && + trim(mobile).replace(/[^0-9]+/g, "").length >= 10 + ) +} + +export function canonicalMobile(mobile: string): string { + if (!validMobile(mobile)) { + return "" + } + let clean = trim(mobile).replace(/[-0-9. ()+]$/g, "") + if (clean.length === 11) { + clean = clean.replace(/^33/, "0") + } + if (clean.length < 10) { + return "" + } + if (clean.length === 10) { + return clean.replace(/([0-9]{2})/g, "$1 ").replace(/ $/, "") + } + return clean +} + +export function trim(src: string): string { + return typeof src !== "string" ? "" : src.replace(/^\s*/, "").replace(/\s*$/, "") +} diff --git a/yarn.lock b/yarn.lock index 79875c5..f7c6bbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9138,6 +9138,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-devtools-extension@^2.13.9: + version "2.13.9" + resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz#6b764e8028b507adcb75a1cae790f71e6be08ae7" + integrity sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A== + redux-mock-store@^1.5.4: version "1.5.4" resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.4.tgz#90d02495fd918ddbaa96b83aef626287c9ab5872"