Skip to content

Commit

Permalink
feat: add reset password forms (#414)
Browse files Browse the repository at this point in the history
* fix: update UI version

* feat: show differnt success message for password on success, refactor

* feat: add request password request and reset pages

* fix: update strict typescript and layout

* fix: add tests for request password request

* fix: udpate transaltion strings

* fix: update translation strings

* fix: all tests for the full functionality

* fix: all tests for the full functionality

* fix: improve email adornment

* fix: add button to go to forgot password page if error occurs

* fix: add better screen that check the validity of the jwt

* fix: jwt token tests

* fix: sign in password test fix

* fix: use a different domain for the redirection link

* fix: update workflow

* fix: run instanbul in test mode

* fix: update config

* fix: update config and script

* fix: update env vars

* fix: force build instrument

* fix: add a trycatch for invalid tokens

* fix: email constant and simplify rendering condition

* fix: apply review comments

* fix: translate requirements text

* fix: url in test

* fix: rename hook file

---------

Co-authored-by: spaenleh <[email protected]>
  • Loading branch information
pyphilia and spaenleh authored Oct 3, 2024
1 parent bf96912 commit 4dd2baf
Show file tree
Hide file tree
Showing 49 changed files with 1,187 additions and 203 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ jobs:
cypress: true

# type check
- name: Type-check code
run: tsc --noEmit
- name: Check code
run: yarn check

# use the Cypress GitHub Action to run Cypress tests within the chrome browser
- name: Cypress run
uses: cypress-io/github-action@v6
with:
install: false
start: yarn dev
build: yarn build:test
start: yarn preview:test
browser: chrome
quiet: true
config-file: cypress.config.ts
Expand All @@ -43,7 +44,6 @@ jobs:
VITE_VERSION: ${{ vars.VITE_VERSION }}
VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }}
VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }}
VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }}
VITE_GRAASP_BUILDER_HOST: ${{ vars.VITE_GRAASP_BUILDER_HOST }}
VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }}
VITE_RECAPTCHA_SITE_KEY: ${{ secrets.VITE_RECAPTCHA_SITE_KEY }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ jobs:
VITE_VERSION: ${{ github.sha }}
VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }}
VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }}
VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }}
VITE_GRAASP_BUILDER_HOST: ${{ vars.VITE_GRAASP_BUILDER_HOST }}
VITE_RECAPTCHA_SITE_KEY: ${{ secrets.VITE_RECAPTCHA_SITE_KEY }}
VITE_SENTRY_ENV: ${{ vars.VITE_SENTRY_ENV }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
VITE_VERSION: ${{ github.event.client_payload.tag }}
VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }}
VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }}
VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }}
VITE_GRAASP_BUILDER_HOST: ${{ vars.VITE_GRAASP_BUILDER_HOST }}
VITE_RECAPTCHA_SITE_KEY: ${{ secrets.VITE_RECAPTCHA_SITE_KEY }}
VITE_SENTRY_ENV: ${{ vars.VITE_SENTRY_ENV }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/deploy-stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
VITE_VERSION: ${{ github.event.client_payload.tag }}
VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }}
VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }}
VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }}
VITE_GRAASP_BUILDER_HOST: ${{ vars.VITE_GRAASP_BUILDER_HOST }}
VITE_RECAPTCHA_SITE_KEY: ${{ secrets.VITE_RECAPTCHA_SITE_KEY }}
VITE_SENTRY_ENV: ${{ vars.VITE_SENTRY_ENV }}
Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ Create an `.env.development` file with:

```sh
VITE_PORT=3001
VITE_GRAASP_API_HOST=http://localhost:3000
VITE_VERSION=latest
VITE_GRAASP_BUILDER_HOST=http://localhost:3111
VITE_SHOW_NOTIFICATIONS=true
VITE_GRAASP_AUTH_HOST=http://localhost:3001
VITE_GRAASP_DOMAIN=localhost:3001
VITE_GRAASP_API_HOST=http://localhost:3000
VITE_GRAASP_LANDING_PAGE_ORIGIN=https://graasp.org
VITE_SHOW_NOTIFICATIONS=true

VITE_RECAPTCHA_SITE_KEY=
```
Expand All @@ -22,11 +21,11 @@ For running tests locally create a `.env.test` file:

