Feat/plugin users with jwt (#258)
* feat: plugin users with jwt * update github actions env * feat: get jwt config from options * feat: jwtService * fix: type * fix: build error * fix: yarn repository * fix: yarn build * fix: yarn build
This commit is contained in:
parent
25339a4240
commit
7e4b60c410
@ -12,6 +12,7 @@ export interface Context extends Koa.Context {
|
||||
db: Database;
|
||||
action: Action;
|
||||
body: any;
|
||||
app: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
@ -8,9 +8,12 @@
|
||||
"build:cjs": "tsc --project tsconfig.build.json",
|
||||
"build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^8.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nocobase/test": "^0.6.0-alpha.0"
|
||||
"@nocobase/test": "^0.6.0-alpha.0",
|
||||
"@types/jsonwebtoken": "^8.5.8"
|
||||
},
|
||||
"gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4"
|
||||
}
|
||||
|
51
packages/plugin-users/src/__tests__/actions.test.ts
Normal file
51
packages/plugin-users/src/__tests__/actions.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import Database from '@nocobase/database';
|
||||
import PluginACL from '@nocobase/plugin-acl';
|
||||
import PluginUsers from '../server';
|
||||
import supertest from 'supertest';
|
||||
import { userPluginConfig } from './utils';
|
||||
|
||||
describe('actions', () => {
|
||||
let api: MockServer;
|
||||
let db: Database;
|
||||
let agent;
|
||||
let pluginUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = mockServer();
|
||||
await api.cleanDb();
|
||||
api.plugin(PluginUsers, userPluginConfig);
|
||||
api.plugin(PluginACL);
|
||||
|
||||
await api.loadAndInstall();
|
||||
db = api.db;
|
||||
|
||||
agent = supertest.agent(api.callback());
|
||||
pluginUser = api.getPlugin('@nocobase/plugin-users');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('should login user with password', async () => {
|
||||
const { adminEmail, adminPassword } = userPluginConfig.installing;
|
||||
|
||||
let response = await api.agent().resource('users').check();
|
||||
expect(response.statusCode).toEqual(401);
|
||||
|
||||
response = await agent.post('/users:signin').send({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
const data = response.body.data;
|
||||
const token = data.token;
|
||||
expect(token).toBeDefined();
|
||||
|
||||
response = await agent.get('/users:check').set({ Authorization: 'Bearer ' + token });
|
||||
expect(response.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
@ -1,5 +1,6 @@
|
||||
import Database from '@nocobase/database';
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import { userPluginConfig } from './utils';
|
||||
|
||||
describe('createdBy/updatedBy', () => {
|
||||
let api: MockServer;
|
||||
@ -7,7 +8,7 @@ describe('createdBy/updatedBy', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
api = mockServer();
|
||||
api.plugin(require('../server').default);
|
||||
api.plugin(require('../server').default, userPluginConfig);
|
||||
await api.loadAndInstall();
|
||||
db = api.db;
|
||||
});
|
||||
|
@ -1,19 +1,24 @@
|
||||
import Database, { BelongsToManyRepository } from '@nocobase/database';
|
||||
import PluginACL from '@nocobase/plugin-acl';
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
import { userPluginConfig } from './utils';
|
||||
import UsersPlugin from '@nocobase/plugin-users';
|
||||
|
||||
describe('role', () => {
|
||||
let api: MockServer;
|
||||
let db: Database;
|
||||
|
||||
let usersPlugin: UsersPlugin;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = mockServer();
|
||||
await api.cleanDb();
|
||||
api.plugin(require('../server').default);
|
||||
api.plugin(UsersPlugin, userPluginConfig);
|
||||
api.plugin(PluginACL);
|
||||
await api.loadAndInstall();
|
||||
|
||||
db = api.db;
|
||||
usersPlugin = api.getPlugin('@nocobase/plugin-users');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -98,6 +103,7 @@ describe('role', () => {
|
||||
await userRolesRepo.add('test1');
|
||||
await userRolesRepo.add('test2');
|
||||
|
||||
const userToken = usersPlugin.jwtService.sign({ userId: user.get('id') });
|
||||
const response = await api
|
||||
.agent()
|
||||
.post('/users:setDefaultRole')
|
||||
@ -105,7 +111,7 @@ describe('role', () => {
|
||||
defaultRole: 'test2',
|
||||
})
|
||||
.set({
|
||||
Authorization: `Bearer ${user.get('token')}`,
|
||||
Authorization: `Bearer ${userToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
12
packages/plugin-users/src/__tests__/utils.ts
Normal file
12
packages/plugin-users/src/__tests__/utils.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { UserPluginConfig } from '../server';
|
||||
|
||||
export const userPluginConfig: UserPluginConfig = {
|
||||
jwt: {
|
||||
secret: '09f26e402586e2faa8da4c98a35f1b20d6b033c60',
|
||||
},
|
||||
installing: {
|
||||
adminEmail: 'test@test.com',
|
||||
adminPassword: 'password',
|
||||
adminNickname: 'test',
|
||||
},
|
||||
};
|
@ -1,5 +1,7 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { PasswordField } from '@nocobase/database';
|
||||
import UsersPlugin from '../server';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function check(ctx: Context, next: Next) {
|
||||
@ -14,7 +16,7 @@ export async function check(ctx: Context, next: Next) {
|
||||
|
||||
export async function signin(ctx: Context, next: Next) {
|
||||
const { uniqueField = 'email', values } = ctx.action.params;
|
||||
console.log('signin.values', values);
|
||||
|
||||
if (!values[uniqueField]) {
|
||||
ctx.throw(401, '请填写邮箱账号');
|
||||
}
|
||||
@ -32,19 +34,19 @@ export async function signin(ctx: Context, next: Next) {
|
||||
if (!isValid) {
|
||||
ctx.throw(401, '密码错误,请您重新输入');
|
||||
}
|
||||
if (!user.token) {
|
||||
user.token = crypto.randomBytes(20).toString('hex');
|
||||
await user.save();
|
||||
}
|
||||
|
||||
const pluginUser = ctx.app.getPlugin('@nocobase/plugin-users');
|
||||
|
||||
ctx.body = {
|
||||
...user.toJSON(),
|
||||
token: user.get('token'),
|
||||
token: pluginUser.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
};
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function signout(ctx: Context, next: Next) {
|
||||
await ctx.state.currentUser.update({ token: null });
|
||||
ctx.body = ctx.state.currentUser;
|
||||
await next();
|
||||
}
|
||||
|
@ -65,12 +65,6 @@ export default {
|
||||
type: 'string',
|
||||
name: 'appLang',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'token',
|
||||
unique: true,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'resetToken',
|
||||
|
34
packages/plugin-users/src/jwt-service.ts
Normal file
34
packages/plugin-users/src/jwt-service.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
export interface JwtOptions {
|
||||
secret: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
|
||||
export class JwtService {
|
||||
constructor(protected options: JwtOptions) {}
|
||||
|
||||
private expiresIn() {
|
||||
return this.options.expiresIn || '7d';
|
||||
}
|
||||
|
||||
private secret() {
|
||||
return this.options.secret;
|
||||
}
|
||||
|
||||
sign(payload: any) {
|
||||
return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn() });
|
||||
}
|
||||
|
||||
decode(token: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, this.secret(), (err: any, decoded: any) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
resolve(decoded);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import UsersPlugin from '../server';
|
||||
|
||||
// TODO(feature): 表名应在 options 中配置
|
||||
// 中间件默认只解决解析 token 和附加对应 user 的工作,不解决是否提前报 401 退出。
|
||||
@ -33,14 +34,23 @@ function setCurrentRole(ctx, user) {
|
||||
}
|
||||
|
||||
async function findUserByToken(ctx: Context) {
|
||||
const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, '');
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.repository.findOne({
|
||||
const token = getTokenFromCtx(ctx);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginUser = ctx.app.getPlugin('@nocobase/plugin-users');
|
||||
|
||||
const { userId } = await pluginUser.jwtService.decode(token);
|
||||
|
||||
return await ctx.db.getRepository('users').findOne({
|
||||
filter: {
|
||||
token,
|
||||
id: userId,
|
||||
},
|
||||
appends: ['roles'],
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getTokenFromCtx(ctx: Context) {
|
||||
return ctx.get('Authorization').replace(/^Bearer\s+/gi, '');
|
||||
}
|
||||
|
@ -3,8 +3,26 @@ import { Plugin } from '@nocobase/server';
|
||||
import { resolve } from 'path';
|
||||
import * as actions from './actions/users';
|
||||
import * as middlewares from './middlewares';
|
||||
import { JwtOptions, JwtService } from './jwt-service';
|
||||
|
||||
export interface UserPluginConfig {
|
||||
jwt: JwtOptions;
|
||||
|
||||
installing: {
|
||||
adminNickname: string;
|
||||
adminEmail: string;
|
||||
adminPassword: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default class UsersPlugin extends Plugin<UserPluginConfig> {
|
||||
public jwtService: JwtService;
|
||||
|
||||
constructor(app, options) {
|
||||
super(app, options);
|
||||
this.jwtService = new JwtService(options?.jwt);
|
||||
}
|
||||
|
||||
export default class UsersPlugin extends Plugin {
|
||||
async beforeLoad() {
|
||||
this.db.on('users.afterCreateWithAssociations', async (model, options) => {
|
||||
const { transaction } = options;
|
||||
@ -82,7 +100,7 @@ export default class UsersPlugin extends Plugin {
|
||||
adminNickname = 'Super Admin',
|
||||
adminEmail = 'admin@nocobase.com',
|
||||
adminPassword = 'admin123',
|
||||
} = this.options;
|
||||
} = this.options.installing;
|
||||
|
||||
return {
|
||||
adminNickname,
|
||||
@ -90,6 +108,7 @@ export default class UsersPlugin extends Plugin {
|
||||
adminPassword,
|
||||
};
|
||||
}
|
||||
|
||||
async install() {
|
||||
const { adminNickname, adminPassword, adminEmail } = this.getRootUserInfo();
|
||||
|
||||
@ -102,6 +121,7 @@ export default class UsersPlugin extends Plugin {
|
||||
roles: ['admin'],
|
||||
},
|
||||
});
|
||||
|
||||
const repo = this.db.getRepository<any>('collections');
|
||||
if (repo) {
|
||||
await repo.db2cm('users');
|
||||
|
87
yarn.lock
87
yarn.lock
@ -3169,6 +3169,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||
|
||||
"@types/jsonwebtoken@^8.5.8":
|
||||
version "8.5.8"
|
||||
resolved "https://registry.npmjs.org/@types%2fjsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44"
|
||||
integrity sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/keygrip@*":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
|
||||
@ -4837,6 +4844,11 @@ bser@2.1.1:
|
||||
dependencies:
|
||||
node-int64 "^0.4.0"
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
||||
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
|
||||
|
||||
buffer-from@1.x, buffer-from@^1.0.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||
@ -6487,6 +6499,13 @@ ecc-jsbn@~0.1.1:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
|
||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
ee-first@1.1.1, ee-first@~1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
@ -9923,6 +9942,22 @@ jsonparse@^1.2.0, jsonparse@^1.3.1:
|
||||
resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
||||
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
|
||||
|
||||
jsonwebtoken@^8.5.1:
|
||||
version "8.5.1"
|
||||
resolved "https://registry.npmmirror.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"
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
|
||||
@ -9946,6 +9981,23 @@ jstoxml@^0.2.3:
|
||||
array-includes "^3.1.3"
|
||||
object.assign "^4.1.2"
|
||||
|
||||
jwa@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmmirror.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.npmmirror.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
|
||||
dependencies:
|
||||
jwa "^1.4.1"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
katex@^0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.npmjs.org/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9"
|
||||
@ -10288,16 +10340,51 @@ lodash.debounce@^4.0.8:
|
||||
resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||
|
||||
lodash.includes@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.npmmirror.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.npmmirror.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.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
|
||||
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
|
||||
|
||||
lodash.ismatch@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37"
|
||||
integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=
|
||||
|
||||
lodash.isnumber@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.npmmirror.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.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
|
||||
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
|
||||
|
||||
lodash.isstring@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmmirror.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.npmjs.org/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.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
|
||||
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
|
||||
|
||||
lodash.sortby@^4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
||||
|
Loading…
Reference in New Issue
Block a user