feat: upgrade plugin-users
This commit is contained in:
parent
bf2840b089
commit
f09991d932
@ -36,7 +36,7 @@ export class BelongsToField extends RelationField {
|
||||
});
|
||||
|
||||
// inverse relation
|
||||
this.TargetModel.hasMany(collection.model);
|
||||
// this.TargetModel.hasMany(collection.model);
|
||||
|
||||
// 建立关系之后从 pending 列表中删除
|
||||
database.removePendingField(this);
|
||||
@ -76,4 +76,5 @@ export class BelongsToField extends RelationField {
|
||||
|
||||
export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions {
|
||||
type: 'belongsTo';
|
||||
target?: string;
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ export class HasManyField extends RelationField {
|
||||
});
|
||||
|
||||
// inverse relation
|
||||
this.TargetModel.belongsTo(collection.model);
|
||||
// this.TargetModel.belongsTo(collection.model);
|
||||
|
||||
// 建立关系之后从 pending 列表中删除
|
||||
database.removePendingField(this);
|
||||
|
@ -283,7 +283,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
|
||||
* @param options
|
||||
*/
|
||||
@transaction()
|
||||
async create(options: CreateOptions): Promise<Model> {
|
||||
async create<M extends Model>(options: CreateOptions): Promise<M> {
|
||||
const transaction = await this.getTransaction(options);
|
||||
|
||||
const guard = UpdateGuard.fromOptions(this.model, options);
|
||||
|
1
packages/plugin-notifications/src/index.ts
Normal file
1
packages/plugin-notifications/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './server';
|
1
packages/plugin-system-settings/src/index.ts
Normal file
1
packages/plugin-system-settings/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './server';
|
@ -6,7 +6,7 @@ export default {
|
||||
async load() {
|
||||
const database = this.app.db;
|
||||
|
||||
database.import({
|
||||
await database.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
});
|
||||
|
||||
|
7
packages/plugin-users/.npmignore
Normal file
7
packages/plugin-users/.npmignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
*.log
|
||||
docs
|
||||
__tests__
|
||||
tsconfig.json
|
||||
src
|
||||
.fatherrc.ts
|
18
packages/plugin-users/package.json
Normal file
18
packages/plugin-users/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-users",
|
||||
"version": "0.6.0-alpha.0",
|
||||
"main": "lib/index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm",
|
||||
"build:cjs": "tsc --project tsconfig.build.json",
|
||||
"build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"crypto-random-string": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nocobase/test": "^0.6.0-alpha.0"
|
||||
},
|
||||
"gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4"
|
||||
}
|
84
packages/plugin-users/src/__tests__/fields.test.ts
Normal file
84
packages/plugin-users/src/__tests__/fields.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import Database from '@nocobase/database';
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
|
||||
describe('createdBy/updatedBy', () => {
|
||||
let api: MockServer;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = mockServer();
|
||||
api.plugin(require('../server').default);
|
||||
await api.load();
|
||||
db = api.db;
|
||||
await db.sync();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
describe('collection definition', () => {
|
||||
it('case 1', async () => {
|
||||
const Post = db.collection({
|
||||
name: 'posts',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
});
|
||||
expect(Post.hasField('createdBy')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('case 2', async () => {
|
||||
const Post = db.collection({
|
||||
name: 'posts',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
});
|
||||
await db.sync();
|
||||
const currentUser = await db.getCollection('users').model.create();
|
||||
const p1 = await Post.repository.create({
|
||||
context: {
|
||||
state: {
|
||||
currentUser,
|
||||
},
|
||||
},
|
||||
});
|
||||
const p2 = await Post.repository.findOne({
|
||||
appends: ['createdBy', 'updatedBy'],
|
||||
});
|
||||
expect(p2.get('createdBy')).toMatchObject(currentUser.toJSON());
|
||||
expect(p2.get('updatedBy')).toMatchObject(currentUser.toJSON());
|
||||
});
|
||||
|
||||
it('case 3', async () => {
|
||||
const Post = db.collection({
|
||||
name: 'posts',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
});
|
||||
await db.sync();
|
||||
const user1 = await db.getCollection('users').model.create();
|
||||
const user2 = await db.getCollection('users').model.create();
|
||||
const p1 = await Post.repository.create<any>({
|
||||
context: {
|
||||
state: {
|
||||
currentUser: user1,
|
||||
},
|
||||
},
|
||||
});
|
||||
await Post.repository.update({
|
||||
values: {},
|
||||
filterByPk: p1.id,
|
||||
context: {
|
||||
state: {
|
||||
currentUser: user2,
|
||||
},
|
||||
},
|
||||
});
|
||||
const p2 = await Post.repository.findOne({
|
||||
appends: ['createdBy', 'updatedBy'],
|
||||
});
|
||||
expect(p2.get('createdBy')).toMatchObject(user1.toJSON());
|
||||
expect(p2.get('updatedBy')).toMatchObject(user2.toJSON());
|
||||
});
|
||||
});
|
||||
});
|
156
packages/plugin-users/src/actions/users.ts
Normal file
156
packages/plugin-users/src/actions/users.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { PasswordField } from '@nocobase/database';
|
||||
import cryptoRandomString from 'crypto-random-string';
|
||||
|
||||
export async function check(ctx: Context, next: Next) {
|
||||
if (ctx.state.currentUser) {
|
||||
const user = ctx.state.currentUser.toJSON();
|
||||
ctx.body = user;
|
||||
await next();
|
||||
} else {
|
||||
ctx.throw(401, 'Unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
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, '请填写邮箱账号');
|
||||
}
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.model.scope('withPassword').findOne<any>({
|
||||
where: {
|
||||
[uniqueField]: values[uniqueField],
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
ctx.throw(401, '邮箱账号未注册');
|
||||
}
|
||||
const pwd = User.getField<PasswordField>('password');
|
||||
const isValid = await pwd.verify(values.password, user.password);
|
||||
if (!isValid) {
|
||||
ctx.throw(401, '密码错误,请您重新输入');
|
||||
}
|
||||
if (!user.token) {
|
||||
user.token = cryptoRandomString({ length: 20 });
|
||||
await user.save();
|
||||
}
|
||||
ctx.body = user.toJSON();
|
||||
delete ctx.body.password;
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function signout(ctx: Context, next: Next) {
|
||||
ctx.body = {};
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function signup(ctx: Context, next: Next) {
|
||||
const User = ctx.db.getCollection('users');
|
||||
const { values } = ctx.action.params;
|
||||
try {
|
||||
const user = await User.model.create(values);
|
||||
ctx.body = user;
|
||||
} catch (error) {
|
||||
if (error.errors) {
|
||||
ctx.throw(401, error.errors.map((data) => data.message).join(', '));
|
||||
} else {
|
||||
ctx.throw(401, '注册失败');
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function lostpassword(ctx: Context, next: Next) {
|
||||
const {
|
||||
values: { email },
|
||||
} = ctx.action.params;
|
||||
if (!email) {
|
||||
ctx.throw(401, '请填写邮箱账号');
|
||||
}
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.model.findOne<any>({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
ctx.throw(401, '邮箱账号未注册');
|
||||
}
|
||||
user.resetToken = cryptoRandomString({ length: 20 });
|
||||
await user.save();
|
||||
ctx.body = user;
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function resetpassword(ctx: Context, next: Next) {
|
||||
const {
|
||||
values: { email, password, resetToken },
|
||||
} = ctx.action.params;
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.model.findOne<any>({
|
||||
where: {
|
||||
email,
|
||||
resetToken,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
ctx.throw(401, 'Unauthorized');
|
||||
}
|
||||
user.token = null;
|
||||
user.resetToken = null;
|
||||
user.password = password;
|
||||
await user.save();
|
||||
ctx.body = user;
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function getUserByResetToken(ctx: Context, next: Next) {
|
||||
const { token } = ctx.action.params;
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.model.findOne({
|
||||
where: {
|
||||
resetToken: token,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
ctx.throw(401, 'Unauthorized');
|
||||
}
|
||||
ctx.body = user;
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function updateProfile(ctx: Context, next: Next) {
|
||||
const { values } = ctx.action.params;
|
||||
if (!ctx.state.currentUser) {
|
||||
ctx.throw(401, 'Unauthorized');
|
||||
}
|
||||
await ctx.state.currentUser.update(values);
|
||||
ctx.body = ctx.state.currentUser;
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function changePassword(ctx: Context, next: Next) {
|
||||
const {
|
||||
values: { oldPassword, newPassword },
|
||||
} = ctx.action.params;
|
||||
if (!ctx.state.currentUser) {
|
||||
ctx.throw(401, 'Unauthorized');
|
||||
}
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.model.scope('withPassword').findOne<any>({
|
||||
where: {
|
||||
email: ctx.state.currentUser.email,
|
||||
},
|
||||
});
|
||||
const pwd = User.getField<PasswordField>('password');
|
||||
const isValid = await pwd.verify(oldPassword, user.password);
|
||||
if (!isValid) {
|
||||
ctx.throw(401, '密码错误,请您重新输入');
|
||||
}
|
||||
user.password = newPassword;
|
||||
user.save();
|
||||
ctx.body = ctx.state.currentUser.toJSON();
|
||||
await next();
|
||||
}
|
90
packages/plugin-users/src/collections/users.ts
Normal file
90
packages/plugin-users/src/collections/users.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'users',
|
||||
title: '{{t("Users")}}',
|
||||
sortable: 'sort',
|
||||
scopes: {
|
||||
withPassword: {
|
||||
attributes: { include: ['password'] },
|
||||
},
|
||||
},
|
||||
defaultScope: {
|
||||
attributes: { exclude: ['password'] },
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'nickname',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("Nickname")}}',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'email',
|
||||
type: 'string',
|
||||
name: 'email',
|
||||
unique: true,
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("Email")}}',
|
||||
'x-component': 'Input',
|
||||
require: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'password',
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("Password")}}',
|
||||
'x-component': 'Password',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'belongsToMany',
|
||||
name: 'roles',
|
||||
target: 'roles',
|
||||
foreignKey: 'user_id',
|
||||
otherKey: 'role_name',
|
||||
sourceKey: 'id',
|
||||
targetKey: 'name',
|
||||
uiSchema: {
|
||||
type: 'array',
|
||||
title: '{{t("Roles")}}',
|
||||
'x-component': 'Select.Drawer',
|
||||
'x-component-props': {
|
||||
multiple: true,
|
||||
fieldNames: {
|
||||
label: 'title',
|
||||
value: 'name',
|
||||
},
|
||||
},
|
||||
'x-decorator': 'FormItem',
|
||||
'x-designable-bar': 'Select.Drawer.DesignableBar',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
name: 'appLang',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'token',
|
||||
unique: true,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'resetToken',
|
||||
unique: true,
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
1
packages/plugin-users/src/index.ts
Normal file
1
packages/plugin-users/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './server';
|
10
packages/plugin-users/src/middlewares/check.ts
Normal file
10
packages/plugin-users/src/middlewares/check.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// TODO(usage): 拦截用户的处理暂时作为一个中间件导出,应用需要的时候可以直接使用这个中间件
|
||||
export function check(options) {
|
||||
return async function check(ctx, next) {
|
||||
const { currentUser } = ctx.state;
|
||||
if (!currentUser) {
|
||||
return ctx.throw(401, 'Unauthorized');
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
2
packages/plugin-users/src/middlewares/index.ts
Normal file
2
packages/plugin-users/src/middlewares/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { check } from './check';
|
||||
export { parseToken } from './parseToken';
|
23
packages/plugin-users/src/middlewares/parseToken.ts
Normal file
23
packages/plugin-users/src/middlewares/parseToken.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
|
||||
// TODO(feature): 表名应在 options 中配置
|
||||
// 中间件默认只解决解析 token 和附加对应 user 的工作,不解决是否提前报 401 退出。
|
||||
|
||||
// 因为是否提供匿名访问资源是应用决定的,不是使用插件就一定不能匿名访问。
|
||||
export function parseToken(options) {
|
||||
return async function parseToken(ctx: Context, next: Next) {
|
||||
const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, '');
|
||||
const User = ctx.db.getCollection('users');
|
||||
const user = await User.model.findOne({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
ctx.state.currentUser = user;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
63
packages/plugin-users/src/server.ts
Normal file
63
packages/plugin-users/src/server.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import path from 'path';
|
||||
import * as actions from './actions/users';
|
||||
import * as middlewares from './middlewares';
|
||||
import { Collection } from '@nocobase/database';
|
||||
import { PluginOptions } from '@nocobase/server';
|
||||
|
||||
export default {
|
||||
name: 'users',
|
||||
async load() {
|
||||
const database = this.app.db;
|
||||
const resourcer = this.app.resourcer;
|
||||
|
||||
this.app.on('db.init', async () => {
|
||||
const User = database.getCollection('users');
|
||||
await User.model.create({
|
||||
nickname: 'Super Admin',
|
||||
email: process.env.ADMIN_EMAIL || 'admin@nocobase.com',
|
||||
password: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
});
|
||||
});
|
||||
|
||||
database.on('afterDefineCollection', (collection: Collection) => {
|
||||
let { createdBy, updatedBy } = collection.options;
|
||||
if (createdBy === true) {
|
||||
collection.setField('createdById', {
|
||||
type: 'context',
|
||||
dataType: 'integer',
|
||||
dataIndex: 'state.currentUser.id',
|
||||
createOnly: true,
|
||||
});
|
||||
collection.setField('createdBy', {
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
foreignKey: 'createdById',
|
||||
targetKey: 'id',
|
||||
});
|
||||
}
|
||||
if (updatedBy === true) {
|
||||
collection.setField('updatedById', {
|
||||
type: 'context',
|
||||
dataType: 'integer',
|
||||
dataIndex: 'state.currentUser.id',
|
||||
});
|
||||
collection.setField('updatedBy', {
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
foreignKey: 'updatedById',
|
||||
targetKey: 'id',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await database.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
});
|
||||
|
||||
for (const [key, action] of Object.entries(actions)) {
|
||||
resourcer.registerActionHandler(`users:${key}`, action);
|
||||
}
|
||||
|
||||
resourcer.use(middlewares.parseToken({}));
|
||||
},
|
||||
} as PluginOptions;
|
9
packages/plugin-users/tsconfig.build.json
Normal file
9
packages/plugin-users/tsconfig.build.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
|
||||
}
|
5
packages/plugin-users/tsconfig.json
Normal file
5
packages/plugin-users/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["./esm/*", "./lib/*"]
|
||||
}
|
@ -4647,6 +4647,13 @@ crypto-random-string@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
|
||||
integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
|
||||
|
||||
crypto-random-string@^3.3.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-3.3.1.tgz#13cee94cac8001e4842501608ef779e0ed08f82d"
|
||||
integrity sha512-5j88ECEn6h17UePrLi6pn1JcLtAiANa3KExyr9y9Z5vo2mv56Gh3I4Aja/B9P9uyMwyxNHAHWv+nE72f30T5Dg==
|
||||
dependencies:
|
||||
type-fest "^0.8.1"
|
||||
|
||||
css-blank-pseudo@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5"
|
||||
|
Loading…
Reference in New Issue
Block a user