feat: upgrade plugin-users

This commit is contained in:
chenos 2021-12-08 09:10:44 +08:00
parent bf2840b089
commit f09991d932
19 changed files with 482 additions and 4 deletions

View File

@ -36,7 +36,7 @@ export class BelongsToField extends RelationField {
}); });
// inverse relation // inverse relation
this.TargetModel.hasMany(collection.model); // this.TargetModel.hasMany(collection.model);
// 建立关系之后从 pending 列表中删除 // 建立关系之后从 pending 列表中删除
database.removePendingField(this); database.removePendingField(this);
@ -76,4 +76,5 @@ export class BelongsToField extends RelationField {
export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions { export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions {
type: 'belongsTo'; type: 'belongsTo';
target?: string;
} }

View File

@ -102,7 +102,7 @@ export class HasManyField extends RelationField {
}); });
// inverse relation // inverse relation
this.TargetModel.belongsTo(collection.model); // this.TargetModel.belongsTo(collection.model);
// 建立关系之后从 pending 列表中删除 // 建立关系之后从 pending 列表中删除
database.removePendingField(this); database.removePendingField(this);

View File

@ -283,7 +283,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
* @param options * @param options
*/ */
@transaction() @transaction()
async create(options: CreateOptions): Promise<Model> { async create<M extends Model>(options: CreateOptions): Promise<M> {
const transaction = await this.getTransaction(options); const transaction = await this.getTransaction(options);
const guard = UpdateGuard.fromOptions(this.model, options); const guard = UpdateGuard.fromOptions(this.model, options);

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -6,7 +6,7 @@ export default {
async load() { async load() {
const database = this.app.db; const database = this.app.db;
database.import({ await database.import({
directory: path.resolve(__dirname, 'collections'), directory: path.resolve(__dirname, 'collections'),
}); });

View File

@ -0,0 +1,7 @@
node_modules
*.log
docs
__tests__
tsconfig.json
src
.fatherrc.ts

View 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"
}

View 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());
});
});
});

View 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();
}

View 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;

View File

@ -0,0 +1 @@
export { default } from './server';

View 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();
};
}

View File

@ -0,0 +1,2 @@
export { check } from './check';
export { parseToken } from './parseToken';

View 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();
};
}

View 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;

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./lib",
"declaration": true
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
}

View File

@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./esm/*", "./lib/*"]
}

View File

@ -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" resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= 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: css-blank-pseudo@^0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" resolved "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5"