From 4dd2baff218041fedc12e84d375f1dfefee9084f Mon Sep 17 00:00:00 2001 From: Kim Lan Phan Hoang Date: Thu, 3 Oct 2024 13:35:57 +0200 Subject: [PATCH] feat: add reset password forms (#414) * 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 --- .github/workflows/cypress.yml | 8 +- .github/workflows/deploy-dev.yml | 1 - .github/workflows/deploy-prod.yml | 1 - .github/workflows/deploy-stage.yml | 1 - README.md | 13 +- cypress/e2e/SignInPassword.cy.ts | 44 +++- cypress/e2e/requestPasswordReset.cy.ts | 57 +++++ cypress/e2e/resetPassword.cy.ts | 148 +++++++++++ cypress/e2e/util.ts | 19 ++ cypress/fixtures/members.ts | 20 +- cypress/support/commands.ts | 26 +- cypress/support/server.ts | 38 ++- package.json | 9 +- src/App.tsx | 19 +- src/components/APIChecker.tsx | 7 +- src/components/EmailInput.tsx | 16 +- src/components/LeftContentContainer.tsx | 10 +- src/components/Redirection.tsx | 11 +- src/components/SignIn.tsx | 37 ++- src/components/SignUp.tsx | 24 +- src/components/StyledDivider.tsx | 10 - src/components/common/EmailAdornment.tsx | 9 + src/components/common/ErrorDisplay.tsx | 15 +- src/components/common/PasswordInput.tsx | 10 +- .../{ => common}/StyledTextField.tsx | 0 src/components/layout/CenteredContent.tsx | 40 +++ src/components/layout/DialogHeader.tsx | 28 +++ .../{ => register}/AgreementForm.tsx | 8 +- .../{ => register}/EnableAnalyticsForm.tsx | 6 +- .../InvalidTokenScreen.tsx | 32 +++ .../RequestPasswordReset.tsx | 129 ++++++++++ .../requestPasswordReset/ResetPassword.tsx | 237 ++++++++++++++++++ src/components/{ => signIn}/MagicLinkForm.tsx | 28 ++- .../PasswordForm.tsx} | 55 ++-- src/components/styles.ts | 7 + src/config/constants.ts | 5 + src/config/env.ts | 6 - src/config/messages.ts | 1 - src/config/paths.ts | 2 + src/config/selectors.ts | 54 +++- src/env.d.ts | 1 - src/hooks/mobile.tsx | 4 +- src/hooks/searchParams.tsx | 1 - src/hooks/useValidateJWTToken.ts | 22 ++ src/langs/constants.ts | 37 +++ src/langs/en.json | 35 ++- src/utils/validation.ts | 37 ++- vite.config.ts | 26 +- yarn.lock | 36 ++- 49 files changed, 1187 insertions(+), 203 deletions(-) create mode 100644 cypress/e2e/requestPasswordReset.cy.ts create mode 100644 cypress/e2e/resetPassword.cy.ts delete mode 100644 src/components/StyledDivider.tsx create mode 100644 src/components/common/EmailAdornment.tsx rename src/components/{ => common}/StyledTextField.tsx (100%) create mode 100644 src/components/layout/CenteredContent.tsx create mode 100644 src/components/layout/DialogHeader.tsx rename src/components/{ => register}/AgreementForm.tsx (89%) rename src/components/{ => register}/EnableAnalyticsForm.tsx (85%) create mode 100644 src/components/requestPasswordReset/InvalidTokenScreen.tsx create mode 100644 src/components/requestPasswordReset/RequestPasswordReset.tsx create mode 100644 src/components/requestPasswordReset/ResetPassword.tsx rename src/components/{ => signIn}/MagicLinkForm.tsx (77%) rename src/components/{SignInPasswordForm.tsx => signIn/PasswordForm.tsx} (73%) create mode 100644 src/components/styles.ts delete mode 100644 src/config/messages.ts create mode 100644 src/hooks/useValidateJWTToken.ts diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 6a98fc16..cda15218 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -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 @@ -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 }} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index ac2630d8..7140a2ef 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -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 }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 6fc82cdc..1f6fdc65 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -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 }} diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 57f05300..c5d424b0 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -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 }} diff --git a/README.md b/README.md index 68c030e7..eb586a5a 100644 --- a/README.md +++ b/README.md @@ -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= ``` @@ -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= ``` diff --git a/cypress/e2e/SignInPassword.cy.ts b/cypress/e2e/SignInPassword.cy.ts index 5e4dd789..35f2fdce 100644 --- a/cypress/e2e/SignInPassword.cy.ts +++ b/cypress/e2e/SignInPassword.cy.ts @@ -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'; @@ -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: '

Mock Auth Page

', + }); + }, + ).as('redirectionPage'); const { WRONG_EMAIL, GRAASP } = MEMBERS; cy.visit(SIGN_IN_PATH); @@ -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); }); @@ -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'); + }); }); diff --git a/cypress/e2e/requestPasswordReset.cy.ts b/cypress/e2e/requestPasswordReset.cy.ts new file mode 100644 index 00000000..c8600ea8 --- /dev/null +++ b/cypress/e2e/requestPasswordReset.cy.ts @@ -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', + ); + }); +}); diff --git a/cypress/e2e/resetPassword.cy.ts b/cypress/e2e/resetPassword.cy.ts new file mode 100644 index 00000000..2094fe18 --- /dev/null +++ b/cypress/e2e/resetPassword.cy.ts @@ -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.', + ); + }); + }); +}); diff --git a/cypress/e2e/util.ts b/cypress/e2e/util.ts index 2d735773..2a8288ea 100644 --- a/cypress/e2e/util.ts +++ b/cypress/e2e/util.ts @@ -1,3 +1,5 @@ +import { SignJWT } from 'jose/jwt/sign'; + import { EMAIL_SIGN_IN_FIELD_ID, EMAIL_SIGN_IN_MAGIC_LINK_FIELD_ID, @@ -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; +}; diff --git a/cypress/fixtures/members.ts b/cypress/fixtures/members.ts index 6dfd83e2..369e99e1 100644 --- a/cypress/fixtures/members.ts +++ b/cypress/fixtures/members.ts @@ -1,18 +1,11 @@ import { AccountType, CompleteMember, Password } from '@graasp/sdk'; -export const MEMBERS: { - [name: string]: CompleteMember & { - nameValid?: boolean; - emailValid?: boolean; - passwordValid?: boolean; - password?: Password; - }; -} = { +export const MEMBERS = { GRAASP: { id: 'graasp-id', name: 'graasp', email: 'graasp@graasp.org', - password: 'test', + password: 'aPassword1', nameValid: true, emailValid: true, passwordValid: true, @@ -27,7 +20,7 @@ export const MEMBERS: { id: 'graasp_other-id', name: 'graasp_other', email: 'graasp_other@graasp.org', - password: 'test', + password: 'aPassword2', nameValid: true, emailValid: true, passwordValid: true, @@ -104,6 +97,13 @@ export const MEMBERS: { enableSaveActions: true, isValidated: true, }, +} satisfies { + [name: string]: CompleteMember & { + nameValid?: boolean; + emailValid?: boolean; + passwordValid?: boolean; + password?: Password; + }; }; export default MEMBERS; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 8075dd8f..67a987da 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -25,7 +25,12 @@ import { submitSignIn, submitSignUp, } from '../e2e/util'; -import { mockGetCurrentMember, mockGetStatus } from './server'; +import { + mockGetCurrentMember, + mockGetStatus, + mockRequestPasswordReset, + mockResetPassword, +} from './server'; // cypress/support/index.ts declare global { @@ -33,6 +38,8 @@ declare global { interface Chainable { setUpApi(args?: { currentMember?: CompleteMember | null; + shouldFailRequestPasswordReset?: boolean; + shouldFailResetPassword?: boolean; }): Chainable>; checkErrorTextField( @@ -71,10 +78,19 @@ declare global { } } -Cypress.Commands.add('setUpApi', ({ currentMember = null } = {}) => { - mockGetCurrentMember(currentMember); - mockGetStatus(); -}); +Cypress.Commands.add( + 'setUpApi', + ({ + currentMember = null, + shouldFailRequestPasswordReset = false, + shouldFailResetPassword = false, + } = {}) => { + mockGetCurrentMember(currentMember); + mockGetStatus(); + mockRequestPasswordReset(shouldFailRequestPasswordReset); + mockResetPassword(shouldFailResetPassword); + }, +); Cypress.Commands.add('checkErrorTextField', (id, flag) => { const existence = flag ? 'not.exist' : 'exist'; diff --git a/cypress/support/server.ts b/cypress/support/server.ts index c4092b8b..e703fa6a 100644 --- a/cypress/support/server.ts +++ b/cypress/support/server.ts @@ -1,7 +1,7 @@ import { StatusCodes } from 'http-status-codes'; import { API_ROUTES } from '@graasp/query-client'; -import { CompleteMember } from '@graasp/sdk'; +import { CompleteMember, HttpMethod } from '@graasp/sdk'; const { buildGetCurrentMemberRoute } = API_ROUTES; @@ -19,7 +19,7 @@ export const mockGetCurrentMember = ( ) => { cy.intercept( { - method: 'get', + method: HttpMethod.Get, url: `${API_HOST}/${buildGetCurrentMemberRoute()}`, }, ({ reply }) => { @@ -39,7 +39,7 @@ export const mockGetCurrentMember = ( export const mockGetStatus = (shouldThrowServerError = false) => { cy.intercept( { - method: 'get', + method: HttpMethod.Get, url: `${API_HOST}/status`, }, ({ reply }) => { @@ -50,3 +50,35 @@ export const mockGetStatus = (shouldThrowServerError = false) => { }, ).as('getStatus'); }; + +export const mockRequestPasswordReset = (shouldThrowServerError = false) => { + cy.intercept( + { + method: HttpMethod.Post, + url: `${API_HOST}/password/reset`, + }, + ({ reply }) => { + if (shouldThrowServerError) { + // member email was not found + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + return reply({ statusCode: StatusCodes.NO_CONTENT }); + }, + ).as('requestPasswordReset'); +}; + +export const mockResetPassword = (shouldThrowServerError = false) => { + cy.intercept( + { + method: HttpMethod.Patch, + url: `${API_HOST}/password/reset`, + }, + ({ reply }) => { + if (shouldThrowServerError) { + // token is not present or password is too weak + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + return reply({ statusCode: StatusCodes.NO_CONTENT }); + }, + ).as('resetPassword'); +}; diff --git a/package.json b/package.json index 16bce5e2..81d38739 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@emotion/cache": "11.13.1", "@emotion/react": "11.13.3", "@emotion/styled": "11.13.0", - "@graasp/query-client": "3.22.4", + "@graasp/query-client": "3.25.0", "@graasp/sdk": "4.29.1", "@graasp/stylis-plugin-rtl": "2.2.0", "@graasp/translations": "1.37.1", @@ -25,10 +25,13 @@ "date-fns": "4.1.0", "http-status-codes": "2.3.0", "i18next": "23.15.1", + "jose": "5.9.3", + "jwt-decode": "4.0.0", "lucide-react": "0.447.0", "react": "18.3.1", "react-dom": "18.3.1", "react-ga4": "2.1.0", + "react-hook-form": "7.53.0", "react-i18next": "15.0.2", "react-router": "6.26.2", "react-router-dom": "6.26.2", @@ -43,8 +46,10 @@ "start:test": "vite --mode test", "build": "vite build", "build:dev": "vite build --mode development", + "build:test": "vite build --mode test", "preview": "vite preview", "preview:dev": "vite preview --mode development", + "preview:test": "vite preview --mode test", "lint": "eslint .", "pre-commit": "yarn prettier:check && yarn lint", "prettier:check": "prettier --check {src,cypress}/**/*.{js,ts,tsx}", @@ -54,7 +59,7 @@ "hooks:uninstall": "husky uninstall", "hooks:install": "husky install", "cypress:open": "env-cmd -f ./.env.test cypress open", - "cypress": "concurrently -k -s first \"yarn start:test\" \"yarn cypress:run\"", + "cypress": "yarn build:test && concurrently -k -s first \"yarn preview:test\" \"yarn cypress:run\"", "test": "yarn cypress", "cypress:run": "env-cmd -f ./.env.test cypress run --headless --browser chrome", "postinstall": "husky install" diff --git a/src/App.tsx b/src/App.tsx index 12004605..aeda163d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,10 @@ import * as Sentry from '@sentry/react'; -import { Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import { + Navigate, + Route, + BrowserRouter as Router, + Routes, +} from 'react-router-dom'; import ErrorFallback from './components/ErrorFallback'; import MagicLinkSuccessContent from './components/MagicLinkSuccessContent'; @@ -7,9 +12,13 @@ import MobileAuth from './components/MobileAuth'; import Redirection from './components/Redirection'; import SignIn from './components/SignIn'; import SignUp from './components/SignUp'; +import { RequestPasswordReset } from './components/requestPasswordReset/RequestPasswordReset'; +import ResetPassword from './components/requestPasswordReset/ResetPassword'; import { HOME_PATH, MOBILE_AUTH_PATH, + REQUEST_PASSWORD_RESET_PATH, + RESET_PASSWORD_PATH, SIGN_IN_MAGIC_LINK_SUCCESS_PATH, SIGN_IN_PATH, SIGN_UP_PATH, @@ -26,8 +35,16 @@ const App = () => ( element={} /> } /> + } + /> + } /> } /> } /> + + {/* Fallback route */} + } /> diff --git a/src/components/APIChecker.tsx b/src/components/APIChecker.tsx index bb9987d1..1a63bdce 100644 --- a/src/components/APIChecker.tsx +++ b/src/components/APIChecker.tsx @@ -7,7 +7,7 @@ import { useAuthTranslation } from '../config/i18n'; import { axios, useQuery } from '../config/queryClient'; import { AUTH } from '../langs/constants'; -const APIChecker = (): JSX.Element | false => { +const APIChecker = (): JSX.Element | null => { const { t } = useAuthTranslation(); const { isSuccess, isLoading, refetch, isError } = useQuery({ queryKey: ['apiStatus'], @@ -19,7 +19,7 @@ const APIChecker = (): JSX.Element | false => { }); if (isSuccess) { - return false; + return null; } if (isError) { @@ -44,6 +44,7 @@ const APIChecker = (): JSX.Element | false => { ); } - return false; + // everything all right, we render nothing if connection is ok. + return null; }; export default APIChecker; diff --git a/src/components/EmailInput.tsx b/src/components/EmailInput.tsx index 1a4c85da..674e41c4 100644 --- a/src/components/EmailInput.tsx +++ b/src/components/EmailInput.tsx @@ -1,12 +1,10 @@ -import { Mail } from 'lucide-react'; import React, { FC, useEffect, useState } from 'react'; -import { InputAdornment } from '@mui/material'; - import { useAuthTranslation } from '../config/i18n'; import { AUTH } from '../langs/constants'; import { emailValidator } from '../utils/validation'; -import StyledTextField from './StyledTextField'; +import { EmailAdornment } from './common/EmailAdornment'; +import StyledTextField from './common/StyledTextField'; const { EMAIL_INPUT_PLACEHOLDER } = AUTH; @@ -21,7 +19,7 @@ type Props = { autoFocus?: boolean; }; -const EmailInput: FC = ({ +export const EmailInput: FC = ({ required = false, value = '', id, @@ -52,11 +50,7 @@ const EmailInput: FC = ({ return ( - - - ), + startAdornment: EmailAdornment, }} variant="outlined" value={value} @@ -74,5 +68,3 @@ const EmailInput: FC = ({ /> ); }; - -export default EmailInput; diff --git a/src/components/LeftContentContainer.tsx b/src/components/LeftContentContainer.tsx index ae390d8c..cbd276fa 100644 --- a/src/components/LeftContentContainer.tsx +++ b/src/components/LeftContentContainer.tsx @@ -10,11 +10,13 @@ import { Box, Stack } from '@mui/material'; import { BACKGROUND_PATTERN } from '../config/constants'; import { useAuthTranslation } from '../config/i18n'; +import { PLATFORM_ADVERTISEMENT_CONTAINER_ID } from '../config/selectors'; import { AUTH } from '../langs/constants'; import APIChecker from './APIChecker'; import { BrandingLogo } from './BrandingLogo'; import Footer from './Footer'; import { PlatformContent } from './leftContent/PlatformContent'; +import { styledBox } from './styles'; type Props = { children: JSX.Element | JSX.Element[]; @@ -51,9 +53,10 @@ const LeftContentContainer = ({ children }: Props): JSX.Element => { width="100%" justifyContent="center" alignItems="center" + px={3} > - + { = ({ children }) => { +const Redirection = ({ children }: Props) => { const { data: member } = hooks.useCurrentMember(); const redirect = useRedirection(); if (member) { - redirectToSavedUrl(window, GRAASP_BUILDER_HOST); - return ( ); diff --git a/src/components/SignIn.tsx b/src/components/SignIn.tsx index ce9d45ce..3351569d 100644 --- a/src/components/SignIn.tsx +++ b/src/components/SignIn.tsx @@ -2,7 +2,7 @@ import { Link, useLocation } from 'react-router-dom'; import { GraaspLogo } from '@graasp/ui'; -import { Divider, Stack, useTheme } from '@mui/material'; +import { Button, Divider, Stack, useTheme } from '@mui/material'; import Typography from '@mui/material/Typography'; import { useAuthTranslation } from '../config/i18n'; @@ -10,10 +10,8 @@ import { SIGN_UP_PATH } from '../config/paths'; import { SIGN_IN_HEADER_ID } from '../config/selectors'; import { AUTH } from '../langs/constants'; import LeftContentContainer from './LeftContentContainer'; -import MagicLinkForm from './MagicLinkForm'; -import SignInPasswordForm from './SignInPasswordForm'; - -const { SIGN_UP_LINK_TEXT, SIGN_IN_HEADER } = AUTH; +import MagicLinkForm from './signIn/MagicLinkForm'; +import PasswordForm from './signIn/PasswordForm'; const SignIn = () => { const { t } = useAuthTranslation(); @@ -24,7 +22,7 @@ const SignIn = () => { return ( { - + { component="h2" id={SIGN_IN_HEADER_ID} > - {t(SIGN_IN_HEADER)} + {t(AUTH.SIGN_IN_HEADER)} - - or - - - or - {t(SIGN_UP_LINK_TEXT)} + {t(AUTH.LOGIN_METHODS_DIVIDER)} + } + gap={3} + > + + + + } diff --git a/src/components/SignUp.tsx b/src/components/SignUp.tsx index 02f5d725..7cacd7ea 100644 --- a/src/components/SignUp.tsx +++ b/src/components/SignUp.tsx @@ -1,4 +1,3 @@ -import { UserRound } from 'lucide-react'; import { ChangeEventHandler, useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; @@ -10,13 +9,7 @@ import { import { GraaspLogo } from '@graasp/ui'; import { LoadingButton } from '@mui/lab'; -import { - FormControl, - InputAdornment, - LinearProgress, - Stack, - useTheme, -} from '@mui/material'; +import { FormControl, LinearProgress, Stack, useTheme } from '@mui/material'; import Typography from '@mui/material/Typography'; import { useAuthTranslation } from '../config/i18n'; @@ -34,12 +27,13 @@ import { useRedirection } from '../hooks/searchParams'; import { useAgreementForm } from '../hooks/useAgreementForm'; import { AUTH } from '../langs/constants'; import { emailValidator, nameValidator } from '../utils/validation'; -import { AgreementForm } from './AgreementForm'; -import EmailInput from './EmailInput'; -import { EnableAnalyticsForm } from './EnableAnalyticsForm'; +import { EmailInput } from './EmailInput'; import LeftContentContainer from './LeftContentContainer'; -import StyledTextField from './StyledTextField'; +import { EmailAdornment } from './common/EmailAdornment'; import ErrorDisplay from './common/ErrorDisplay'; +import StyledTextField from './common/StyledTextField'; +import { AgreementForm } from './register/AgreementForm'; +import { EnableAnalyticsForm } from './register/EnableAnalyticsForm'; const { SIGN_IN_LINK_TEXT, @@ -171,11 +165,7 @@ const SignUp = () => { - - - ), + startAdornment: EmailAdornment, }} required placeholder={t(NAME_FIELD_LABEL)} diff --git a/src/components/StyledDivider.tsx b/src/components/StyledDivider.tsx deleted file mode 100644 index c613a994..00000000 --- a/src/components/StyledDivider.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { styled } from '@mui/material'; -import MuiDivider from '@mui/material/Divider'; - -const Divider = styled(MuiDivider)(({ theme }) => ({ - margin: theme.spacing(1), -})); - -const StyledDivider = () => ; - -export default StyledDivider; diff --git a/src/components/common/EmailAdornment.tsx b/src/components/common/EmailAdornment.tsx new file mode 100644 index 00000000..97a2bc68 --- /dev/null +++ b/src/components/common/EmailAdornment.tsx @@ -0,0 +1,9 @@ +import { MailIcon } from 'lucide-react'; + +import { InputAdornment } from '@mui/material'; + +export const EmailAdornment = ( + + + +); diff --git a/src/components/common/ErrorDisplay.tsx b/src/components/common/ErrorDisplay.tsx index 4970808c..79166d5b 100644 --- a/src/components/common/ErrorDisplay.tsx +++ b/src/components/common/ErrorDisplay.tsx @@ -7,16 +7,15 @@ const ErrorDisplay = ({ error, }: { error: Error | null; -}): JSX.Element | false => { +}): JSX.Element | null => { const { t: translateMessages } = useMessagesTranslation(); - if (error) { - return ( - - {translateMessages(getErrorMessage(error))} - - ); + if (!error) { + return null; } - return false; + + return ( + {translateMessages(getErrorMessage(error))} + ); }; export default ErrorDisplay; diff --git a/src/components/common/PasswordInput.tsx b/src/components/common/PasswordInput.tsx index b8eb827b..7270cdc9 100644 --- a/src/components/common/PasswordInput.tsx +++ b/src/components/common/PasswordInput.tsx @@ -1,4 +1,3 @@ -import { Lock } from 'lucide-react'; import { useState } from 'react'; import { Visibility, VisibilityOff } from '@mui/icons-material'; @@ -6,7 +5,8 @@ import { IconButton, InputAdornment } from '@mui/material'; import { useAuthTranslation } from '../../config/i18n'; import { AUTH } from '../../langs/constants'; -import StyledTextField from '../StyledTextField'; +import { EmailAdornment } from './EmailAdornment'; +import StyledTextField from './StyledTextField'; const { PASSWORD_INPUT_PLACEHOLDER } = AUTH; @@ -27,11 +27,7 @@ const PasswordInput = ({ id, value, error, onKeyDown, onChange }: Props) => { return ( - - - ), + startAdornment: EmailAdornment, endAdornment: ( { + return ( + + + + + {header} + {children} + + +