Unverified Commit 402519da authored by pom421's avatar pom421 Committed by GitHub
Browse files

feat: add forgot and reset password pages and api (#140)

* Send email in case of forgot password

* feat: build html for email with a JWT token

* feat: new page and api for reset password of a user

* feat: remove zone for create an account
User can't create an account by himself. He needs to contact the team by email.

* feat: the start script now runs migrate latest first

* feat: add Matomo tracker for forget/reset password

* feat: add a test for forgot-password page

* refactor: use of getByRole for improving stability of test over time
It's better I think, since the Alert component has the alert Aria role

* fix: knex configuration is now packed in docker

* fix: need to call getInitialProps for pages
Necessary to access API_URL variable.
parent d038f6dd
Pipeline #103786 passed with stage
in 47 seconds
# Environment
NODE_ENV=development
# Debug SQL queries
DEBUG=knex:query
DEBUG_MODE=true
# API endpoints
API_URL=http://localhost:3000/api
# DB parameters ----------
POSTGRES_SSL=false # BE CAREFUL WITH THE SSL MODE !! In local, set it to false, for Azure hosted, set it to true
DATABASE_URL=psql://medle:jJFWsfW5ePbN7J@localhost:5436/medle
# JSON Web Token
JWT_SECRET=Hq!sB$eTeWj63H
# Test date for testing purpose
#TEST_CURRENT_DATE=10/09/2019
# Matomo
MATOMO_SITE_ID=16
#MATOMO_URL=https://matomo.fabrique.social.gouv.fr
# Sentry
#SENTRY_DSN=https://75f34cada95a4c189d69bc05e8aa324f@sentry.fabrique.social.gouv.fr/29
......@@ -17,3 +17,9 @@ JWT_SECRET=TByQqGjC2wBfHt
# Test variables
#TEST_CURRENT_DATE=10/09/2019
MAIL_HOST=smtp.tipimail.com
MAIL_PORT=587
MAIL_USERNAME=username from tipimail dashboard
MAIL_PASSWORD=password from tipimail dashboard
MAIL_FROM=Médlé <noreply@fabrique.social.gouv.fr>
......@@ -10,6 +10,8 @@ RUN yarn --frozen-lockfile
COPY .next/ /app/.next/
COPY public/ /app/public/
COPY next.config.js /app/
COPY knexfile.js /app/
COPY src/knex /app/src/knex/
USER node
......
......@@ -6,17 +6,19 @@ MedLé is a platform for french hospitals to declare their medico-legal activity
> Since april 2021, Medlé is deployed by Gitlab on Kubernetes. The instructions with docker-compose are kept for reference.
First, install git, yarn, docker, docker-compose with [brew](https://brew.sh/) on Mac OS.
As a prerequisite, install git, yarn, docker, docker-compose with [brew](https://brew.sh/) on Mac OS.
Then, run the containers with `docker-compose`.
First, you need a database. A docker container is made for that.
`docker-compose up --build -d`
You only have to run the db container (and not the app): `docker-compose up --build -d db`
Then, the DB is exposed on port 5434 and the app is accessible on port 80.
Connect to the DB via a Postgresql client. For start, there a user with the name `user` and the password `password`.
Next, you have to create the database named medle and the app user.
At start, there is a user with the name `user` and the password `password`.
`docker exec -it medle_db_1 psql -U user`
So, connect to the db container : `psql postgres://user:password@localhost:5436`
Alternatively, if you don't have `psql` CLI, you can use : `docker exec -it medle_db_1 psql -U user`
Create the medle database and the user medle with the password of your choice.
......@@ -26,61 +28,74 @@ create database medle with owner medle encoding 'UTF8';
alter user medle with superuser;
```
Alternatively, if you have already a Postgres user :
Alternatively, if you have create in other way a Postgres user :
```sql
create database medle encoding 'UTF8';
grant all privileges on database medle to other-user
```
Then create/modify an `.env` file in the root of the project (see `.env.dev` as a reference).
Set some environment variables (see next paragraph how) for accessing this Postgres server :
For DATABASE_URL, be sure to use the matching password you have just created before.
```js
NODE_ENV=development
For example :
```
DATABASE_URL=psql://medle:jJFWsfW5ePbN7J@db:5432/medle
POSTGRES_SSL=false
```
API_URL=http://localhost/api
Then run the container app :
# for container app usage
POSTGRES_SSL=false
DATABASE_URL=psql://medle:jJFWsfW5ePbN7J@db:5432/medle
`docker-compose up --build -d app`
# JWT
JWT_SECRET=NEGLaRS3n9JHuY
This will run the HTTP server and run automatically the Knex migrations to create the tables in DB.
# Test variables
#TEST_CURRENT_DATE=10/09/2019
```
Optionnaly, in test environements, you may want to populate some data. Run Knex seeds for that :
Then rerun the container app, to force usage of this `.env` file.
`docker-compose up --build -d app`
`docker-compose exec app yarn seed:run:dev`
This is supposed to work now!
This is supposed to work now!
### 🎛️ Env vars
## 🏗️ Development usage
As previously said, you need to set `process.env` variables.
First, you need a database. A docker container is made for that.
Usually, the easiest solution to set the variables is to populate the `.env` file at the project's root.
You only have to run the db container (and not the app): `docker-compose up --build -d db`
A blueprint of `env` can be seen with the file `.env.sample`.
Next, you have to create the database named medle and the app user.
The variables are :
So, connect to the db container : `psql postgres://user:password@localhost:5436`
- NODE_ENV=development or production
- API_URL URL of the api (in local, it's http://localhost:3000/api)
- POSTGRES_SSL mode to connect to Postgres (false in local, true for for Azure hosted)
- DATABASE_URL URL of Postgres DB
- JWT_SECRET the secret for generating JWT tokens
- MATOMO_SITE_ID site id on piwik instance
- MATOMO_URL URL to your piwik instance
- SENTRY_DSN DSN of your sentry project
- MAIL_HOST SMTP host for mailing
- MAIL_PORT port for SMTP server
- MAIL_USERNAME username of SMTP server account
- MAIL_PASSWORD password of SMTP server account
- MAIL_FROM string used in from email
```sql
create user medle with encrypted password 'test';
create database medle with owner medle encoding 'UTF8';
alter user medle with superuser;
```
Besides, in some cases you may want to set :
- DEBUG used to debug Knex (ex: knex:query to show SQL queries)
- DEBUG_MODE set to true to console.debug
- TEST_CURRENT_DATE useful to set a date in the past and have consistent result for tests using dates
- E2E_JEST_DATABASE_URL URL of Postgres DB for E2E tests
Now, you will build the tables of the medle db : `yarn migrate:latest`.
## 🏗️ Development usage
In local development usage, you don't have to use docker-compose for all the ops stuff.
For example, you can manually apply the last migration in db with : `yarn migrate:latest`.
And a minimal set of data : `yarn seed:run:dev`
Second, the front office in Next. With `yarn dev`, you will benefit from the hot reload offered by Next.
Just copy the file `.env.dev` given as reference and named it as `.env` to be used by dotenv.
Second, the front office in Next. With `yarn dev`, you will benefit from the hot reload/fast refresh offered by Next.
With this configuration, the API will run on `localhost:3000` and the database will be a Docker container running on your machine.
......@@ -111,28 +126,20 @@ To initiate a migration, the easiest way is to use `migrate:make` script in pack
yarn knex migrate:make my-name-of-migration
```
Modify it accordingly to the business needs.
Modify it accordingly to your business needs.
To apply it, use `migrate:latest` script in package.json.
In development mode
`yarn migrate:latest`
In development mode : `yarn migrate:latest`
In production mode
`sudo docker-compose exec app yarn migrate:latest`
So on another platform like production, the pattern is:
If you're not happy with the migration done, you can rollback with the script `migrate:rollback` in package.json.
```sh
git pull
sudo docker-compose up --build -d
Alternatively, if you are in VM mode :
```bash
sudo docker-compose exec app yarn migrate:latest
sudo docker-compose exec app yarn migrate:rollback
```
If you're not happy with the migration done, you can rollback with the script `migrate:rollback` in package.json.
`sudo docker-compose exec app yarn migrate:rollback`
On the development platform (local or staging environment), you may need to populate table in JS (you may yet do it directly with SQL client too).
Make a new seed file, in src/knex/seeds/development or src/knex/seeds/staging, then:
......@@ -140,7 +147,7 @@ Make a new seed file, in src/knex/seeds/development or src/knex/seeds/staging, t
In development mode, for applying the development seeds:
`yarn seed:run:dev`
In production mode, for applying the staging seeds:
In VM mode, for applying the staging seeds:
`sudo docker-compose exec app yarn seed:run:staging`
Never, never, never <strike>give up</strike> apply this seeds in real production environment (under penalty of fetching a Postgres backup in Azure 😭).
......@@ -200,6 +207,8 @@ You need to use the commit lint convention for commit message.
I.e, you must specify a type in prefix position, for the message using one of the following:
NB : The Gitlab CI configuration uses semantic release. If you want to have automatically a new release, the commit must begin with `feat:` or `fix:`.
- build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
- ci: Changes to our CI configuration files and scripts (example scopes: Circle, BrowserStack, SauceLabs)
- docs: Documentation only changes
......
......@@ -7,7 +7,7 @@
"dev": "node -r dotenv/config node_modules/next/dist/bin/next dev",
"build": "NODE_ENV=production node -r dotenv/config node_modules/next/dist/bin/next build",
"debug": "NODE_OPTIONS='--inspect' node -r dotenv/config node_modules/next/dist/bin/next dev",
"start": "node -r dotenv/config node_modules/next/dist/bin/next start",
"start": "yarn migrate:latest && node -r dotenv/config node_modules/next/dist/bin/next start",
"test": "node -r dotenv/config node_modules/jest-cli/bin/jest --config ./src/__tests__/jest.config.js",
"test:e2e": "node -r dotenv/config node_modules/jest-cli/bin/jest --config ./src/__e2e__/jest.config.js --runInBand",
"lint": "eslint ./src",
......@@ -44,6 +44,7 @@
"next": "^10.0.9",
"next-connect": "^0.9.1",
"next-cookies": "^2.0.3",
"nodemailer": "^6.5.0",
"pg": "^8.5.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
......
POST http://localhost:3000/api/forgot-password
Content-Type: application/json
{
"email": "marty.macfly@gmail.com"
}
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import faker from "faker"
import { rest } from "msw"
import { setupServer } from "msw/node"
import React from "react"
import { API_URL, FORGOT_PWD_ENDPOINT } from "../../../config"
import ForgotPasswordPage from "../../../pages/forgot-password"
const originalWindow = { ...window }
const originalConsoleError = { ...console.error }
const notFoundEmail = faker.internet.email()
const foundEmail = "xx" + notFoundEmail // Ensure to have consistently a different password than notFoundEmail.
const url = `${API_URL}${FORGOT_PWD_ENDPOINT}`
const server = setupServer(
rest.post(url, (req, res, ctx) => {
if (req.body?.email === notFoundEmail) {
return res(ctx.status(404), ctx.json({ message: `User with email ${notFoundEmail} doesn't exist.`, status: 404 }))
}
return res(ctx.status(200), ctx.json({}))
}),
)
beforeAll(() => {
server.listen()
// Disable window._paq.push used by Matomo.
if (!window?._paq?.push) {
window._paq = {
push: jest.fn(),
}
}
console.error = () => {}
})
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})
afterAll(() => {
server.close()
// eslint-disable-next-line no-global-assign
window = originalWindow
console.error = originalConsoleError
})
it("should render ForgotPasswordPage", () => {
render(<ForgotPasswordPage />)
const title = screen.queryByText(/Vous avez oublié votre mot de passe/i)
expect(title).toBeInTheDocument()
})
it("should show an error if no email is given", () => {
render(<ForgotPasswordPage />)
userEvent.type(screen.getByLabelText(/courriel/i), "")
userEvent.click(screen.getByRole("button", { name: /envoyer un email/i }))
expect(screen.getByRole("alert")).toHaveTextContent(/Veuillez renseigner le champ Courriel/i)
})
it("should render error if no user with this email is found in db", async () => {
render(<ForgotPasswordPage />)
userEvent.type(screen.getByLabelText(/courriel/i), notFoundEmail)
userEvent.click(screen.getByRole("button", { name: /envoyer un email/i }))
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/Le courriel ne semble pas exister/i)
})
})
it("should render correctly if user email is found", async () => {
render(<ForgotPasswordPage />)
userEvent.type(screen.getByLabelText(/courriel/i), foundEmail)
userEvent.click(screen.getByRole("button", { name: /envoyer un email/i }))
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/Un courriel vous a été envoyé/i)
})
})
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import faker from "faker"
import { rest } from "msw"
import { setupServer } from "msw/node"
import * as nextRouter from "next/router"
import React from "react"
import { API_URL, RESET_PWD_ENDPOINT } from "../../../config"
import ResetPasswordPage from "../../../pages/reset-password"
import { generateToken } from "../../../utils/jwt"
import { mockRouterImplementation } from "../../../utils/test-utils"
const originalWindow = { ...window }
const originalConsoleError = { ...console.error }
jest.spyOn(nextRouter, "useRouter")
const user = { email: faker.internet.email() }
const correctLoginToken = generateToken(user, { timeout: "1H" })
const incorrectLoginToken = generateToken(user, { timeout: "100ms" }) // Token only valid for 100 ms.
beforeAll(() => {
/* eslint-disable no-import-assign*/
nextRouter.useRouter.mockImplementation(() => ({
...mockRouterImplementation,
query: { loginToken: correctLoginToken },
}))
})
afterAll(() => {
nextRouter.useRouter.mockRestore()
})
const url = `${API_URL}${RESET_PWD_ENDPOINT}`
const server = setupServer(
rest.patch(url, (req, res, ctx) => {
if (req.body?.loginToken === correctLoginToken) {
return res(ctx.status(200), ctx.json({}))
}
return res(ctx.status(500))
}),
)
beforeAll(() => {
server.listen()
// Disable window._paq.push used by Matomo.
if (!window?._paq?.push) {
window._paq = {
push: jest.fn(),
}
}
console.error = () => {}
})
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})
afterAll(() => {
server.close()
// eslint-disable-next-line no-global-assign
window = originalWindow
console.error = originalConsoleError
})
it("should render ResetPasswordPage", () => {
render(<ResetPasswordPage />)
const title = screen.queryByText(/Changement de mot de passe/i)
expect(title).toBeInTheDocument()
})
it("should show an error if no email is given", async () => {
render(<ResetPasswordPage />)
userEvent.type(screen.getByLabelText(/^Mot de passe$/i), "tototiti")
userEvent.type(screen.getByLabelText(/Confirmation mot de passe/i), "tototata")
userEvent.click(screen.getByRole("button", { name: /appliquer/i }))
await waitFor(() => expect(screen.getByText(/Les mots de passe ne correspondent pas/i)).toBeInTheDocument())
})
it("should works for correct token", async () => {
nextRouter.useRouter.mockImplementation(() => ({
...mockRouterImplementation,
query: { loginToken: correctLoginToken },
}))
render(<ResetPasswordPage />)
const password = "tototiti"
userEvent.type(screen.getByLabelText(/^Mot de passe$/i), password)
userEvent.type(screen.getByLabelText(/Confirmation mot de passe/i), password)
userEvent.click(screen.getByRole("button", { name: /appliquer/i }))
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Mot de passe réinitialisé/i))
})
it("should display an error for incorrect token", async () => {
nextRouter.useRouter.mockImplementation(() => ({
...mockRouterImplementation,
query: { loginToken: incorrectLoginToken },
}))
render(<ResetPasswordPage />)
const password = "tototiti"
userEvent.type(screen.getByLabelText(/^Mot de passe$/i), password)
userEvent.type(screen.getByLabelText(/Confirmation mot de passe/i), password)
userEvent.click(screen.getByRole("button", { name: /appliquer/i }))
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Erreur serveur/i))
})
import fetch from "isomorphic-unfetch"
import { API_URL, RESET_PWD_ENDPOINT, USERS_ENDPOINT } from "../config"
import { API_URL, FORGOT_PWD_ENDPOINT, RESET_PWD_ENDPOINT, USERS_ENDPOINT } from "../config"
import { handleAPIResponse2 } from "../utils/errors"
import { METHOD_DELETE, METHOD_PATCH, METHOD_POST, METHOD_PUT } from "../utils/http"
......@@ -24,32 +24,70 @@ export const searchUsersFuzzy = async ({ search, requestedPage, headers }) => {
}
export const createUser = async ({ user, headers }) => {
const response = await fetch(`${API_URL}${USERS_ENDPOINT}`, {
method: METHOD_POST,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(user),
headers: { ...headers, "Content-Type": "application/json" },
method: METHOD_POST,
})
return handleAPIResponse2(response)
}
export const updateUser = async ({ user, headers }) => {
const response = await fetch(`${API_URL}${USERS_ENDPOINT}/${user.id}`, {
method: METHOD_PUT,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(user),
headers: { ...headers, "Content-Type": "application/json" },
method: METHOD_PUT,
})
return handleAPIResponse2(response)
}
export const deleteUser = async ({ id, headers }) => {
const response = await fetch(`${API_URL}${USERS_ENDPOINT}/${id}`, { method: METHOD_DELETE, headers })
const response = await fetch(`${API_URL}${USERS_ENDPOINT}/${id}`, { headers, method: METHOD_DELETE })
return handleAPIResponse2(response)
}
export const patchUser = async ({ id, password, headers }) => {
/**
* Reset password of a user by an admin.
*
* @param {Object} payload - Inputs of the clients.
* @param {number} payload.id - User id to change password.
* @param {string} payload.password - New password to apply.
* @param {Object} payload.headers - Headers including authorization token.
*/
export const resetPasswordByAdmin = async ({ id, password, headers }) => {
const response = await fetch(`${API_URL}${RESET_PWD_ENDPOINT}`, {
method: METHOD_PATCH,
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ id, password }),
headers: { ...headers, "Content-Type": "application/json" },
method: METHOD_PATCH,
})
return handleAPIResponse2(response)
}
/**
* Reset a user's password by himself.
*
* @param {Object} payload - Inputs of the clients.
* @param {string} payload.password - New password to apply.
* @param {number} payload.loginToken - Authentification token to verify the identity of the user.
*/
export const resetPassword = async ({ loginToken, password }) => {
const response = await fetch(`${API_URL}${RESET_PWD_ENDPOINT}`, {
body: JSON.stringify({ loginToken, password }),
headers: { "Content-Type": "application/json" },
method: METHOD_PATCH,
})
return handleAPIResponse2(response)
}
/**
* Send an email in case of forgetting the password.
*
* @param {string} email - Email to send the magic link.
*/
export const forgotPassword = async (email) => {
const response = await fetch(`${API_URL}${FORGOT_PWD_ENDPOINT}`, {
body: JSON.stringify({ email }),
headers: { "Content-Type": "application/json" },
method: METHOD_POST,
})
return handleAPIResponse2(response)
}
......@@ -51,7 +51,7 @@ const Login = ({ authentication, error }) => {
<FormGroup>
<Label for="password">Mot de passe</Label>
<div className="float-right">
<Link href="forgotPassword">
<Link href="forgot-password">
<a>Mot de passe oublié&nbsp;?</a>
</Link>
</div>
......@@ -68,8 +68,8 @@ const Login = ({ authentication, error }) => {
<InputGroupAddon addonType="append" style={{ backgroundColor: "#e9ecef" }}>
<InputGroupText
style={{
borderColor: "#ced4da",
backgroundColor: "#e9ecef",
borderColor: "#ced4da",
}}
onClick={handleClick}
className={hidden ? "" : "text-primary"}
......@@ -88,12 +88,6 @@ const Login = ({ authentication, error }) => {
</Alert>
</Form>
</div>
<div className="encadre shadow border m-4 px-3 py-2 rounded">
Vous êtes nouveau sur Medlé&nbsp;?{" "}
<Link href="createAccount">
<a>Créer un compte</a>
</Link>
</div>
</div>
<style jsx>{`
.encadre {
......
import PropTypes from "prop-types"
import React from "react"
import { Alert } from "reactstrap"
/**
* @typedef {"idle" | "pending" | "error" | "sucess"} StatusType
*/
/**
* Display an error or a success message.
*
* @param {Object} status - Status object.
* @param {StatusType} status.type - Status type.
* @param {string} [status.message] - Message (optionnal).
* @param {string} [status.className] - CSS classnames (optionnal).
*/
export default function StatusAlert({ type, message = "", className }) {
const color = type === "error" ? "danger" : type === "success" ? "success" : "info"
return (