```sh
VITE_PORT=3002
VITE_GRAASP_API_HOST=http://localhost:3636
VITE_VERSION=latest
VITE_GRAASP_BUILDER_HOST=http://localhost:3111
VITE_GRAASP_DOMAIN=localhost:3002
VITE_GRAASP_API_HOST=http://localhost:3636
VITE_GRAASP_LANDING_PAGE_ORIGIN=https://graasp.org
VITE_SHOW_NOTIFICATIONS=true
VITE_GRAASP_AUTH_HOST=http://localhost:3001

VITE_RECAPTCHA_SITE_KEY=
```
44 changes: 40 additions & 4 deletions cypress/e2e/SignInPassword.cy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { StatusCodes } from 'http-status-codes';

import { API_ROUTES } from '@graasp/query-client';

import { SIGN_IN_PATH } from '../../src/config/paths';
Expand All @@ -6,15 +8,27 @@ import { MEMBERS } from '../fixtures/members';

describe('Email and Password Validation', () => {
it('Sign In With Password', () => {
const redirectionLink = 'mylink';
const redirectionLink = 'http://localhost:3005/mylink';
cy.intercept(
{
pathname: API_ROUTES.SIGN_IN_WITH_PASSWORD_ROUTE,
},
(req) => {
req.reply({ statusCode: 303, body: { resource: redirectionLink } });
({ reply }) => {
reply({ statusCode: 303, body: { resource: redirectionLink } });
},
).as('signInWithPassword');
cy.intercept(
{
url: redirectionLink,
},
({ reply }) => {
reply({
headers: { 'content-type': 'text/html' },
statusCode: StatusCodes.OK,
body: '<h1>Mock Auth Page</h1>',
});
},
).as('redirectionPage');

const { WRONG_EMAIL, GRAASP } = MEMBERS;
cy.visit(SIGN_IN_PATH);
Expand All @@ -23,7 +37,7 @@ describe('Email and Password Validation', () => {

// Signing in with a valid email and password
cy.signInPasswordAndCheck(GRAASP);

cy.wait('@signInWithPassword');
cy.url().should('contain', redirectionLink);
});

Expand Down Expand Up @@ -65,4 +79,26 @@ describe('Email and Password Validation', () => {

cy.get(`#${PASSWORD_SUCCESS_ALERT}`).should('be.visible');
});

it('Sign In With Password shows success message if no redirect', () => {
cy.intercept(
{
pathname: API_ROUTES.SIGN_IN_WITH_PASSWORD_ROUTE,
},
(req) => {
req.reply({ statusCode: 303 });
},
).as('signInWithPassword');

const { WRONG_EMAIL, WRONG_PASSWORD, GRAASP } = MEMBERS;
cy.visit(SIGN_IN_PATH);
// Signing in with wrong email
cy.signInPasswordAndCheck(WRONG_EMAIL);
// Signing in with a valid email but empty password
cy.signInPasswordAndCheck(WRONG_PASSWORD);
// Signing in with a valid email and password
cy.signInPasswordAndCheck(GRAASP);

cy.get(`#${PASSWORD_SUCCESS_ALERT}`).should('be.visible');
});
});
57 changes: 57 additions & 0 deletions cypress/e2e/requestPasswordReset.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { REQUEST_PASSWORD_RESET_PATH } from '../../src/config/paths';
import {
REQUEST_PASSWORD_RESET_EMAIL_FIELD_HELPER_ID,
REQUEST_PASSWORD_RESET_EMAIL_FIELD_ID,
REQUEST_PASSWORD_RESET_ERROR_MESSAGE_ID,
REQUEST_PASSWORD_RESET_SUBMIT_BUTTON_ID,
REQUEST_PASSWORD_RESET_SUCCESS_MESSAGE_ID,
} from '../../src/config/selectors';
import { MEMBERS } from '../fixtures/members';

describe('Request password reset', () => {
it('For existing member', () => {
cy.setUpApi();
cy.visit(REQUEST_PASSWORD_RESET_PATH);
// request password reset for an existing member
cy.get(`#${REQUEST_PASSWORD_RESET_EMAIL_FIELD_ID}`).type(
MEMBERS.GRAASP.email,
);
cy.get(`#${REQUEST_PASSWORD_RESET_SUBMIT_BUTTON_ID}`).click();
cy.get(`#${REQUEST_PASSWORD_RESET_SUCCESS_MESSAGE_ID}`).should(
'be.visible',
);
});
it('For non-email', () => {
cy.setUpApi();
cy.visit(REQUEST_PASSWORD_RESET_PATH);

cy.get(`#${REQUEST_PASSWORD_RESET_EMAIL_FIELD_ID}`).type(
MEMBERS.WRONG_EMAIL.email,
);

// click the button to trigger the validation
cy.get(`#${REQUEST_PASSWORD_RESET_SUBMIT_BUTTON_ID}`).click();
cy.get(`#${REQUEST_PASSWORD_RESET_SUBMIT_BUTTON_ID}`).should('be.disabled');

cy.get(`#${REQUEST_PASSWORD_RESET_EMAIL_FIELD_HELPER_ID}`).should(
'contain.text',
'This does not look like a valid email address',
);
});
it('For non-member', () => {
cy.setUpApi({ shouldFailRequestPasswordReset: true });
cy.visit(REQUEST_PASSWORD_RESET_PATH);

cy.get(`#${REQUEST_PASSWORD_RESET_EMAIL_FIELD_ID}`).type(
MEMBERS.GRAASP.email,
);

cy.get(`#${REQUEST_PASSWORD_RESET_SUBMIT_BUTTON_ID}`).click();

// expect the backend to fail the request because the captcha was not sent
cy.get(`#${REQUEST_PASSWORD_RESET_ERROR_MESSAGE_ID}`).should(
'contain.text',
'There was an error making your request',
);
});
});
148 changes: 148 additions & 0 deletions cypress/e2e/resetPassword.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { RESET_PASSWORD_PATH } from '../../src/config/paths';
import {
RESET_PASSWORD_ERROR_MESSAGE_ID,
RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ERROR_TEXT_ID,
RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ID,
RESET_PASSWORD_NEW_PASSWORD_FIELD_ERROR_TEXT_ID,
RESET_PASSWORD_NEW_PASSWORD_FIELD_ID,
RESET_PASSWORD_SUBMIT_BUTTON_ID,
RESET_PASSWORD_SUCCESS_MESSAGE_ID,
RESET_PASSWORD_TOKEN_ERROR_ID,
} from '../../src/config/selectors';
import { MEMBERS } from '../fixtures/members';
import { generateJWT } from './util';

describe('Reset password', () => {
describe('With valid token', () => {
it('With strong password', () => {
cy.setUpApi();

// this allows to run async code in cypress
cy.wrap(null).then(async () => {
const token = await generateJWT('1234');
cy.visit(`${RESET_PASSWORD_PATH}?t=${token}`);
});

cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_FIELD_ID}`).type(
MEMBERS.GRAASP.password,
);
cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ID}`).type(
MEMBERS.GRAASP.password,
);
cy.get(`#${RESET_PASSWORD_SUBMIT_BUTTON_ID}`).click();
cy.get(`#${RESET_PASSWORD_SUCCESS_MESSAGE_ID}`).should('be.visible');
});

it('With weak password', () => {
cy.setUpApi();

// this allows to run async code in cypress
cy.wrap(null).then(async () => {
const token = await generateJWT('1234');
cy.visit(`${RESET_PASSWORD_PATH}?t=${token}`);
});
cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_FIELD_ID}`).type('weak');
cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ID}`).type(
'weak',
);
cy.get(`#${RESET_PASSWORD_SUBMIT_BUTTON_ID}`).click();
cy.get(`#${RESET_PASSWORD_SUBMIT_BUTTON_ID}`).should('be.disabled');

cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_FIELD_ERROR_TEXT_ID}`).should(
'contain.text',
'This password is too weak',
);

cy.get(
`#${RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ERROR_TEXT_ID}`,
).should('contain.text', 'This password is too weak');
});

it('Without matching passwords', () => {
cy.setUpApi();

// this allows to run async code in cypress
cy.wrap(null).then(async () => {
const token = await generateJWT('1234');
cy.visit(`${RESET_PASSWORD_PATH}?t=${token}`);
});

cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_FIELD_ID}`).type('aPassword1');
cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ID}`).type(
'aPassword2',
);
cy.get(`#${RESET_PASSWORD_SUBMIT_BUTTON_ID}`).click();
cy.get(`#${RESET_PASSWORD_SUBMIT_BUTTON_ID}`).should('be.disabled');

cy.get(
`#${RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ERROR_TEXT_ID}`,
).should('contain.text', 'The passwords do not match.');
});

