diff --git a/.env b/.env index 83b3cbeda..c045f4e05 100644 --- a/.env +++ b/.env @@ -16,6 +16,8 @@ TARGET_ENV=development ############## API_PORT=5010 APP_URL=https://dev.podkrepi.bg +APP_URL_LOCAL=http://localhost:3040 + ## Database ## ############## diff --git a/.env.local.example b/.env.local.example index aaf12a64e..7f1c4e4e9 100644 --- a/.env.local.example +++ b/.env.local.example @@ -4,3 +4,4 @@ STRIPE_SECRET_KEY=stripe-key STRIPE_WEBHOOK_SECRET=stripe-secret S3_ACCESS_KEY=s3-access-key S3_SECRET_ACCESS_KEY=s3-secret-access-key +JWT_SECRET_KEY = VerySecretPrivetKey diff --git a/apps/api/src/account/account.controller.spec.ts b/apps/api/src/account/account.controller.spec.ts index 5699e937d..7b48561da 100644 --- a/apps/api/src/account/account.controller.spec.ts +++ b/apps/api/src/account/account.controller.spec.ts @@ -11,6 +11,8 @@ import { HttpService } from '@nestjs/axios' import { KEYCLOAK_INSTANCE } from 'nest-keycloak-connect' import KeycloakConnect from 'keycloak-connect' import { mock, mockDeep } from 'jest-mock-extended' +import { JwtService } from '@nestjs/jwt' +import { EmailService } from '../email/email.service' describe('AccountController', () => { let controller: AccountController @@ -56,6 +58,14 @@ describe('AccountController', () => { provide: KEYCLOAK_INSTANCE, useValue: mock(), }, + { + provide: JwtService, + useValue: mockDeep(), + }, + { + provide: EmailService, + useValue: mockDeep(), + }, ], }) .overrideProvider(ConfigService) diff --git a/apps/api/src/account/account.service.spec.ts b/apps/api/src/account/account.service.spec.ts index 7a6786d62..02a7ccb12 100644 --- a/apps/api/src/account/account.service.spec.ts +++ b/apps/api/src/account/account.service.spec.ts @@ -1,11 +1,13 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client' import { HttpService } from '@nestjs/axios' import { ConfigService } from '@nestjs/config' +import { JwtService } from '@nestjs/jwt' import { Test, TestingModule } from '@nestjs/testing' import { mock, mockDeep } from 'jest-mock-extended' import KeycloakConnect from 'keycloak-connect' import { KEYCLOAK_INSTANCE } from 'nest-keycloak-connect' import { AuthService } from '../auth/auth.service' +import { EmailService } from '../email/email.service' import { PersonService } from '../person/person.service' import { MockPrismaService } from '../prisma/prisma-client.mock' import { AccountService } from './account.service' @@ -43,6 +45,14 @@ describe('AccountService', () => { provide: KEYCLOAK_INSTANCE, useValue: mock(), }, + { + provide: JwtService, + useValue: mockDeep(), + }, + { + provide: EmailService, + useValue: mockDeep(), + }, ], }).compile() diff --git a/apps/api/src/assets/templates/forgot-password.json b/apps/api/src/assets/templates/forgot-password.json new file mode 100644 index 000000000..7dab025be --- /dev/null +++ b/apps/api/src/assets/templates/forgot-password.json @@ -0,0 +1,3 @@ +{ + "subject": "Забравена парола за Подкрепи.бг" +} diff --git a/apps/api/src/assets/templates/forgot-password.mjml b/apps/api/src/assets/templates/forgot-password.mjml new file mode 100644 index 000000000..e39d93fb0 --- /dev/null +++ b/apps/api/src/assets/templates/forgot-password.mjml @@ -0,0 +1,55 @@ + + + + + + + + Здравейте {{firstName}} {{lastName}}, +

+
+ + някой (може би Вие) е пуснал заявка за смяна на парола за достъп до Подкрепи.бг.

+ Ако не сте пускали подобна заявка, не е необходимо да правите нищо.

+ Ако Вие сте поискали възстановяване на паролата, натиснете бутона „Нова парола“.

