diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 17311a4a5..03ebddc54 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -42,7 +42,11 @@ import { getClaimsByToken, hashPassword, } from '../../utils/auth' -import { createUser, sendConfirmationEmail } from '../../services/create_user' +import { + createUser, + sendConfirmationEmail, + sendPasswordResetEmail, +} from '../../services/create_user' import { isErrorWithCode } from '../../resolvers' import { initModels } from '../../server' import { getRepository } from '../../entity/utils' @@ -302,7 +306,7 @@ export function authRouter() { async function handleSuccessfulLogin( req: express.Request, res: express.Response, - user: UserData, + user: UserData | User, newUser: boolean ): Promise { try { @@ -363,6 +367,12 @@ export function authRouter() { async (req: express.Request, res: express.Response) => { const { email, password } = req.body + if (!email || !password) { + return res.redirect( + `${env.client.url}/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}` + ) + } + try { const models = initModels(kx, false) const user = await models.user.getWhere({ @@ -426,6 +436,12 @@ export function authRouter() { cors(corsConfig), async (req: express.Request, res: express.Response) => { const { email, password, name, username, bio, pictureUrl } = req.body + + if (!email || !password || !name || !username) { + return res.redirect( + `${env.client.url}/email-signup?errorCodes=INVALID_CREDENTIALS` + ) + } const lowerCasedUsername = username.toLowerCase() try { @@ -491,7 +507,7 @@ export function authRouter() { ) } - res.redirect(`${env.client.url}/email-login?message=EMAIL_VERIFIED`) + await handleSuccessfulLogin(req, res, user, false) } catch (e) { logger.info('confirm-email exception:', e) if (e instanceof jwt.TokenExpiredError) { @@ -505,5 +521,54 @@ export function authRouter() { } ) + router.options( + '/email-reset-password', + cors({ ...corsConfig, maxAge: 600 }) + ) + + router.post( + '/email-reset-password', + cors(corsConfig), + async (req: express.Request, res: express.Response) => { + const email = req.body.email + if (!email) { + return res.redirect( + `${env.client.url}/email-reset-password?errorCodes=INVALID_EMAIL` + ) + } + + try { + const user = await getRepository(User).findOneBy({ + email, + }) + if (!user) { + return res.redirect( + `${env.client.url}/email-reset-password?errorCodes=USER_NOT_FOUND` + ) + } + + if (user.status === StatusType.Pending) { + return res.redirect( + `${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION` + ) + } + + if (!(await sendPasswordResetEmail(user))) { + return res.redirect( + `${env.client.url}/email-reset-password?errorCodes=INVALID_EMAIL` + ) + } + + res.redirect(`${env.client.url}/email-reset-password?message=SUCCESS`) + } catch (e) { + logger.info('email-reset-password exception:', e) + + res.redirect( + `${env.client.url}/email-reset-password?errorCodes=UNKNOWN` + ) + } + } + ) + return router } diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index 2b742e4ac..39cea746a 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -143,3 +143,20 @@ export const sendConfirmationEmail = async (user: { text: `Hey ${user.name},\n\nPlease confirm your email by clicking the link below:\n\n${confirmationLink}\n\n`, }) } + +export const sendPasswordResetEmail = async (user: { + id: string + name: string + email: string +}): Promise => { + // generate link + const token = generateVerificationToken(user.id) + const link = `${env.client.url}/reset-password/${token}` + // send email + return sendEmail({ + from: env.sender.message, + to: user.email, + subject: 'Reset your password', + text: `Hey ${user.name},\n\nPlease reset your password by clicking the link below:\n\n${link}\n\n`, + }) +} diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index 877fdf521..e3457271c 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -12,21 +12,21 @@ import { generateVerificationToken, hashPassword } from '../../src/utils/auth' describe('auth router', () => { const route = '/api/auth' - const signupRequest = ( - email: string, - password: string, - name: string, - username: string - ): supertest.Test => { - return request.post(`${route}/email-signup`).send({ - email, - password, - name, - username, - }) - } describe('email signup', () => { + const signupRequest = ( + email: string, + password: string, + name: string, + username: string + ): supertest.Test => { + return request.post(`${route}/email-signup`).send({ + email, + password, + name, + username, + }) + } const validPassword = 'validPassword' let email: string @@ -284,11 +284,9 @@ describe('auth router', () => { token = generateVerificationToken(user.id) }) - it('redirects to email-login page', async () => { + it('logs in and redirects to home page', async () => { const res = await confirmEmailRequest(token).expect(302) - expect(res.header.location).to.endWith( - '/email-login?message=EMAIL_VERIFIED' - ) + expect(res.header.location).to.endWith('/home') }) it('sets user as active', async () => { @@ -336,4 +334,122 @@ describe('auth router', () => { }) }) }) + + describe('email-reset-password', () => { + const emailResetPasswordReq = (email: string): supertest.Test => { + return request.post(`${route}/email-reset-password`).send({ + email, + }) + } + + let email: string + + context('when email is not empty', () => { + before(() => { + email = `some_email@domain.app` + }) + + context('when user exists', () => { + let user: User + + before(async () => { + user = await createTestUser('test_user') + email = user.email + }) + + after(async () => { + await deleteTestUser(user.name) + }) + + context('when email is verified', () => { + let fake: (msg: MailDataRequired) => Promise + + before(async () => { + await getRepository(User).update(user.id, { + status: StatusType.Active, + }) + }) + + context('when reset password email sent', () => { + before(() => { + fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) + }) + + after(() => { + sinon.restore() + }) + + it('redirects to email-reset-password page with success message', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/email-reset-password?message=SUCCESS' + ) + }) + }) + + context('when reset password email not sent', () => { + before(() => { + fake = sinon.replace( + util, + 'sendEmail', + sinon.fake.resolves(false) + ) + }) + + after(() => { + sinon.restore() + }) + + it('redirects to sign up page with error code INVALID_EMAIL', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/email-reset-password?errorCodes=INVALID_EMAIL' + ) + }) + }) + }) + + context('when email is not verified', () => { + before(async () => { + await getRepository(User).update(user.id, { + status: StatusType.Pending, + }) + }) + + it('redirects to email-login page with error code PENDING_VERIFICATION', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/email-login?errorCodes=PENDING_VERIFICATION' + ) + }) + }) + }) + + context('when user does not exist', () => { + before(() => { + email = 'non_exists_email@domain.app' + }) + + it('redirects to email-reset-password page with error code USER_NOT_FOUND', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/email-reset-password?errorCodes=USER_NOT_FOUND' + ) + }) + }) + }) + + context('when email is empty', () => { + before(() => { + email = '' + }) + + it('redirects to email-reset-password page with error code INVALID_EMAIL', async () => { + const res = await emailResetPasswordReq(email).expect(302) + expect(res.header.location).to.endWith( + '/email-reset-password?errorCodes=INVALID_EMAIL' + ) + }) + }) + }) })