Add login api

This commit is contained in:
pikiou 2022-01-03 17:55:47 +01:00
parent 4540e92171
commit 395955f32a
36 changed files with 340 additions and 437 deletions

View File

@ -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"

View File

@ -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 (
<form onSubmit={onSubmit}>
@ -23,15 +31,16 @@ const LoginForm = (): JSX.Element => {
</div>
<div className={styles.formLine} key="line-email">
<label htmlFor="email">Email</label>
<input type="email" id="email" />
<input type="email" id="email" name="utilisateur" />
</div>
<div className={styles.formLine} key="line-password">
<label htmlFor="password">Mot de passe</label>
<input type="password" id="password" />
<input type="password" id="password" name="motdepasse" />
</div>
<div className={styles.formButtons}>
<button type="submit">Connexion</button>
</div>
<div className={styles.error}>{error}</div>
</form>
)
}

View File

@ -24,3 +24,7 @@
padding: 5px 0;
text-align: center;
}
.error {
color: rgb(255, 0, 0);
}

View File

@ -23,7 +23,7 @@ describe("<VolunteerInfo />", () => {
privileges: 0,
active: 0,
comment: "",
timestamp: "0000-00-00",
timestamp: new Date(0),
password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
}}
/>

View File

@ -24,7 +24,7 @@ describe("<VolunteerList />", () => {
privileges: 0,
active: 0,
comment: "",
timestamp: "0000-00-00",
timestamp: new Date(0),
password:
"$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
},

View File

@ -25,7 +25,7 @@ describe("<SetVolunteer />", () => {
privileges: 0,
active: 0,
comment: "",
timestamp: "0000-00-00",
timestamp: new Date(0),
password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
}}
/>

View File

@ -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,

View File

@ -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<Props> = (): JSX.Element => (
<div className={styles.loginPage}>
<div className={styles.loginContent}>
<Helmet title="RegisterPage" />
<LoginForm />
const RegisterPage: React.FC<Props> = (): JSX.Element => {
const dispatch = useDispatch()
const loginError = useSelector((state: AppState) => state.volunteerLogin.error, shallowEqual)
return (
<div className={styles.loginPage}>
<div className={styles.loginContent}>
<Helmet title="RegisterPage" />
<LoginForm dispatch={dispatch} error={loginError || ""} />
</div>
</div>
</div>
)
)
}
export default memo(RegisterPage)

View File

@ -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])
}

View File

@ -51,11 +51,7 @@ export default class ExpressAccessors<
add() {
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
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<void> => {
// transformer can be an async function
customGet(
transformer: (list: Element[] | undefined, body?: Request["body"]) => Promise<any> | any
) {
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
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 })
}
}
}

View File

@ -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<VolunteerWithoutId, Volunteer>(
"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,
}
}
)

View File

@ -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)

View File

@ -53,12 +53,12 @@ async function getSecret() {
export async function getJwt(email: string): Promise<string> {
const jwt = sign(
{ user: canonicalEmail(email), permissions: [] },
await getSecret(),
__TEST__
? undefined
: {
expiresIn: "365d",
}
await getSecret()
// __TEST__
// ? undefined
// : {
// expiresIn: "365d",
// }
)
return jwt
}

View File

@ -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"
)
})
})

View File

@ -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<void> {
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<VolunteerLogin> {
const sheet = getSheet<VolunteerWithoutId, Volunteer>(
"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,
}
}

View File

@ -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<ElementGetResponse> => {
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<ElementGetResponse> => {
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<ElementGetResponse> => {
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 }
}

View File

@ -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)

View File

@ -54,10 +54,7 @@ const elementName = "JavGame"
export type JavGameWithoutId = Omit<JavGame, "id">
const { listGet, get, set, add } = getServiceAccessors<JavGameWithoutId, JavGame>(
elementName,
translationJavGame
)
const { listGet, get, set, add } = getServiceAccessors<JavGameWithoutId, JavGame>(elementName)
export const javGameListGet = listGet()
export const javGameGet = get()

View File

@ -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<PreVolunteer, "id">
const { listGet, get, set, add, countGet } = getServiceAccessors<
PreVolunteerWithoutId,
PreVolunteer
>(elementName, translationPreVolunteer)
>(elementName)
export const preVolunteerListGet = listGet()
export const preVolunteerGet = get()

View File

@ -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<Volunteer, "id">
const { listGet, get, set, add } = getServiceAccessors<VolunteerWithoutId, Volunteer>(
elementName,
translationVolunteer
)
const accessors = getServiceAccessors<VolunteerWithoutId, Volunteer>(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")

View File

@ -27,10 +27,7 @@ const elementName = "Wish"
export type WishWithoutId = Omit<Wish, "id">
const { listGet, get, set, add } = getServiceAccessors<WishWithoutId, Wish>(
elementName,
translationWish
)
const { listGet, get, set, add } = getServiceAccessors<WishWithoutId, Wish>(elementName)
export const wishListGet = listGet()
export const wishGet = get()

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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) =>

View File

@ -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,

View File

@ -33,32 +33,32 @@ export function toastSuccess(message: string): void {
}
export function elementFetch<Element>(
elementService: (id: number) => Promise<{
elementService: (...idArgs: any[]) => Promise<{
data?: Element | undefined
error?: Error | undefined
}>,
getRequesting: ActionCreatorWithoutPayload<string>,
getSuccess: ActionCreatorWithPayload<Element, string>,
getFailure: ActionCreatorWithPayload<string, string>,
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)
}
}
}
}

View File

@ -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) =>

View File

@ -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 !")
)

View File

@ -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"

View File

@ -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<VolunteerLogin>) => ({
readyStatus: "success",
entity: payload,
}),
getFailure: (_, { payload }: PayloadAction<string>) => ({
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)
}
)

View File

@ -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é !")
)

View File

@ -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 !")
)

View File

@ -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"

View File

@ -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*$/, "")
}

View File

@ -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"