mirror of
https://github.com/Paris-est-Ludique/intranet.git
synced 2025-06-08 08:34:20 +02:00
Add password reset
This commit is contained in:
parent
395955f32a
commit
adde4f366e
@ -57,5 +57,6 @@ module.exports = {
|
||||
__DEV__: true,
|
||||
__LOCAL__: false,
|
||||
__TEST__: false,
|
||||
__SENDGRID_API_KEY__: false,
|
||||
},
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ module.exports = {
|
||||
__SERVER__: false,
|
||||
__LOCAL__: false,
|
||||
__TEST__: true,
|
||||
__SENDGRID_API_KEY__: "",
|
||||
localStorage: { getItem: () => null, setItem: () => null, removeItem: () => null },
|
||||
},
|
||||
maxConcurrency: 50,
|
||||
|
@ -73,6 +73,7 @@
|
||||
"@loadable/component": "^5.15.0",
|
||||
"@loadable/server": "^5.15.0",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"@sendgrid/mail": "^7.6.0",
|
||||
"@types/lodash": "^4.14.177",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"axios": "^0.21.1",
|
||||
|
48
src/components/ForgotForm/ForgotForm.tsx
Normal file
48
src/components/ForgotForm/ForgotForm.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React, { memo, useCallback } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { AppDispatch } from "../../store"
|
||||
import { fetchVolunteerForgot } from "../../store/volunteerForgot"
|
||||
import styles from "./styles.module.scss"
|
||||
|
||||
interface Props {
|
||||
dispatch: AppDispatch
|
||||
error: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const ForgotForm = ({ dispatch, error, message }: Props): JSX.Element => {
|
||||
const onSubmit = useCallback(
|
||||
(event: React.SyntheticEvent): void => {
|
||||
event.preventDefault()
|
||||
const target = event.target as typeof event.target & {
|
||||
email: { value: string }
|
||||
}
|
||||
const email = target.email.value
|
||||
|
||||
dispatch(fetchVolunteerForgot({ email }))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className={styles.forgotIntro} key="forgot-intro">
|
||||
Nous allons te renvoyer un mot de passe à l'adresse suivante.
|
||||
</div>
|
||||
<div className={styles.formLine} key="line-email">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input type="email" id="email" name="utilisateur" />
|
||||
</div>
|
||||
<div className={styles.formButtons}>
|
||||
<button type="submit">Connexion</button>
|
||||
</div>
|
||||
<div className={styles.error}>{error}</div>
|
||||
<div className={styles.message}>{message}</div>
|
||||
<div className={styles.link}>
|
||||
<Link to="/login"> S'identifier </Link>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ForgotForm)
|
43
src/components/ForgotForm/styles.module.scss
Executable file
43
src/components/ForgotForm/styles.module.scss
Executable file
@ -0,0 +1,43 @@
|
||||
@import "../../theme/variables";
|
||||
@import "../../theme/mixins";
|
||||
|
||||
.forgotIntro {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.formLine {
|
||||
padding: 5px 0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.formButtons {
|
||||
margin-top: 10px;
|
||||
padding: 5px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 10px;
|
||||
color: rgb(11, 138, 0);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 10px;
|
||||
color: rgb(255, 0, 0);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import React, { memo, useCallback } from "react"
|
||||
import styles from "./styles.module.scss"
|
||||
import { Link } from "react-router-dom"
|
||||
import { AppDispatch } from "../../store"
|
||||
import { fetchVolunteerLogin } from "../../store/volunteerLogin"
|
||||
import styles from "./styles.module.scss"
|
||||
|
||||
interface Props {
|
||||
dispatch: AppDispatch
|
||||
@ -41,6 +42,9 @@ const LoginForm = ({ dispatch, error }: Props): JSX.Element => {
|
||||
<button type="submit">Connexion</button>
|
||||
</div>
|
||||
<div className={styles.error}>{error}</div>
|
||||
<div className={styles.link}>
|
||||
<Link to="/forgot"> Demander un nouveau mot de passe </Link>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
@ -26,5 +26,12 @@
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 10px;
|
||||
color: rgb(255, 0, 0);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -22,9 +22,9 @@ describe("<VolunteerInfo />", () => {
|
||||
adult: 1,
|
||||
privileges: 0,
|
||||
active: 0,
|
||||
comment: "",
|
||||
timestamp: new Date(0),
|
||||
password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
created: new Date(0),
|
||||
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
|
@ -23,9 +23,10 @@ describe("<VolunteerList />", () => {
|
||||
adult: 1,
|
||||
privileges: 0,
|
||||
active: 0,
|
||||
comment: "",
|
||||
timestamp: new Date(0),
|
||||
password:
|
||||
created: new Date(0),
|
||||
password1:
|
||||
"$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
password2:
|
||||
"$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
},
|
||||
]}
|
||||
|
@ -24,9 +24,9 @@ describe("<SetVolunteer />", () => {
|
||||
adult: 1,
|
||||
privileges: 0,
|
||||
active: 0,
|
||||
comment: "",
|
||||
timestamp: new Date(0),
|
||||
password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
created: new Date(0),
|
||||
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
|
34
src/pages/Forgot/ForgotPage.tsx
Normal file
34
src/pages/Forgot/ForgotPage.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { RouteComponentProps } from "react-router-dom"
|
||||
import { useDispatch, useSelector, shallowEqual } from "react-redux"
|
||||
import React, { memo } from "react"
|
||||
import { Helmet } from "react-helmet"
|
||||
|
||||
import { AppState } from "../../store"
|
||||
import ForgotForm from "../../components/ForgotForm/ForgotForm"
|
||||
import styles from "./styles.module.scss"
|
||||
|
||||
export type Props = RouteComponentProps
|
||||
|
||||
const ForgotPage: React.FC<Props> = (): JSX.Element => {
|
||||
const dispatch = useDispatch()
|
||||
const forgotError = useSelector((state: AppState) => state.volunteerForgot.error, shallowEqual)
|
||||
const forgotMessage = useSelector(
|
||||
(state: AppState) => state.volunteerForgot.entity?.message,
|
||||
shallowEqual
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.forgotPage}>
|
||||
<div className={styles.forgotContent}>
|
||||
<Helmet title="ForgotPage" />
|
||||
<ForgotForm
|
||||
dispatch={dispatch}
|
||||
error={forgotError || ""}
|
||||
message={forgotMessage || ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ForgotPage)
|
14
src/pages/Forgot/index.tsx
Executable file
14
src/pages/Forgot/index.tsx
Executable file
@ -0,0 +1,14 @@
|
||||
import loadable from "@loadable/component"
|
||||
|
||||
import { Loading, ErrorBoundary } from "../../components"
|
||||
import { Props } from "./ForgotPage"
|
||||
|
||||
const ForgotPage = loadable(() => import("./ForgotPage"), {
|
||||
fallback: <Loading />,
|
||||
})
|
||||
|
||||
export default (props: Props): JSX.Element => (
|
||||
<ErrorBoundary>
|
||||
<ForgotPage {...props} />
|
||||
</ErrorBoundary>
|
||||
)
|
9
src/pages/Forgot/styles.module.scss
Executable file
9
src/pages/Forgot/styles.module.scss
Executable file
@ -0,0 +1,9 @@
|
||||
@import "../../theme/mixins";
|
||||
|
||||
.forgotPage {
|
||||
@include page-wrapper-center;
|
||||
}
|
||||
|
||||
.forgotContent {
|
||||
@include page-content-wrapper;
|
||||
}
|
@ -9,18 +9,18 @@ import styles from "./styles.module.scss"
|
||||
|
||||
export type Props = RouteComponentProps
|
||||
|
||||
const RegisterPage: React.FC<Props> = (): JSX.Element => {
|
||||
const LoginPage: 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" />
|
||||
<Helmet title="LoginPage" />
|
||||
<LoginForm dispatch={dispatch} error={loginError || ""} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RegisterPage)
|
||||
export default memo(LoginPage)
|
||||
|
@ -5,6 +5,7 @@ import AsyncHome, { loadData as loadHomeData } from "../pages/Home"
|
||||
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"
|
||||
|
||||
@ -26,6 +27,10 @@ export default [
|
||||
path: "/login",
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: "/forgot",
|
||||
component: Forgot,
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
component: AsyncHome,
|
||||
|
@ -120,6 +120,7 @@ export class Sheet<
|
||||
if (!foundElement) {
|
||||
throw new Error(`No element found to be set in ${this.name} at id ${element.id}`)
|
||||
}
|
||||
|
||||
if (!_.isEqual(foundElement, element)) {
|
||||
Object.assign(foundElement, element)
|
||||
await this.setList(elements)
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { Request, Response, NextFunction } from "express"
|
||||
import { SheetNames, ElementWithId, getSheet, Sheet } from "./accessors"
|
||||
|
||||
export type RequestBody = Request["body"]
|
||||
export type CustomSetReturn<Element> = { toDatabase: Element; toCaller: any }
|
||||
|
||||
export default class ExpressAccessors<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ElementNoId extends object,
|
||||
@ -27,23 +30,27 @@ export default class ExpressAccessors<
|
||||
if (elements) {
|
||||
response.status(200).json(elements)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
response.status(400).json(e)
|
||||
} catch (e: any) {
|
||||
response.status(200).json({ error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get() {
|
||||
// custom can be async
|
||||
get(custom?: (list: Element[], body: Request["body"]) => Promise<any> | any) {
|
||||
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(request.query.id as string, 10) || -1
|
||||
const elements = await this.sheet.getList()
|
||||
if (elements) {
|
||||
const element = elements.find((e: Element) => e.id === id)
|
||||
response.status(200).json(element)
|
||||
const list = (await this.sheet.getList()) || []
|
||||
let toCaller: any
|
||||
if (!custom) {
|
||||
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)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
response.status(400).json(e)
|
||||
response.status(200).json(toCaller)
|
||||
} catch (e: any) {
|
||||
response.status(200).json({ error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -55,31 +62,36 @@ export default class ExpressAccessors<
|
||||
if (element) {
|
||||
response.status(200).json(element)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
response.status(400).json(e)
|
||||
} catch (e: any) {
|
||||
response.status(200).json({ error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set() {
|
||||
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
await this.sheet.set(request.body)
|
||||
response.status(200)
|
||||
} catch (e: unknown) {
|
||||
response.status(400).json(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// transformer can be an async function
|
||||
customGet(
|
||||
transformer: (list: Element[] | undefined, body?: Request["body"]) => Promise<any> | any
|
||||
// custom can be async
|
||||
set(
|
||||
custom?: (
|
||||
list: Element[],
|
||||
body: RequestBody
|
||||
) => Promise<CustomSetReturn<Element>> | CustomSetReturn<Element>
|
||||
) {
|
||||
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const elements = await this.sheet.getList()
|
||||
response.status(200).json(await transformer(elements, request.body))
|
||||
if (!custom) {
|
||||
await this.sheet.set(request.body)
|
||||
response.status(200)
|
||||
} else {
|
||||
const list = (await this.sheet.getList()) || []
|
||||
const { toDatabase, toCaller } = await custom(list, request.body)
|
||||
if (toDatabase !== undefined) {
|
||||
await this.sheet.set(toDatabase)
|
||||
}
|
||||
if (toCaller !== undefined) {
|
||||
response.status(200).json(toCaller)
|
||||
} else {
|
||||
response.status(200)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
response.status(200).json({ error: e.message })
|
||||
}
|
||||
|
@ -8,9 +8,6 @@ const expressAccessor = new ExpressAccessors<JavGameWithoutId, JavGame>(
|
||||
)
|
||||
|
||||
export const javGameListGet = expressAccessor.listGet()
|
||||
|
||||
export const javGameGet = expressAccessor.get()
|
||||
|
||||
export const javGameAdd = expressAccessor.add()
|
||||
|
||||
export const javGameSet = expressAccessor.set()
|
||||
|
@ -12,13 +12,10 @@ const expressAccessor = new ExpressAccessors<PreVolunteerWithoutId, PreVolunteer
|
||||
)
|
||||
|
||||
export const preVolunteerListGet = expressAccessor.listGet()
|
||||
|
||||
export const preVolunteerGet = expressAccessor.get()
|
||||
|
||||
export const preVolunteerAdd = expressAccessor.add()
|
||||
|
||||
export const preVolunteerSet = expressAccessor.set()
|
||||
|
||||
export const preVolunteerCountGet = expressAccessor.customGet(
|
||||
export const preVolunteerCountGet = expressAccessor.get(
|
||||
(list?: PreVolunteer[]) => (list && list.length) || 0
|
||||
)
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Request } from "express"
|
||||
import _ from "lodash"
|
||||
import bcrypt from "bcrypt"
|
||||
import sgMail from "@sendgrid/mail"
|
||||
|
||||
import ExpressAccessors from "./expressAccessors"
|
||||
import ExpressAccessors, { RequestBody } from "./expressAccessors"
|
||||
import { Volunteer, VolunteerWithoutId, translationVolunteer } from "../../services/volunteers"
|
||||
import { canonicalEmail } from "../../utils/standardization"
|
||||
import { getJwt } from "../secure"
|
||||
@ -13,38 +14,98 @@ 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.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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
const password = body.password || ""
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
const password = body.password || ""
|
||||
const password1Match = await bcrypt.compare(
|
||||
password,
|
||||
volunteer.password1.replace(/^\$2y/, "$2a")
|
||||
)
|
||||
if (!password1Match) {
|
||||
const password2Match = await bcrypt.compare(
|
||||
password,
|
||||
volunteer.password.replace(/^\$2y/, "$2a")
|
||||
volunteer.password2.replace(/^\$2y/, "$2a")
|
||||
)
|
||||
if (!passwordMatch) {
|
||||
if (!password2Match) {
|
||||
throw Error("Mauvais mot de passe pour cet email")
|
||||
}
|
||||
|
||||
const jwt = await getJwt(email)
|
||||
|
||||
return {
|
||||
firstname: volunteer.firstname,
|
||||
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) => {
|
||||
const volunteer = getByEmail(list, body.email)
|
||||
if (!volunteer) {
|
||||
throw Error("Il n'y a aucun bénévole avec cet email")
|
||||
}
|
||||
const newVolunteer = _.cloneDeep(volunteer)
|
||||
|
||||
const now = +new Date()
|
||||
const timeSinceLastSent = now - lastForgot[volunteer.id]
|
||||
if (timeSinceLastSent < 2 * 60 * 1000) {
|
||||
throw Error(
|
||||
"Un email t'a déjà été envoyé avec un nouveau mot de passe. Es-tu sûr qu'il n'est pas dans tes spams ?"
|
||||
)
|
||||
}
|
||||
lastForgot[volunteer.id] = now
|
||||
|
||||
const password = generatePassword()
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
newVolunteer.password2 = passwordHash
|
||||
|
||||
await sendForgetEmail(volunteer.email, password)
|
||||
|
||||
return {
|
||||
toDatabase: newVolunteer,
|
||||
toCaller: {
|
||||
message: `Un nouveau mot de passe t'a été envoyé par email. Regarde bien dans tes spams, ils pourrait y être :/`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function getByEmail(list: Volunteer[], rawEmail: string): Volunteer | undefined {
|
||||
const email = canonicalEmail(rawEmail || "")
|
||||
const volunteer = list.find((v) => canonicalEmail(v.email) === email)
|
||||
return volunteer
|
||||
}
|
||||
|
||||
function generatePassword(): string {
|
||||
const s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return Array(16)
|
||||
.join()
|
||||
.split(",")
|
||||
.map(() => s.charAt(Math.floor(Math.random() * s.length)))
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function sendForgetEmail(email: string, password: string): Promise<void> {
|
||||
const apiKey = process.env.SENDGRID_API_KEY || ""
|
||||
if (__DEV__ || apiKey === "") {
|
||||
console.error(`Fake sending forget email to ${email} with password ${password}`)
|
||||
} else {
|
||||
sgMail.setApiKey(apiKey)
|
||||
const msg = {
|
||||
to: email,
|
||||
from: "contact@parisestludique.fr",
|
||||
subject: "Nouveau mot de passe pour le site de Paris est Ludique",
|
||||
text: `Voici le nouveau mot de passe : ${password}\nL'ancien fonctionne encore, si tu t'en rappelles.`,
|
||||
html: `Voici le nouveau mot de passe : <strong>${password}</strong><br />L'ancien fonctionne encore, si tu t'en rappelles.`,
|
||||
}
|
||||
await sgMail.send(msg)
|
||||
}
|
||||
}
|
||||
|
@ -8,9 +8,6 @@ const expressAccessor = new ExpressAccessors<WishWithoutId, Wish>(
|
||||
)
|
||||
|
||||
export const wishListGet = expressAccessor.listGet()
|
||||
|
||||
export const wishGet = expressAccessor.get()
|
||||
|
||||
export const wishAdd = expressAccessor.add()
|
||||
|
||||
export const wishSet = expressAccessor.set()
|
||||
|
@ -19,7 +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, volunteerLogin } from "./gsheets/volunteers"
|
||||
import { volunteerGet, volunteerSet, volunteerLogin, volunteerForgot } from "./gsheets/volunteers"
|
||||
import config from "../config"
|
||||
|
||||
const app = express()
|
||||
@ -56,6 +56,7 @@ app.post("/WishAdd", wishAdd)
|
||||
app.post("/PreVolunteerAdd", preVolunteerAdd)
|
||||
app.get("/PreVolunteerCountGet", preVolunteerCountGet)
|
||||
app.post("/VolunteerLogin", volunteerLogin)
|
||||
app.post("/VolunteerForgot", volunteerForgot)
|
||||
|
||||
// Secured APIs
|
||||
app.get("/VolunteerGet", secure as RequestHandler, volunteerGet)
|
||||
|
@ -7,12 +7,15 @@ export type ElementWithId = unknown & { id: number }
|
||||
|
||||
export type ElementTranslation<Element> = { [k in keyof Element]: string }
|
||||
|
||||
export default function getServiceAccessors<
|
||||
export default class ServiceAccessors<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ElementNoId extends object,
|
||||
Element extends ElementNoId & ElementWithId
|
||||
>(elementName: string): any {
|
||||
function get(): (id: number) => Promise<{
|
||||
> {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(readonly elementName: string) {}
|
||||
|
||||
get(): (id: number) => Promise<{
|
||||
data?: Element
|
||||
error?: Error
|
||||
}> {
|
||||
@ -22,7 +25,7 @@ export default function getServiceAccessors<
|
||||
}
|
||||
return async (id: number): Promise<ElementGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.get(`${config.API_URL}/${elementName}Get`, {
|
||||
const { data } = await axios.get(`${config.API_URL}/${this.elementName}Get`, {
|
||||
...axiosConfig,
|
||||
params: { id },
|
||||
})
|
||||
@ -33,7 +36,7 @@ export default function getServiceAccessors<
|
||||
}
|
||||
}
|
||||
|
||||
function listGet(): () => Promise<{
|
||||
listGet(): () => Promise<{
|
||||
data?: Element[]
|
||||
error?: Error
|
||||
}> {
|
||||
@ -44,7 +47,7 @@ export default function getServiceAccessors<
|
||||
return async (): Promise<ElementListGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${config.API_URL}/${elementName}ListGet`,
|
||||
`${config.API_URL}/${this.elementName}ListGet`,
|
||||
axiosConfig
|
||||
)
|
||||
return { data }
|
||||
@ -55,7 +58,7 @@ export default function getServiceAccessors<
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
function add(): (volunteerWithoutId: ElementNoId) => Promise<{
|
||||
add(): (volunteerWithoutId: ElementNoId) => Promise<{
|
||||
data?: Element
|
||||
error?: Error
|
||||
}> {
|
||||
@ -66,7 +69,7 @@ export default function getServiceAccessors<
|
||||
return async (volunteerWithoutId: ElementNoId): Promise<ElementGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${config.API_URL}/${elementName}Add`,
|
||||
`${config.API_URL}/${this.elementName}Add`,
|
||||
volunteerWithoutId,
|
||||
axiosConfig
|
||||
)
|
||||
@ -77,7 +80,7 @@ export default function getServiceAccessors<
|
||||
}
|
||||
}
|
||||
|
||||
function set(): (volunteer: Element) => Promise<{
|
||||
set(): (volunteer: Element) => Promise<{
|
||||
data?: Element
|
||||
error?: Error
|
||||
}> {
|
||||
@ -88,7 +91,7 @@ export default function getServiceAccessors<
|
||||
return async (volunteer: Element): Promise<ElementGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${config.API_URL}/${elementName}Set`,
|
||||
`${config.API_URL}/${this.elementName}Set`,
|
||||
volunteer,
|
||||
axiosConfig
|
||||
)
|
||||
@ -99,7 +102,7 @@ export default function getServiceAccessors<
|
||||
}
|
||||
}
|
||||
|
||||
function countGet(): () => Promise<{
|
||||
countGet(): () => Promise<{
|
||||
data?: number
|
||||
error?: Error
|
||||
}> {
|
||||
@ -110,7 +113,7 @@ export default function getServiceAccessors<
|
||||
return async (): Promise<ElementCountGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${config.API_URL}/${elementName}CountGet`,
|
||||
`${config.API_URL}/${this.elementName}CountGet`,
|
||||
axiosConfig
|
||||
)
|
||||
return { data }
|
||||
@ -120,18 +123,18 @@ export default function getServiceAccessors<
|
||||
}
|
||||
}
|
||||
|
||||
function customPost(apiName: string): (params: any) => Promise<{
|
||||
data?: Element
|
||||
customPost(apiName: string): (params: any) => Promise<{
|
||||
data?: any
|
||||
error?: Error
|
||||
}> {
|
||||
interface ElementGetResponse {
|
||||
data?: Element
|
||||
data?: any
|
||||
error?: Error
|
||||
}
|
||||
return async (params: any): Promise<ElementGetResponse> => {
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${config.API_URL}/${elementName}${apiName}`,
|
||||
`${config.API_URL}/${this.elementName}${apiName}`,
|
||||
params,
|
||||
axiosConfig
|
||||
)
|
||||
@ -144,6 +147,4 @@ export default function getServiceAccessors<
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { listGet, get, set, add, countGet, customPost }
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import getServiceAccessors from "./accessors"
|
||||
import ServiceAccessors from "./accessors"
|
||||
|
||||
export class JavGame {
|
||||
id = 0
|
||||
@ -54,9 +54,9 @@ const elementName = "JavGame"
|
||||
|
||||
export type JavGameWithoutId = Omit<JavGame, "id">
|
||||
|
||||
const { listGet, get, set, add } = getServiceAccessors<JavGameWithoutId, JavGame>(elementName)
|
||||
const serviceAccessors = new ServiceAccessors<JavGameWithoutId, JavGame>(elementName)
|
||||
|
||||
export const javGameListGet = listGet()
|
||||
export const javGameGet = get()
|
||||
export const javGameAdd = add()
|
||||
export const javGameSet = set()
|
||||
export const javGameListGet = serviceAccessors.listGet()
|
||||
export const javGameGet = serviceAccessors.get()
|
||||
export const javGameAdd = serviceAccessors.add()
|
||||
export const javGameSet = serviceAccessors.set()
|
||||
|
@ -1,4 +1,4 @@
|
||||
import getServiceAccessors from "./accessors"
|
||||
import ServiceAccessors from "./accessors"
|
||||
|
||||
export class PreVolunteer {
|
||||
id = 0
|
||||
@ -34,13 +34,10 @@ export const passwordMinLength = 4
|
||||
|
||||
export type PreVolunteerWithoutId = Omit<PreVolunteer, "id">
|
||||
|
||||
const { listGet, get, set, add, countGet } = getServiceAccessors<
|
||||
PreVolunteerWithoutId,
|
||||
PreVolunteer
|
||||
>(elementName)
|
||||
const serviceAccessors = new ServiceAccessors<PreVolunteerWithoutId, PreVolunteer>(elementName)
|
||||
|
||||
export const preVolunteerListGet = listGet()
|
||||
export const preVolunteerGet = get()
|
||||
export const preVolunteerAdd = add()
|
||||
export const preVolunteerSet = set()
|
||||
export const preVolunteerCountGet = countGet()
|
||||
export const preVolunteerListGet = serviceAccessors.listGet()
|
||||
export const preVolunteerGet = serviceAccessors.get()
|
||||
export const preVolunteerAdd = serviceAccessors.add()
|
||||
export const preVolunteerSet = serviceAccessors.set()
|
||||
export const preVolunteerCountGet = serviceAccessors.countGet()
|
||||
|
@ -1,4 +1,4 @@
|
||||
import getServiceAccessors from "./accessors"
|
||||
import ServiceAccessors from "./accessors"
|
||||
|
||||
export class Volunteer {
|
||||
id = 0
|
||||
@ -21,11 +21,11 @@ export class Volunteer {
|
||||
|
||||
active = 0
|
||||
|
||||
comment = ""
|
||||
created = new Date()
|
||||
|
||||
timestamp = new Date()
|
||||
password1 = ""
|
||||
|
||||
password = ""
|
||||
password2 = ""
|
||||
}
|
||||
|
||||
export const translationVolunteer: { [k in keyof Volunteer]: string } = {
|
||||
@ -39,9 +39,9 @@ export const translationVolunteer: { [k in keyof Volunteer]: string } = {
|
||||
adult: "majeur",
|
||||
privileges: "privilege",
|
||||
active: "actif",
|
||||
comment: "commentaire",
|
||||
timestamp: "horodatage",
|
||||
password: "passe",
|
||||
created: "creation",
|
||||
password1: "passe1",
|
||||
password2: "passe2",
|
||||
}
|
||||
|
||||
const elementName = "Volunteer"
|
||||
@ -52,16 +52,20 @@ export const passwordMinLength = 4
|
||||
|
||||
export type VolunteerWithoutId = Omit<Volunteer, "id">
|
||||
|
||||
const accessors = getServiceAccessors<VolunteerWithoutId, Volunteer>(elementName)
|
||||
const { listGet, get, set, add } = accessors
|
||||
const serviceAccessors = new ServiceAccessors<VolunteerWithoutId, Volunteer>(elementName)
|
||||
|
||||
export const volunteerListGet = listGet()
|
||||
export const volunteerGet = get()
|
||||
export const volunteerAdd = add()
|
||||
export const volunteerSet = set()
|
||||
export const volunteerListGet = serviceAccessors.listGet()
|
||||
export const volunteerGet = serviceAccessors.get()
|
||||
export const volunteerAdd = serviceAccessors.add()
|
||||
export const volunteerSet = serviceAccessors.set()
|
||||
|
||||
export interface VolunteerLogin {
|
||||
firstname: string
|
||||
jwt: string
|
||||
}
|
||||
export const volunteerLogin = accessors.customPost("Login")
|
||||
export const volunteerLogin = serviceAccessors.customPost("Login")
|
||||
|
||||
export interface VolunteerForgot {
|
||||
message: string
|
||||
}
|
||||
export const volunteerForgot = serviceAccessors.customPost("Forgot")
|
||||
|
@ -1,4 +1,4 @@
|
||||
import getServiceAccessors from "./accessors"
|
||||
import ServiceAccessors from "./accessors"
|
||||
|
||||
export class Wish {
|
||||
id = 0
|
||||
@ -27,9 +27,9 @@ const elementName = "Wish"
|
||||
|
||||
export type WishWithoutId = Omit<Wish, "id">
|
||||
|
||||
const { listGet, get, set, add } = getServiceAccessors<WishWithoutId, Wish>(elementName)
|
||||
const serviceAccessors = new ServiceAccessors<WishWithoutId, Wish>(elementName)
|
||||
|
||||
export const wishListGet = listGet()
|
||||
export const wishGet = get()
|
||||
export const wishAdd = add()
|
||||
export const wishSet = set()
|
||||
export const wishListGet = serviceAccessors.listGet()
|
||||
export const wishGet = serviceAccessors.get()
|
||||
export const wishAdd = serviceAccessors.add()
|
||||
export const wishSet = serviceAccessors.set()
|
||||
|
@ -23,9 +23,9 @@ const mockData: Volunteer = {
|
||||
adult: 1,
|
||||
privileges: 0,
|
||||
active: 0,
|
||||
comment: "",
|
||||
timestamp: new Date(0),
|
||||
password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
|
||||
created: new Date(0),
|
||||
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
|
||||
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
|
||||
}
|
||||
const { id } = mockData
|
||||
const mockError = "Oops! Something went wrong."
|
||||
|
@ -25,9 +25,9 @@ const mockData: Volunteer[] = [
|
||||
adult: 1,
|
||||
privileges: 0,
|
||||
active: 0,
|
||||
comment: "",
|
||||
timestamp: new Date(0),
|
||||
password: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
|
||||
created: new Date(0),
|
||||
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
|
||||
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPlobkyRrNIal8ASimSjNj4SR.9O",
|
||||
},
|
||||
]
|
||||
const mockError = "Oops! Something went wrong."
|
||||
|
@ -9,6 +9,7 @@ import volunteerAdd from "./volunteerAdd"
|
||||
import volunteerList from "./volunteerList"
|
||||
import volunteerSet from "./volunteerSet"
|
||||
import volunteerLogin from "./volunteerLogin"
|
||||
import volunteerForgot from "./volunteerForgot"
|
||||
import preVolunteerAdd from "./preVolunteerAdd"
|
||||
import preVolunteerCount from "./preVolunteerCount"
|
||||
|
||||
@ -23,6 +24,7 @@ export default (history: History) => ({
|
||||
volunteerList,
|
||||
volunteerSet,
|
||||
volunteerLogin,
|
||||
volunteerForgot,
|
||||
preVolunteerAdd,
|
||||
preVolunteerCount,
|
||||
router: connectRouter(history) as any,
|
||||
|
@ -51,14 +51,10 @@ export function elementFetch<Element>(
|
||||
|
||||
if (error) {
|
||||
dispatch(getFailure(error.message))
|
||||
if (errorMessage) {
|
||||
errorMessage(error)
|
||||
}
|
||||
errorMessage?.(error)
|
||||
} else {
|
||||
dispatch(getSuccess(data as Element))
|
||||
if (successMessage) {
|
||||
successMessage(data as Element)
|
||||
}
|
||||
successMessage?.(data as Element)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,12 +67,8 @@ export function elementAddFetch<Element>(
|
||||
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 */
|
||||
}
|
||||
errorMessage?: (error: Error) => void,
|
||||
successMessage?: () => void
|
||||
): (volunteerWithoutId: Omit<Element, "id">) => AppThunk {
|
||||
return (volunteerWithoutId: Omit<Element, "id">): AppThunk =>
|
||||
async (dispatch) => {
|
||||
@ -86,10 +78,10 @@ export function elementAddFetch<Element>(
|
||||
|
||||
if (error) {
|
||||
dispatch(getFailure(error.message))
|
||||
errorMessage(error)
|
||||
errorMessage?.(error)
|
||||
} else {
|
||||
dispatch(getSuccess(data as Element))
|
||||
successMessage()
|
||||
successMessage?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -102,12 +94,8 @@ export function elementListFetch<Element>(
|
||||
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 */
|
||||
}
|
||||
errorMessage?: (error: Error) => void,
|
||||
successMessage?: () => void
|
||||
): () => AppThunk {
|
||||
return (): AppThunk => async (dispatch) => {
|
||||
dispatch(getRequesting())
|
||||
@ -116,10 +104,10 @@ export function elementListFetch<Element>(
|
||||
|
||||
if (error) {
|
||||
dispatch(getFailure(error.message))
|
||||
errorMessage(error)
|
||||
errorMessage?.(error)
|
||||
} else {
|
||||
dispatch(getSuccess(data as Element[]))
|
||||
successMessage()
|
||||
successMessage?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,12 +120,8 @@ export function elementSet<Element>(
|
||||
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 */
|
||||
}
|
||||
errorMessage?: (error: Error) => void,
|
||||
successMessage?: () => void
|
||||
): (element: Element) => AppThunk {
|
||||
return (element: Element): AppThunk =>
|
||||
async (dispatch) => {
|
||||
@ -147,10 +131,10 @@ export function elementSet<Element>(
|
||||
|
||||
if (error) {
|
||||
dispatch(getFailure(error.message))
|
||||
errorMessage(error)
|
||||
errorMessage?.(error)
|
||||
} else {
|
||||
dispatch(getSuccess(data as Element))
|
||||
successMessage()
|
||||
successMessage?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -163,12 +147,8 @@ export function elementValueFetch<Element>(
|
||||
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 */
|
||||
}
|
||||
errorMessage?: (error: Error) => void,
|
||||
successMessage?: () => void
|
||||
): () => AppThunk {
|
||||
return (): AppThunk => async (dispatch) => {
|
||||
dispatch(getRequesting())
|
||||
@ -177,10 +157,10 @@ export function elementValueFetch<Element>(
|
||||
|
||||
if (error) {
|
||||
dispatch(getFailure(error.message))
|
||||
errorMessage(error)
|
||||
errorMessage?.(error)
|
||||
} else {
|
||||
dispatch(getSuccess(data as Element))
|
||||
successMessage()
|
||||
successMessage?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
38
src/store/volunteerForgot.ts
Normal file
38
src/store/volunteerForgot.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
|
||||
import { StateRequest, elementFetch } from "./utils"
|
||||
import { VolunteerForgot, volunteerForgot } from "../services/volunteers"
|
||||
|
||||
type StateVolunteer = { entity?: VolunteerForgot } & StateRequest
|
||||
|
||||
export const initialState: StateVolunteer = {
|
||||
readyStatus: "idle",
|
||||
}
|
||||
|
||||
const volunteerForgotSlice = createSlice({
|
||||
name: "volunteerForgot",
|
||||
initialState,
|
||||
reducers: {
|
||||
getRequesting: (_) => ({
|
||||
readyStatus: "request",
|
||||
}),
|
||||
getSuccess: (_, { payload }: PayloadAction<VolunteerForgot>) => ({
|
||||
readyStatus: "success",
|
||||
entity: payload,
|
||||
}),
|
||||
getFailure: (_, { payload }: PayloadAction<string>) => ({
|
||||
readyStatus: "failure",
|
||||
error: payload,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default volunteerForgotSlice.reducer
|
||||
export const { getRequesting, getSuccess, getFailure } = volunteerForgotSlice.actions
|
||||
|
||||
export const fetchVolunteerForgot = elementFetch(
|
||||
volunteerForgot,
|
||||
getRequesting,
|
||||
getSuccess,
|
||||
getFailure
|
||||
)
|
@ -11,7 +11,7 @@ export const initialState: StateVolunteer = {
|
||||
}
|
||||
|
||||
const volunteerLoginSlice = createSlice({
|
||||
name: "volunteer",
|
||||
name: "volunteerLogin",
|
||||
initialState,
|
||||
reducers: {
|
||||
getRequesting: (_) => ({
|
||||
|
2
src/types/index.d.ts
vendored
2
src/types/index.d.ts
vendored
@ -3,6 +3,7 @@ declare const __SERVER__: boolean
|
||||
declare const __DEV__: boolean
|
||||
declare const __LOCAL__: boolean
|
||||
declare const __TEST__: boolean
|
||||
declare const __SENDGRID_API_KEY__: string
|
||||
|
||||
declare module "*.svg"
|
||||
declare module "*.gif"
|
||||
@ -20,6 +21,7 @@ declare namespace NodeJS {
|
||||
__DEV__: boolean
|
||||
__LOCAL__: boolean
|
||||
__TEST__: boolean
|
||||
__SENDGRID_API_KEY__: string
|
||||
$RefreshReg$: () => void
|
||||
$RefreshSig$$: () => void
|
||||
}
|
||||
|
23
yarn.lock
23
yarn.lock
@ -1334,6 +1334,29 @@
|
||||
redux-thunk "^2.3.0"
|
||||
reselect "^4.0.0"
|
||||
|
||||
"@sendgrid/client@^7.6.0":
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@sendgrid/client/-/client-7.6.0.tgz#f90cb8759c96e1d90224f29ad98f8fdc2be287f3"
|
||||
integrity sha512-cpBVZKLlMTO+vpE18krTixubYmZa98oTbLkqBDuTiA3zRkW+urrxg7pDR24TkI35Mid0Zru8jDHwnOiqrXu0TA==
|
||||
dependencies:
|
||||
"@sendgrid/helpers" "^7.6.0"
|
||||
axios "^0.21.4"
|
||||
|
||||
"@sendgrid/helpers@^7.6.0":
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@sendgrid/helpers/-/helpers-7.6.0.tgz#b381bfab391bcd66c771811b22bb6bb2d5c1dfc6"
|
||||
integrity sha512-0uWD+HSXLl4Z/X3cN+UMQC20RE7xwAACgppnfjDyvKG0KvJcUgDGz7HDdQkiMUdcVWfmyk6zKSg7XKfKzBjTwA==
|
||||
dependencies:
|
||||
deepmerge "^4.2.2"
|
||||
|
||||
"@sendgrid/mail@^7.6.0":
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@sendgrid/mail/-/mail-7.6.0.tgz#e74ee30110527feab5d3b83d68af0cd94537f6d2"
|
||||
integrity sha512-0KdaSZzflJD/vUAZjB3ALBIuaVGoLq22hrb2fvQXZHRepU/yhRNlEOqrr05MfKBnKskzq1blnD1J0fHxiwaolw==
|
||||
dependencies:
|
||||
"@sendgrid/client" "^7.6.0"
|
||||
"@sendgrid/helpers" "^7.6.0"
|
||||
|
||||
"@sindresorhus/is@^0.14.0":
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
|
||||
|
Loading…
x
Reference in New Issue
Block a user