From f09991d932520aebb4f5e414b323b07cece5ab76 Mon Sep 17 00:00:00 2001 From: chenos Date: Wed, 8 Dec 2021 09:10:44 +0800 Subject: [PATCH] feat: upgrade plugin-users --- .../database/src/fields/belongs-to-field.ts | 3 +- .../database/src/fields/has-many-field.ts | 2 +- packages/database/src/repository.ts | 2 +- packages/plugin-notifications/src/index.ts | 1 + packages/plugin-system-settings/src/index.ts | 1 + packages/plugin-system-settings/src/server.ts | 2 +- packages/plugin-users/.npmignore | 7 + packages/plugin-users/package.json | 18 ++ .../plugin-users/src/__tests__/fields.test.ts | 84 ++++++++++ packages/plugin-users/src/actions/users.ts | 156 ++++++++++++++++++ .../plugin-users/src/collections/users.ts | 90 ++++++++++ packages/plugin-users/src/index.ts | 1 + .../plugin-users/src/middlewares/check.ts | 10 ++ .../plugin-users/src/middlewares/index.ts | 2 + .../src/middlewares/parseToken.ts | 23 +++ packages/plugin-users/src/server.ts | 63 +++++++ packages/plugin-users/tsconfig.build.json | 9 + packages/plugin-users/tsconfig.json | 5 + yarn.lock | 7 + 19 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 packages/plugin-notifications/src/index.ts create mode 100644 packages/plugin-system-settings/src/index.ts create mode 100644 packages/plugin-users/.npmignore create mode 100644 packages/plugin-users/package.json create mode 100644 packages/plugin-users/src/__tests__/fields.test.ts create mode 100644 packages/plugin-users/src/actions/users.ts create mode 100644 packages/plugin-users/src/collections/users.ts create mode 100644 packages/plugin-users/src/index.ts create mode 100644 packages/plugin-users/src/middlewares/check.ts create mode 100644 packages/plugin-users/src/middlewares/index.ts create mode 100644 packages/plugin-users/src/middlewares/parseToken.ts create mode 100644 packages/plugin-users/src/server.ts create mode 100644 packages/plugin-users/tsconfig.build.json create mode 100644 packages/plugin-users/tsconfig.json diff --git a/packages/database/src/fields/belongs-to-field.ts b/packages/database/src/fields/belongs-to-field.ts index af50a3da6..4846a3a71 100644 --- a/packages/database/src/fields/belongs-to-field.ts +++ b/packages/database/src/fields/belongs-to-field.ts @@ -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; } diff --git a/packages/database/src/fields/has-many-field.ts b/packages/database/src/fields/has-many-field.ts index 04cd824e9..8822ff1d1 100644 --- a/packages/database/src/fields/has-many-field.ts +++ b/packages/database/src/fields/has-many-field.ts @@ -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); diff --git a/packages/database/src/repository.ts b/packages/database/src/repository.ts index 41107f563..6a0aa8a86 100644 --- a/packages/database/src/repository.ts +++ b/packages/database/src/repository.ts @@ -283,7 +283,7 @@ export class Repository { + async create(options: CreateOptions): Promise { const transaction = await this.getTransaction(options); const guard = UpdateGuard.fromOptions(this.model, options); diff --git a/packages/plugin-notifications/src/index.ts b/packages/plugin-notifications/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/plugin-notifications/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/plugin-system-settings/src/index.ts b/packages/plugin-system-settings/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/plugin-system-settings/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/plugin-system-settings/src/server.ts b/packages/plugin-system-settings/src/server.ts index c5f521a6b..207e9f02f 100644 --- a/packages/plugin-system-settings/src/server.ts +++ b/packages/plugin-system-settings/src/server.ts @@ -6,7 +6,7 @@ export default { async load() { const database = this.app.db; - database.import({ + await database.import({ directory: path.resolve(__dirname, 'collections'), }); diff --git a/packages/plugin-users/.npmignore b/packages/plugin-users/.npmignore new file mode 100644 index 000000000..461574b2f --- /dev/null +++ b/packages/plugin-users/.npmignore @@ -0,0 +1,7 @@ +node_modules +*.log +docs +__tests__ +tsconfig.json +src +.fatherrc.ts \ No newline at end of file diff --git a/packages/plugin-users/package.json b/packages/plugin-users/package.json new file mode 100644 index 000000000..c98709fba --- /dev/null +++ b/packages/plugin-users/package.json @@ -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" +} diff --git a/packages/plugin-users/src/__tests__/fields.test.ts b/packages/plugin-users/src/__tests__/fields.test.ts new file mode 100644 index 000000000..0ff4f5476 --- /dev/null +++ b/packages/plugin-users/src/__tests__/fields.test.ts @@ -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({ + 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()); + }); + }); +}); diff --git a/packages/plugin-users/src/actions/users.ts b/packages/plugin-users/src/actions/users.ts new file mode 100644 index 000000000..9f346d23b --- /dev/null +++ b/packages/plugin-users/src/actions/users.ts @@ -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({ + where: { + [uniqueField]: values[uniqueField], + }, + }); + if (!user) { + ctx.throw(401, '邮箱账号未注册'); + } + const pwd = User.getField('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({ + 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({ + 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({ + where: { + email: ctx.state.currentUser.email, + }, + }); + const pwd = User.getField('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(); +} diff --git a/packages/plugin-users/src/collections/users.ts b/packages/plugin-users/src/collections/users.ts new file mode 100644 index 000000000..3af271cc3 --- /dev/null +++ b/packages/plugin-users/src/collections/users.ts @@ -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; diff --git a/packages/plugin-users/src/index.ts b/packages/plugin-users/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/plugin-users/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/plugin-users/src/middlewares/check.ts b/packages/plugin-users/src/middlewares/check.ts new file mode 100644 index 000000000..3b9f992b0 --- /dev/null +++ b/packages/plugin-users/src/middlewares/check.ts @@ -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(); + }; +} diff --git a/packages/plugin-users/src/middlewares/index.ts b/packages/plugin-users/src/middlewares/index.ts new file mode 100644 index 000000000..538d318f9 --- /dev/null +++ b/packages/plugin-users/src/middlewares/index.ts @@ -0,0 +1,2 @@ +export { check } from './check'; +export { parseToken } from './parseToken'; diff --git a/packages/plugin-users/src/middlewares/parseToken.ts b/packages/plugin-users/src/middlewares/parseToken.ts new file mode 100644 index 000000000..bd25a0b10 --- /dev/null +++ b/packages/plugin-users/src/middlewares/parseToken.ts @@ -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(); + }; +} diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts new file mode 100644 index 000000000..8d81d414c --- /dev/null +++ b/packages/plugin-users/src/server.ts @@ -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; diff --git a/packages/plugin-users/tsconfig.build.json b/packages/plugin-users/tsconfig.build.json new file mode 100644 index 000000000..8d3db734b --- /dev/null +++ b/packages/plugin-users/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./lib", + "declaration": true + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"] +} \ No newline at end of file diff --git a/packages/plugin-users/tsconfig.json b/packages/plugin-users/tsconfig.json new file mode 100644 index 000000000..e10a5305a --- /dev/null +++ b/packages/plugin-users/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./esm/*", "./lib/*"] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 90e9e2f2f..1af5953bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"