+
+ Нова парола + + + Поздрави,
+ Екипът на Подкрепи.бг +
+
+
+
+
diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index f3c69fda3..3fa94a065 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -10,13 +10,17 @@ import { KeycloakConfigService } from '../config/keycloak-config.service' import { RefreshController } from './refresh.controller' import { HttpModule } from '@nestjs/axios' import { ProviderLoginController } from './provider-login.controller' +import { JwtModule, JwtService } from '@nestjs/jwt' +import { EmailService } from '../email/email.service' +import { TemplateService } from '../email/template.service' @Module({ controllers: [LoginController, RegisterController, RefreshController, ProviderLoginController], - providers: [AuthService, PrismaService], + providers: [AuthService, PrismaService, EmailService, JwtService, TemplateService], imports: [ AppConfigModule, HttpModule, + JwtModule, KeycloakConnectModule.registerAsync({ useExisting: KeycloakConfigService, imports: [AppConfigModule], diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 1329ddc8d..426e4607b 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -16,6 +16,9 @@ import { MockPrismaService, prismaMock } from '../prisma/prisma-client.mock' import { RefreshDto } from './dto/refresh.dto' import { firstValueFrom, Observable } from 'rxjs' import { ProviderDto } from './dto/provider.dto' +import { EmailService } from '../email/email.service' +import { JwtService } from '@nestjs/jwt' +import { TemplateService } from '../email/template.service' jest.mock('@keycloak/keycloak-admin-client') @@ -25,6 +28,9 @@ describe('AuthService', () => { let admin: KeycloakAdminClient let httpService: HttpService let keycloak: KeycloakConnect.Keycloak + let sendEmail: EmailService + let jwtService: JwtService + let templateService: TemplateService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -53,6 +59,18 @@ describe('AuthService', () => { provide: KEYCLOAK_INSTANCE, useValue: mockDeep(), }, + { + provide: JwtService, + useValue: mockDeep(), + }, + { + provide: EmailService, + useValue: mockDeep(), + }, + { + provide: TemplateService, + useValue: mockDeep(), + }, ], }).compile() @@ -61,6 +79,9 @@ describe('AuthService', () => { admin = module.get(KeycloakAdminClient) keycloak = module.get(KEYCLOAK_INSTANCE) httpService = module.get(HttpService) + sendEmail = module.get(EmailService) + jwtService = module.get(JwtService) + templateService = module.get(TemplateService) }) it('should be defined', () => { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d78935cff..0dddd708c 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,8 +1,10 @@ import { + BadRequestException, Inject, Injectable, InternalServerErrorException, Logger, + NotFoundException, UnauthorizedException, } from '@nestjs/common' import { HttpService } from '@nestjs/axios' @@ -23,6 +25,11 @@ import { RefreshDto } from './dto/refresh.dto' import { KeycloakTokenParsed } from './keycloak' import { ProviderDto } from './dto/provider.dto' import { UpdatePersonDto } from '../person/dto/update-person.dto' +import { ForgottenPasswordEmailDto } from './dto/forgot-password.dto' +import { JwtService } from '@nestjs/jwt' +import { EmailService } from '../email/email.service' +import { ForgottenPasswordMailDto } from '../email/template.interface' +import { NewPasswordDto } from './dto/recovery-password.dto' type ErrorResponse = { error: string; data: unknown } type KeycloakErrorResponse = { error: string; error_description: string } @@ -50,6 +57,8 @@ export class AuthService { private readonly admin: KeycloakAdminClient, private readonly prismaService: PrismaService, private readonly httpService: HttpService, + private jwtService: JwtService, + private sendEmail: EmailService, @Inject(KEYCLOAK_INSTANCE) private keycloak: KeycloakConnect.Keycloak, ) {} @@ -273,4 +282,50 @@ export class AuthService { }, ) } + + async sendMailForPasswordChange(forgotPasswordDto: ForgottenPasswordEmailDto) { + const stage = this.config.get('APP_ENV') === 'development' ? 'APP_URL_LOCAL' : 'APP_URL' + const user = await this.prismaService.person.findFirst({ + where: { email: forgotPasswordDto.email }, + }) + if (!user) { + throw new NotFoundException('Invalid email') + } + const payload = { username: user.email, sub: user.keycloakId } + const jtwSecret = process.env.JWT_SECRET_KEY + const access_token = this.jwtService.sign(payload, { + secret: jtwSecret, + expiresIn: '60m', + }) + const appUrl = this.config.get(stage) + const link = `${appUrl}/change-password?token=${access_token}` + const profile = { + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + link: link, + } + const userEmail = { to: [user.email] } + const mail = new ForgottenPasswordMailDto(profile) + await this.sendEmail.sendFromTemplate(mail, userEmail) + } + + async updateForgottenPassword(recoveryPasswordDto: NewPasswordDto) { + try { + const { sub: keycloakId } = this.jwtService.verify(recoveryPasswordDto.token, { + secret: process.env.JWT_SECRET_KEY, + }) + return await this.updateUserPassword(keycloakId, recoveryPasswordDto) + } catch (error) { + const response = { + error: error.message, + data: error?.response?.data, + } + throw response.data + ? new NotFoundException(response.data) + : new BadRequestException( + 'The forgotten password link has expired, request a new link and try again!', + ) + } + } } diff --git a/apps/api/src/auth/dto/forgot-password.dto.ts b/apps/api/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 000000000..6cbb1697d --- /dev/null +++ b/apps/api/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsEmail, IsNotEmpty } from 'class-validator' + +export class ForgottenPasswordEmailDto { + @ApiProperty() + @Expose() + @IsNotEmpty() + @IsEmail() + public readonly email: string +} diff --git a/apps/api/src/auth/dto/recovery-password.dto.ts b/apps/api/src/auth/dto/recovery-password.dto.ts new file mode 100644 index 000000000..9ac54d56b --- /dev/null +++ b/apps/api/src/auth/dto/recovery-password.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsJWT, IsNotEmpty, IsString } from 'class-validator' + +export class NewPasswordDto { + @ApiProperty() + @Expose() + @IsNotEmpty() + @IsJWT() + public readonly token: string + + @ApiProperty() + @Expose() + @IsNotEmpty() + @IsString() + public readonly password: string +} diff --git a/apps/api/src/auth/login.controller.ts b/apps/api/src/auth/login.controller.ts index bc4396a2b..0da29bddb 100644 --- a/apps/api/src/auth/login.controller.ts +++ b/apps/api/src/auth/login.controller.ts @@ -2,7 +2,9 @@ import { Body, Controller, Post } from '@nestjs/common' import { Public, Resource, Scopes } from 'nest-keycloak-connect' import { AuthService } from './auth.service' +import { ForgottenPasswordEmailDto } from './dto/forgot-password.dto' import { LoginDto } from './dto/login.dto' +import { NewPasswordDto } from './dto/recovery-password.dto' @Controller('login') @Resource('login') @@ -15,4 +17,15 @@ export class LoginController { async login(@Body() loginDto: LoginDto) { return await this.authService.login(loginDto) } + + @Post('/forgot-password') + @Public() + async forgotPassword(@Body() forgotPasswordDto: ForgottenPasswordEmailDto) { + return await this.authService.sendMailForPasswordChange(forgotPasswordDto) + } + @Post('/reset-password') + @Public() + async recoveryPassword(@Body() RecoveryPasswordDto: NewPasswordDto) { + return await this.authService.updateForgottenPassword(RecoveryPasswordDto) + } } diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts index bcc251ad1..e58c55b24 100644 --- a/apps/api/src/email/template.interface.ts +++ b/apps/api/src/email/template.interface.ts @@ -1,3 +1,4 @@ +import { CreatePersonDto } from '../person/dto/create-person.dto' import { CreateInquiryDto } from '../support/dto/create-inquiry.dto' import { CreateRequestDto } from '../support/dto/create-request.dto' @@ -6,6 +7,7 @@ export enum TemplateType { welcomeInternal = 'welcome-internal', inquiryReceived = 'inquiry-received', inquiryReceivedInternal = 'inquiry-received-internal', + forgotPass = 'forgot-password', } export type TemplateTypeKeys = keyof typeof TemplateType export type TemplateTypeValues = typeof TemplateType[TemplateTypeKeys] @@ -28,6 +30,10 @@ export abstract class EmailTemplate { } } +export class ForgottenPasswordMailDto extends EmailTemplate { + name = TemplateType.forgotPass +} + export class WelcomeEmailDto extends EmailTemplate { name = TemplateType.welcome } diff --git a/package.json b/package.json index e52b36625..68f389df6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@nestjs/common": "8.4.5", "@nestjs/config": "2.0.0", "@nestjs/core": "8.4.5", + "@nestjs/jwt": "^9.0.0", "@nestjs/platform-express": "8.4.5", "@nestjs/swagger": "5.2.1", "@nestjs/terminus": "8.0.6", diff --git a/yarn.lock b/yarn.lock index 5b142d955..9b5a65a1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -884,6 +884,14 @@ tslib "2.3.0" uuid "8.3.2" +"@nestjs/jwt@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@nestjs/jwt/-/jwt-9.0.0.tgz#73e01338d2853a55033528b540cfd92c7996bae9" + integrity sha512-ZsXGY/wMYKzEhymw2+dxiwrHTRKIKrGszx6r2EjQqNLypdXMQu0QrujwZJ8k3+XQV4snmuJwwNakQoA2ILfq8w== + dependencies: + "@types/jsonwebtoken" "8.5.8" + jsonwebtoken "8.5.1" + "@nestjs/mapped-types@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-1.0.0.tgz#52a0441795f6da8144a35970d3ebc19281f31cfd" @@ -1905,6 +1913,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonwebtoken@8.5.8": + version "8.5.8" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44" + integrity sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A== + dependencies: + "@types/node" "*" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -2751,6 +2766,11 @@ buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -3636,6 +3656,13 @@ dotenv@~10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + editorconfig@^0.15.3: version "0.15.3" resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" @@ -5697,6 +5724,22 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + juice@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/juice/-/juice-7.0.0.tgz#509bed6adbb6e4bbaa7fbfadac4e2e83e8c89ba3" @@ -5708,6 +5751,15 @@ juice@^7.0.0: slick "^1.12.2" web-resource-inliner "^5.0.0" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jwk-to-pem@^2.0.0: version "2.0.5" resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906" @@ -5717,6 +5769,14 @@ jwk-to-pem@^2.0.0: elliptic "^6.5.4" safe-buffer "^5.0.1" +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + keycloak-connect@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/keycloak-connect/-/keycloak-connect-18.0.0.tgz#ad8a2e1af691f7fdee3ff15f84e05d86cdd5a218" @@ -5857,16 +5917,46 @@ lodash.flatten@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.union@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" @@ -6440,7 +6530,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3: +ms@2.1.3, ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==