it('With server error', () => {
cy.setUpApi({ shouldFailResetPassword: true });

// this allows to run async code in cypress
cy.wrap(null).then(async () => {
const token = await generateJWT('1234');
cy.visit(`${RESET_PASSWORD_PATH}?t=${token}`);
});

cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_FIELD_ID}`).type('aPassword1');
cy.get(`#${RESET_PASSWORD_NEW_PASSWORD_CONFIRMATION_FIELD_ID}`).type(
'aPassword1',
);

cy.get(`#${RESET_PASSWORD_SUBMIT_BUTTON_ID}`).click();

// the backend fails the request (token is not valid for example)
cy.get(`#${RESET_PASSWORD_ERROR_MESSAGE_ID}`).should(
'contain.text',
'An error prevented the password reset operation.',
);
});
});

describe('Invalid token', () => {
it('Without token', () => {
cy.setUpApi();
cy.visit(RESET_PASSWORD_PATH);

// a rough error message is displayed when the url does not
// contain the required query string argument `t` containing the token
cy.get(`#${RESET_PASSWORD_TOKEN_ERROR_ID}`).should(
'contain.text',
'No token was provided or the provided token is expired.',
);
});

it('Not a JWT token', () => {
cy.setUpApi();
cy.visit(`${RESET_PASSWORD_PATH}?t=${'1234'}`);

// a rough error message is displayed when the url does not
// contain the required query string argument `t` containing the token
cy.get(`#${RESET_PASSWORD_TOKEN_ERROR_ID}`).should(
'contain.text',
'No token was provided or the provided token is expired.',
);
});

it('Expired token', () => {
cy.setUpApi();

// this allows to run async code in cypress
cy.wrap(null).then(async () => {
const token = await generateJWT('1234', '25h ago');
cy.visit(`${RESET_PASSWORD_PATH}?t=${token}`);
});

// a rough error message is displayed when the url does not
// contain the required query string argument `t` containing the token
cy.get(`#${RESET_PASSWORD_TOKEN_ERROR_ID}`).should(
'contain.text',
'No token was provided or the provided token is expired.',
);
});
});
});
19 changes: 19 additions & 0 deletions cypress/e2e/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SignJWT } from 'jose/jwt/sign';

import {
EMAIL_SIGN_IN_FIELD_ID,
EMAIL_SIGN_IN_MAGIC_LINK_FIELD_ID,
Expand Down Expand Up @@ -69,3 +71,20 @@ export const fillPasswordSignInLayout = ({
export const submitPasswordSignIn = () => {
cy.get(`#${PASSWORD_SIGN_IN_BUTTON_ID}`).click();
};

export const generateJWT = async (
payload: string,
expiresAt: string = '24h',
) => {
const jwt = await new SignJWT({ payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresAt)
.sign(
new TextEncoder().encode(
// random key. You could put whatever you want here.
'cc7e0d44fd473002f1c42167459001140ec6389b7353f8088f4d9a95f2f596f2',
),
);
return jwt;
};
Loading

0 comments on commit 4dd2baf

Please sign in to comment.