diff --git a/package.json b/package.json index e74c316..f1e471b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "@prisma/client": "^3.6.0", "@stitches/react": "^1.2.6", "axios": "^0.24.0", + "bcryptjs": "^2.4.3", + "dotenv": "^10.0.0", + "jsonwebtoken": "^8.5.1", "lodash": "^4.17.21", "next": "12.0.7", "next-connect": "^0.11.0", @@ -35,6 +38,8 @@ "devDependencies": { "@commitlint/cli": "15.0.0", "@commitlint/config-conventional": "15.0.0", + "@types/bcryptjs": "^2.4.2", + "@types/jsonwebtoken": "^8.5.6", "@types/lodash": "^4.14.178", "@types/node": "^16.11.12", "@types/react": "17.0.37", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3534b8..8cfb0d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,3 +11,22 @@ model Data { id Int @id @default(autoincrement()) data String } + +model User { + id Int @id @default(autoincrement()) + username String @unique + password String + role Role +} + +model Session { + id String @id @default(cuid()) + userId Int + expiresAt DateTime + createdAt DateTime @default(now()) +} + +enum Role { + ADMIN + EDITOR +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 3c2134a..27a886c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,7 +1,13 @@ import { PrismaClient } from '@prisma/client' +import { hashSync } from 'bcryptjs' +import dotenv from 'dotenv' const prisma = new PrismaClient() +dotenv.config({ + path: './.env' +}) + async function main() { const data = { settings: { @@ -62,9 +68,7 @@ async function main() { } const hasData = await prisma.data.findUnique({ - where: { - id: 1 - } + where: { id: 1 } }) if (!hasData) { @@ -77,6 +81,26 @@ async function main() { } }) } + + const hasUser = await prisma.user.findFirst() + + if (!hasUser) { + console.log(`Start seeding user...`) + + const { USERNAME, PASSWORD } = process.env + + if (!USERNAME || !PASSWORD) { + throw new Error('USERNAME and PASSWORD env variables are required') + } + + await prisma.user.create({ + data: { + username: USERNAME, + password: hashSync(PASSWORD, 10), + role: 'ADMIN' + } + }) + } } main() diff --git a/src/pages/api/auth/index.ts b/src/pages/api/auth/index.ts new file mode 100644 index 0000000..4795adf --- /dev/null +++ b/src/pages/api/auth/index.ts @@ -0,0 +1,58 @@ +import { Prisma } from '@prisma/client' +import { compare } from 'bcryptjs' +import type { NextApiRequest, NextApiResponse } from 'next' +import nc from 'next-connect' + +import { prisma } from '../../../services/prisma' +import { createSession } from '../../../utils/createSession' +import { ExceptionError } from '../../../utils/error' + +const handler = nc({ + onNoMatch: (_req, res) => { + res.status(404).json({ error: 'Not found' }) + }, + onError: (err, _req, res) => { + if (err instanceof ExceptionError) { + res.status(err.status).json({ + status: err.status, + error: err.message + }) + } else if (err instanceof Prisma.PrismaClientKnownRequestError) { + res.status(400).json({ + status: 400, + error: err.message + }) + } else { + res.status(500).json({ + status: 500, + error: err.message + }) + } + } +}).post(async (req, res) => { + const { username, password } = req.body + + if (!username || !password) { + throw new ExceptionError('Username and password are required') + } + + const user = await prisma.user.findUnique({ + where: { username } + }) + + if (!user) { + throw new ExceptionError('Credentials are invalid', 401) + } + + const isValidPassword = await compare(password, user.password) + + if (!isValidPassword) { + throw new ExceptionError('Credentials are invalid', 401) + } + + const { token, refreshToken } = await createSession(user) + + res.status(200).json({ token, refreshToken }) +}) + +export default handler diff --git a/src/pages/api/auth/refresh-token.ts b/src/pages/api/auth/refresh-token.ts new file mode 100644 index 0000000..613173b --- /dev/null +++ b/src/pages/api/auth/refresh-token.ts @@ -0,0 +1,73 @@ +import { Prisma } from '@prisma/client' +import type { NextApiRequest, NextApiResponse } from 'next' +import nc from 'next-connect' + +import { prisma } from '../../../services/prisma' +import { createSession } from '../../../utils/createSession' +import { ExceptionError } from '../../../utils/error' + +const handler = nc({ + onNoMatch: (_req, res) => { + res.status(404).json({ error: 'Not found' }) + }, + onError: (err, _req, res) => { + if (err instanceof ExceptionError) { + res.status(err.status).json({ + status: err.status, + error: err.message + }) + } else if (err instanceof Prisma.PrismaClientKnownRequestError) { + res.status(400).json({ + status: 400, + error: err.message + }) + } else { + res.status(500).json({ + status: 500, + error: err.message + }) + } + } +}).post(async (req, res) => { + const { refreshToken } = req.body + + if (!refreshToken) { + throw new ExceptionError('Refresh Token required') + } + + const session = await prisma.session.findUnique({ + where: { id: refreshToken } + }) + + if (!session) { + throw new ExceptionError('Refresh Token are invalid', 401) + } + + const isSessionExpired = new Date(session.expiresAt) < new Date() + + if (isSessionExpired) { + await prisma.session.delete({ + where: { id: refreshToken } + }) + + throw new ExceptionError('Refresh Token are expired', 401) + } + + const user = await prisma.user.findUnique({ + where: { id: session.userId } + }) + + if (!user) { + throw new ExceptionError('User not found', 401) + } + + const tokens = await createSession(user) + + await prisma.session.delete({ + where: { id: refreshToken } + }) + + res.status(200).json({ token: tokens.token, refreshToken: tokens.refreshToken }) +}) + +export default handler diff --git a/src/pages/api/data.ts b/src/pages/api/data.ts index c232dc7..118ca69 100644 --- a/src/pages/api/data.ts +++ b/src/pages/api/data.ts @@ -1,14 +1,31 @@ +import { Prisma } from '@prisma/client' import type { NextApiRequest, NextApiResponse } from 'next' import nc from 'next-connect' import { prisma } from '../../services/prisma' +import { ExceptionError } from '../../utils/error' const handler = nc({ onNoMatch: (_req, res) => { res.status(404).json({ error: 'Not found' }) }, onError: (err, _req, res) => { - res.status(500).json({ error: err.message }) + if (err instanceof ExceptionError) { + res.status(err.status).json({ + status: err.status, + error: err.message + }) + } else if (err instanceof Prisma.PrismaClientKnownRequestError) { + res.status(400).json({ + status: 400, + error: err.message + }) + } else { + res.status(500).json({ + status: 500, + error: err.message + }) + } } }) .get(async (_req, res) => { @@ -17,7 +34,7 @@ const handler = nc({ }) if (!response) { - throw new Error('No data found') + throw new ExceptionError('No data found') } res.status(200).json(response?.data) @@ -27,7 +44,7 @@ const handler = nc({ const { data } = req.body if (!data) { - throw new Error('No data provided') + throw new ExceptionError('No data provided') } try { @@ -38,8 +55,7 @@ const handler = nc({ res.status(200).json(response) } catch (err: any) { - console.error(err) - res.status(500).json({ error: err.message }) + throw new ExceptionError(err) } }) diff --git a/src/pages/api/users/[id].ts b/src/pages/api/users/[id].ts new file mode 100644 index 0000000..42928e0 --- /dev/null +++ b/src/pages/api/users/[id].ts @@ -0,0 +1,144 @@ +import { Prisma } from '@prisma/client' +import { compare, hashSync } from 'bcryptjs' +import type { NextApiRequest, NextApiResponse } from 'next' +import nc from 'next-connect' + +import { prisma } from '../../../services/prisma' +import { ExceptionError } from '../../../utils/error' + +const handler = nc({ + onNoMatch: (_req, res) => { + res.status(404).json({ error: 'Not found' }) + }, + onError: (err, _req, res) => { + if (err instanceof ExceptionError) { + res.status(err.status).json({ + status: err.status, + error: err.message + }) + } else if (err instanceof Prisma.PrismaClientKnownRequestError) { + res.status(400).json({ + status: 400, + error: err.message + }) + } else { + res.status(500).json({ + status: 500, + error: err.message + }) + } + } +}) + .get(async (req, res) => { + const { id } = req.query + + if (!id) { + throw new ExceptionError('No id provided') + } + + const response = await prisma.user.findUnique({ + where: { id: +id }, + select: { + id: true, + username: true, + role: true + } + }) + + if (!response) { + throw new ExceptionError('No data found', 404) + } + + res.status(200).json(response) + }) + + .patch(async (req, res) => { + const { id } = req.query + const { username, password, newPassword, role } = req.body + const user = await prisma.user.findUnique({ + where: { id: +id } + }) + + let updatedUser = {} + + if (!user) { + throw new ExceptionError('No user found', 404) + } + + if (!id) { + throw new ExceptionError('No id provided') + } + + if (username) { + const usernameExists = await prisma.user.findUnique({ + where: { username } + }) + + if (usernameExists && usernameExists.id !== +id) { + throw new ExceptionError('Username already exists') + } + + updatedUser = { ...updatedUser, username } + } + + if (password && newPassword) { + const isValidPassword = await compare(password, user.password) + + if (!isValidPassword) { + throw new ExceptionError('Password does not match') + } + + updatedUser = { password: hashSync(newPassword, 10) } + } + + if (role) { + if (!['ADMIN', 'EDITOR'].includes(role)) { + throw new ExceptionError('Role must be either ADMIN or EDITOR') + } + + if (user.role === 'ADMIN' && role === 'EDITOR') { + throw new ExceptionError('Cannot downgrade admin role', 403) + } + + if (user.role === 'EDITOR') { + throw new ExceptionError('Cannot change role', 403) + } + + updatedUser = { ...updatedUser, role } + } + + try { + const response = await prisma.data.update({ + where: { id: +id }, + data: { ...updatedUser } + }) + + res.status(200).json(response) + } catch (err: any) { + throw new ExceptionError(err) + } + }) + + .delete(async (req, res) => { + const { id } = req.query + + if (!id) { + throw new ExceptionError('No id provided') + } + + if (id === '1') { + throw new ExceptionError('Cannot delete admin', 403) + } + + try { + await prisma.data.delete({ + where: { id: +id } + }) + + res.status(204).end() + } catch (err: any) { + throw new ExceptionError(err) + } + }) + +export default handler diff --git a/src/pages/api/users/index.ts b/src/pages/api/users/index.ts new file mode 100644 index 0000000..3eeb639 --- /dev/null +++ b/src/pages/api/users/index.ts @@ -0,0 +1,84 @@ +import { Prisma } from '@prisma/client' +import { hashSync } from 'bcryptjs' +import type { NextApiRequest, NextApiResponse } from 'next' +import nc from 'next-connect' + +import { prisma } from '../../../services/prisma' +import { ExceptionError } from '../../../utils/error' + +const handler = nc({ + onNoMatch: (_req, res) => { + res.status(404).json({ error: 'Not found' }) + }, + onError: (err, _req, res) => { + if (err instanceof ExceptionError) { + res.status(err.status).json({ + status: err.status, + error: err.message + }) + } else if (err instanceof Prisma.PrismaClientKnownRequestError) { + res.status(400).json({ + status: 400, + error: err.message + }) + } else { + res.status(500).json({ + status: 500, + error: err.message + }) + } + } +}) + .get(async (_req, res) => { + const response = await prisma.user.findMany({ + select: { + id: true, + username: true, + role: true + } + }) + + if (!response) { + throw new ExceptionError('No data found') + } + + res.status(200).json(response) + }) + + .post(async (req, res) => { + const { username, password, role } = req.body + + if (!username || !password) { + throw new ExceptionError('Username and password are required') + } + + if (!['ADMIN', 'EDITOR'].includes(role)) { + throw new ExceptionError('Role must be either ADMIN or EDITOR') + } + + // TODO implement role validation + + const usernameExists = await prisma.user.findUnique({ + where: { username } + }) + + if (usernameExists) { + throw new ExceptionError('Username already exists') + } + + try { + const response = await prisma.user.create({ + data: { + username, + password: hashSync(password, 10), + role: role ? role : 'EDITOR' + } + }) + + res.status(200).json(response) + } catch (err: any) { + throw new ExceptionError(err) + } + }) + +export default handler diff --git a/src/utils/createSession.ts b/src/utils/createSession.ts new file mode 100644 index 0000000..26c049c --- /dev/null +++ b/src/utils/createSession.ts @@ -0,0 +1,34 @@ +import { User } from '@prisma/client' +import jwt from 'jsonwebtoken' + +import { prisma } from '../services/prisma' +import { ExceptionError } from './error' + +const EXPIRES_IN = 30 // days +const EXPIRES_IN_MS = EXPIRES_IN * 24 * 60 * 60 * 1000 + +export async function createSession(user: User) { + const { JWT_SECRET } = process.env + + if (!JWT_SECRET) { + throw new ExceptionError('JWT_SECRET is not defined') + } + + try { + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + EXPIRES_IN_MS) + } + }) + + const payload = { role: user.role, sub: user.id } + const token = jwt.sign(payload, JWT_SECRET, { + expiresIn: '15m' + }) + + return { token, refreshToken: session.id } + } catch (err: any) { + throw new ExceptionError(err) + } +} diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..ba2e543 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,9 @@ +export class ExceptionError extends Error { + status: number + + constructor(message: string, status: number = 400) { + super(message) + this.name = 'ExceptionError' + this.status = status + } +} diff --git a/yarn.lock b/yarn.lock index df56313..57c91d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -450,11 +450,23 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@types/bcryptjs@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/bcryptjs/-/bcryptjs-2.4.2.tgz#e3530eac9dd136bfdfb0e43df2c4c5ce1f77dfae" + integrity sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/jsonwebtoken@^8.5.6": + version "8.5.6" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.6.tgz#1913e5a61e70a192c5a444623da4901a7b1a9d42" + integrity sha512-+P3O/xC7nzVizIi5VbF34YtqSonFsdnbXBnWUCYRiKOi1f9gA4sEFvXkrGr/QVV23IbMYvcoerI7nnhDUiWXRQ== + dependencies: + "@types/node" "*" + "@types/lodash@^4.14.178": version "4.14.178" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" @@ -800,6 +812,11 @@ base64-js@^1.0.2, base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bcryptjs@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" + integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms= + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -921,6 +938,11 @@ browserslist@4.16.6: escalade "^3.1.1" node-releases "^1.1.71" +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 sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -1466,6 +1488,18 @@ dot-prop@^5.1.0: dependencies: is-obj "^2.0.0" +dotenv@^10.0.0: + version "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" + electron-to-chromium@^1.3.723: version "1.4.16" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.16.tgz#38ddecc616385e6f101359d1b978c802664157d2" @@ -2598,6 +2632,22 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= +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" + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz#720b97bfe7d901b927d87c3773637ae8ea48781b" @@ -2606,6 +2656,23 @@ jsonparse@^1.2.0: array-includes "^3.1.3" object.assign "^4.1.2" +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" + +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" + kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -2711,11 +2778,46 @@ lodash.get@^4: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= + +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 sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + 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 sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -3736,7 +3838,7 @@ scheduler@^0.20.2: loose-envify "^1.1.0" object-assign "^4.1.1" -"semver@2 || 3 || 4 || 5": +"semver@2 || 3 || 4 || 5", semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==