Skip to content

Commit

Permalink
Information Architecture Feature Branch (#5238)
Browse files Browse the repository at this point in the history
* feat: [Information Architecture] FF, pre-work, etc

* refactor: Use enum in color scheme logic

* [Information Architecture] - New Sidebar Nav (#5283)

* feat: Tests, visibility button, etc (#5298)

* feat: image component (#5331)

* feat: image component

* feat: pr suggestions

* feat: update props

* [Information Architecture] API Keys Page (#5305)

* feat: Refactor and improve functionality of API Keys page

* feat(web): initial user profile page

* chore(web): added suggestions from pr #5322

* feat: Reusable IconButton

* feat: Extract hook for API Keys

* feat: WIP Api changes

* test: Test API Keys page

* refactor: Clean-up

* feat(web): setting user profile page - security section

* chore(api,web,dal): added suggestions from the pr #5329

* chore(web): remove unused refetch api keys prop

* feat(web,api): user profile page form

* chore(web,api,shared): added suggestions from the pr #5381, common interface for update user profile

* feat: API Changes for password reset (#5350)

* feat(web,api): user profile image upload

* chore(web): added suggestion from the pr #5384

* chore(api): branding whitelist web.novu.co domain for tests

* [Information Architecture] User Profile | Set Password Flow (#5325)

* feat: Setup update form (#5362)

* Nv 3449 settings organisation profile page (#5360)

* feat: created the organization profile update page

* feat: added e2e tests for organization profile update

* feat: added v2 settings page without the tabs

* chore: updated spec name

* fix: tests by updating the endpoint

* fix: tests

* fix: ci test

* chore: Removed useCallback

* feat: applied suggestions from the pr

* feat: moved storage types to shared package

* refactor: separate function for image upload and logourl renaming

* feat: add input height constant

* feat: use novu logo as org logo

* refactor: remove unused imports

* fix: test

* Update apps/web/src/pages/settings/organization/OrganizationPage.tsx

Co-authored-by: Joel Anton <[email protected]>

* Update apps/web/src/pages/settings/organization/OrganizationLogo.tsx

Co-authored-by: Joel Anton <[email protected]>

* Update apps/web/src/pages/settings/organization/OrganizationLogo.tsx

Co-authored-by: Joel Anton <[email protected]>

* Update apps/web/src/pages/settings/organization/OrganizationLogo.tsx

Co-authored-by: Joel Anton <[email protected]>

* Update apps/web/src/components/shared/ProfileImage.styles.ts

Co-authored-by: Joel Anton <[email protected]>

* revert: empty line

* Update apps/web/src/pages/settings/organization/OrganizationLogo.tsx

Co-authored-by: Joel Anton <[email protected]>

* refactor: use type from shared

* refactor: updated the props name to use form values

---------

Co-authored-by: Paweł <[email protected]>
Co-authored-by: Joel Anton <[email protected]>

* [Information Architecture] Follow-ups & Clean-up (#5408)

* [Information Architecture] Webhook Page (#5395)

* Nv 3455 settings branding page (#5407)

* feat: created the organization profile update page

* feat: added v2 settings page without the tabs

* feat: applied suggestions from the pr

* feat: moved storage types to shared package

* feat: use novu logo as org logo

* refactor: remove unused imports

* feat: add additional fields to branding api

* feat: branding form v2

* feat: added v2 branding form to route

* fix: spacing between title and input

* fix: remove onerror

* chore: add deprecation

* fix: typo

* revert: empty line

* refactor: divided brand form into sub components and applied pr suggestions

* chore: remove unused import

* feat: update types

* feat: use type

* refactor: updated prop types name

* fix: spell check

* fix: wrong css prop

* fix: add theme based colors

* refactor: format

* feat: applied pr suggestions

* fix: typo

* fix: navigation width when information architecture is disabled (#5424)

* fix: Tweak spacing

* fix: NV-3667 | Hide tooltip when no content

* fix: NV-3668 | Hide changes in prod env

* fix: NV-3666 | Branding page button

* fix: NV-3679 | Re-order inputs

* fix: NV-3665 | Update margin values

* fix: Remove forced feature flag

* fix: Update logo asset

* fix: NV-3664 | Change Profile inputs per Nik

* fix: Styling tweaks

* fix: Use DS value

* test: Add test util for debugging responses

* fix: Remove host restriction in test

* test: Add debugging helpers to e2e

* test: Fix main nav spec

* test: Fix user profile spec

* test: Fix clipboard flakiness -- yay!

* fix: Use panda values -- really to re-run CI though

* fix: CI web tests

* fix: tests

* fix: allow http only in local env

* fix: enable submit btn

* fix: skip un-implemented feature tests

* fix: updated password to meet validation requirements

* fix: close color picker

* fix: data test id and password

* fix: get sub value and intercept

* fix: editor not submiting

* fix: add side nav

* revert: changes

* test: Force when off screen

* test: More force

* test: Attempt flakiness fix for org switcher

* refactor: Extract SidebarFormless and reuse const + styles

* refactor: Use new SidebarFormless component

* ci: Skip playwright test

* ci: Clean-up to retrigger pipelines

* test: Add mock feature flag false to old test

---------

Co-authored-by: Joel Anton <[email protected]>

* test: Attempt wait

* fix: organization switch test

* test: Move visit to test instead of beforeEach

---------

Co-authored-by: Joel Anton <[email protected]>
Co-authored-by: Paweł <[email protected]>
Co-authored-by: Paweł Tymczuk <[email protected]>
  • Loading branch information
4 people authored Apr 23, 2024
1 parent 26f5bfb commit c17dde5
Show file tree
Hide file tree
Showing 211 changed files with 6,492 additions and 431 deletions.
9 changes: 8 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -619,8 +619,15 @@
"zulip",
"uuidv",
"Vonage",
"HeaderNavNew",
"reshard",
"hstack",
"runtimes",
"cafebabe",
"Markunread",
"Fira",
"Roboto",
"Raleway",
"Icann",
"limitbar",
"eazy"
Expand Down Expand Up @@ -708,6 +715,6 @@
"angular.json",
"ng-package.json",
"libs/shared/src/types/timezones/timezones.types.ts",
"*.riv",
"*.riv"
]
}
29 changes: 26 additions & 3 deletions apps/api/src/app/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import {
UseInterceptors,
Logger,
Header,
HttpStatus,
} from '@nestjs/common';
import { MemberRepository, OrganizationRepository, UserRepository, MemberEntity } from '@novu/dal';
import { AuthGuard } from '@nestjs/passport';
import { IJwtPayload } from '@novu/shared';
import { IJwtPayload, PasswordResetFlowEnum } from '@novu/shared';
import { UserRegistrationBodyDto } from './dtos/user-registration.dto';
import { UserRegister } from './usecases/register/user-register.usecase';
import { UserRegisterCommand } from './usecases/register/user-register.command';
Expand All @@ -43,6 +44,9 @@ import {
SwitchOrganizationCommand,
} from '@novu/application-generic';
import { ApiCommonResponses } from '../shared/framework/response.decorator';
import { UpdatePasswordBodyDto } from './dtos/update-password.dto';
import { UpdatePassword } from './usecases/update-password/update-password.usecase';
import { UpdatePasswordCommand } from './usecases/update-password/update-password.command';

@ApiCommonResponses()
@Controller('/auth')
Expand All @@ -60,7 +64,8 @@ export class AuthController {
private switchOrganizationUsecase: SwitchOrganization,
private memberRepository: MemberRepository,
private passwordResetRequestUsecase: PasswordResetRequest,
private passwordResetUsecase: PasswordReset
private passwordResetUsecase: PasswordReset,
private updatePasswordUsecase: UpdatePassword
) {}

@Get('/github')
Expand Down Expand Up @@ -116,10 +121,11 @@ export class AuthController {
}

@Post('/reset/request')
async forgotPasswordRequest(@Body() body: { email: string }) {
async forgotPasswordRequest(@Body() body: { email: string }, @Query('src') src?: PasswordResetFlowEnum) {
return await this.passwordResetRequestUsecase.execute(
PasswordResetRequestCommand.create({
email: body.email,
src,
})
);
}
Expand Down Expand Up @@ -180,6 +186,23 @@ export class AuthController {
};
}

@Post('/update-password')
@Header('Cache-Control', 'no-store')
@UseGuards(UserAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async updatePassword(@UserSession() user: IJwtPayload, @Body() body: UpdatePasswordBodyDto) {
return await this.updatePasswordUsecase.execute(
UpdatePasswordCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
currentPassword: body.currentPassword,
newPassword: body.newPassword,
confirmPassword: body.confirmPassword,
})
);
}

@Get('/test/token/:userId')
async authenticateTest(
@Param('userId') userId: string,
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/app/auth/dtos/update-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { passwordConstraints } from '@novu/shared';
import { IsNotEmpty, Matches, MaxLength, MinLength } from 'class-validator';

export class UpdatePasswordBodyDto {
@IsNotEmpty()
@MinLength(passwordConstraints.minLength)
@MaxLength(passwordConstraints.maxLength)
@Matches(passwordConstraints.pattern, {
message:
'The new password must contain minimum 8 and maximum 64 characters,' +
' at least one uppercase letter, one lowercase letter, one number and one special character #?!@$%^&*()-',
})
newPassword: string;

@IsNotEmpty()
confirmPassword: string;

@IsNotEmpty()
currentPassword: string;
}
19 changes: 18 additions & 1 deletion apps/api/src/app/auth/e2e/password-reset.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { expect } from 'chai';
import { stub, SinonStubbedMember } from 'sinon';
import { subDays, subMinutes } from 'date-fns';
import { PasswordResetFlowEnum } from '@novu/shared';

describe('Password reset - /auth/reset (POST)', async () => {
let session: UserSession;
Expand Down Expand Up @@ -37,7 +38,7 @@ describe('Password reset - /auth/reset (POST)', async () => {
await session.initialize();
});

it('should request a password reset for existing user', async () => {
it('should request a password reset for existing user with no query param', async () => {
const { body } = await session.testAgent.post('/v1/auth/reset/request').send({
email: session.user.email,
});
Expand All @@ -48,6 +49,22 @@ describe('Password reset - /auth/reset (POST)', async () => {
expect(found.resetToken).to.be.ok;
});

Object.values(PasswordResetFlowEnum)
.map(String)
.forEach((src) => {
it(`should request a password reset for existing user with a src query param specified: ${src}`, async () => {
const url = `/v1/auth/reset/request?src=${src}`;
const { body } = await session.testAgent.post(url).send({
email: session.user.email,
});

expect(body.data.success).to.equal(true);
const found = await userRepository.findById(session.user._id);

expect(found.resetToken).to.be.ok;
});
});

it('should request a password reset for existing user with uppercase email', async () => {
const { body } = await session.testAgent.post('/v1/auth/reset/request').send({
email: session.user.email.toUpperCase(),
Expand Down
136 changes: 136 additions & 0 deletions apps/api/src/app/auth/e2e/update-password.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { IJwtPayload } from '@novu/shared';
import { CYPRESS_USER_PASSWORD, UserSession } from '@novu/testing';
import { expect } from 'chai';
import * as jwt from 'jsonwebtoken';

const NEW_PASSWORD = 'newPassword123@';
const PASSWORD_ERROR_MESSAGE =
'The new password must contain minimum 8 and maximum 64 characters,' +
' at least one uppercase letter, one lowercase letter, one number and one special character #?!@$%^&*()-';

describe('User update password - /auth/update-password (POST)', async () => {
let session: UserSession;

before(async () => {
session = new UserSession();
await session.initialize();
});

it('should update password', async () => {
const { statusCode } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: NEW_PASSWORD,
confirmPassword: NEW_PASSWORD,
});

expect(statusCode).to.equal(204);

const { body: loginBody } = await session.testAgent.post('/v1/auth/login').send({
email: session.user.email,
password: NEW_PASSWORD,
});

const jwtContent = (await jwt.decode(loginBody.data.token)) as IJwtPayload;

expect(jwtContent.firstName).to.equal(session.user.firstName);
expect(jwtContent.lastName).to.equal(session.user.lastName);
expect(jwtContent.email).to.equal(session.user.email);
});

it('should fail on bad current password', async () => {
const { body } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: '123123213',
newPassword: NEW_PASSWORD,
confirmPassword: NEW_PASSWORD,
});

expect(body.statusCode).to.equal(401);
expect(body.message).to.contain('Unauthorized');
});

it('should fail on mismatched passwords', async () => {
const { body } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: NEW_PASSWORD,
confirmPassword: '123123213',
});

expect(body.statusCode).to.equal(400);
expect(body.message).to.contain('Passwords do not match');
});

it('should fail on bad password', async () => {
const { body: validLengthBody } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: '12345678',
confirmPassword: '12345678',
});

expect(validLengthBody.statusCode).to.equal(400);
expect(validLengthBody.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);
});

it('should fail on password missing upper case letter', async () => {
const { body } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: 'abcde@12345',
confirmPassword: 'abcde@12345',
});

expect(body.statusCode).to.equal(400);
expect(body.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);
});

it('should fail on password missing lower case letter', async () => {
const { body } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: 'ABCDE@12345',
confirmPassword: 'ABCDE@12345',
});

expect(body.statusCode).to.equal(400);
expect(body.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);
});

it('should fail on password missing special characters', async () => {
const { body } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: 'ABCabc12345',
confirmPassword: 'ABCabc12345',
});

expect(body.statusCode).to.equal(400);
expect(body.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);
});

it('should fail on password missing numbers', async () => {
const { body } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: 'ABCabc@ABCDE',
confirmPassword: 'ABCabc@ABCDE',
});

expect(body.statusCode).to.equal(400);
expect(body.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);
});

it('should fail if password length is less than 8 or more then 64', async () => {
const { body: minimumLengthBody } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: '123',
confirmPassword: '123',
});

expect(minimumLengthBody.statusCode).to.equal(400);
expect(minimumLengthBody.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);

const { body: maxLengthBody } = await session.testAgent.post('/v1/auth/update-password').send({
currentPassword: CYPRESS_USER_PASSWORD,
newPassword: 'Ab1@'.repeat(20),
confirmPassword: 'Ab1@'.repeat(20),
});

expect(maxLengthBody.statusCode).to.equal(400);
expect(maxLengthBody.message[0]).to.equal(PASSWORD_ERROR_MESSAGE);
});
});
2 changes: 2 additions & 0 deletions apps/api/src/app/auth/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PasswordResetRequest } from './password-reset-request/password-reset-re
import { UserRegister } from './register/user-register.usecase';
import { Login } from './login/login.usecase';
import { PasswordReset } from './password-reset/password-reset.usecase';
import { UpdatePassword } from './update-password/update-password.usecase';

export const USE_CASES = [
//
Expand All @@ -13,4 +14,5 @@ export const USE_CASES = [
SwitchOrganization,
PasswordResetRequest,
PasswordReset,
UpdatePassword,
];
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { IsDefined, IsEmail } from 'class-validator';
import { IsDefined, IsEmail, IsEnum, IsOptional } from 'class-validator';
import { PasswordResetFlowEnum } from '@novu/shared';
import { BaseCommand } from '../../../shared/commands/base.command';

export class PasswordResetRequestCommand extends BaseCommand {
@IsEmail()
@IsDefined()
email: string;

@IsEnum(PasswordResetFlowEnum)
@IsOptional()
src?: PasswordResetFlowEnum;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { buildUserKey, InvalidateCacheService } from '@novu/application-generic'

import { normalizeEmail } from '../../../shared/helpers/email-normalization.service';
import { PasswordResetRequestCommand } from './password-reset-request.command';
import { PasswordResetFlowEnum } from '@novu/shared';

@Injectable()
export class PasswordResetRequest {
Expand Down Expand Up @@ -37,14 +38,15 @@ export class PasswordResetRequest {

if ((process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'production') && process.env.NOVU_API_KEY) {
const novu = new Novu(process.env.NOVU_API_KEY);
const resetPasswordLink = PasswordResetRequest.getResetRedirectLink(token, foundUser, command.src);

novu.trigger(process.env.NOVU_TEMPLATEID_PASSWORD_RESET || 'password-reset-llS-wzWMq', {
to: {
subscriberId: foundUser._id,
email: foundUser.email,
},
payload: {
resetPasswordLink: `${process.env.FRONT_BASE_URL}/auth/reset/${token}`,
resetPasswordLink,
},
});
}
Expand All @@ -55,6 +57,21 @@ export class PasswordResetRequest {
};
}

private static getResetRedirectLink(token: string, user: UserEntity, src?: PasswordResetFlowEnum): string {
// ensure that only users without passwords are allowed to reset
if (src === PasswordResetFlowEnum.USER_PROFILE && !user.password) {
return `${process.env.FRONT_BASE_URL}/settings/profile?token=${token}`;
}

/**
* Default to the existing "forgot password flow". Works for:
* 1. No src
* 2. When src is explicitly FORGOT_PASSWORD
* 3. User already has a password
*/
return `${process.env.FRONT_BASE_URL}/auth/reset/${token}`;
}

private isRequestBlocked(user: UserEntity) {
const lastResetAttempt = user.resetTokenDate;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { passwordConstraints } from '@novu/shared';
import { IsNotEmpty, Matches, MaxLength, MinLength } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';

export class UpdatePasswordCommand extends EnvironmentWithUserCommand {
@IsNotEmpty()
@MinLength(passwordConstraints.minLength)
@MaxLength(passwordConstraints.maxLength)
@Matches(passwordConstraints.pattern, {
message:
'The new password must contain minimum 8 and maximum 64 characters,' +
' at least one uppercase letter, one lowercase letter, one number and one special character #?!@$%^&*()-',
})
newPassword: string;

@IsNotEmpty()
confirmPassword: string;

@IsNotEmpty()
currentPassword: string;
}
Loading

0 comments on commit c17dde5

Please sign in to comment.