diff --git a/package.json b/package.json index 4af0ac464b..6a6031f199 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:start": "docker-compose up -d", "lint": "eslint --ext .ts,.tsx,.js \"packages/*/src/**.@(ts|tsx|js)\" --fix", "test": "npm run lint && jest", + "debug": "node --inspect-brk node_modules/.bin/jest --runInBand", "release": "npm run build && lerna publish --yes --registry https://npm.pkg.github.com" }, "devDependencies": { diff --git a/packages/actions/src/__tests__/actions/create1.ts b/packages/actions/src/__tests__/actions/create1.ts index e5b132cef4..2ed6af4e51 100644 --- a/packages/actions/src/__tests__/actions/create1.ts +++ b/packages/actions/src/__tests__/actions/create1.ts @@ -2,7 +2,7 @@ import { ActionOptions } from '@nocobase/resourcer'; import { create } from '../../actions/common'; export default { - defaultValues: { + values: { meta: { location: 'Kunming' } @@ -13,4 +13,4 @@ export default { }, handler: create -} as ActionOptions; +} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/create2.ts b/packages/actions/src/__tests__/actions/create2.ts index ba2a113522..43a40300b6 100644 --- a/packages/actions/src/__tests__/actions/create2.ts +++ b/packages/actions/src/__tests__/actions/create2.ts @@ -7,4 +7,4 @@ export default { }, handler: create -} as ActionOptions; +} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/list1.ts b/packages/actions/src/__tests__/actions/list1.ts index a38e75d9df..a5d283b286 100644 --- a/packages/actions/src/__tests__/actions/list1.ts +++ b/packages/actions/src/__tests__/actions/list1.ts @@ -14,4 +14,4 @@ export default { }, handler: list -} as ActionOptions; +} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/update1.ts b/packages/actions/src/__tests__/actions/update1.ts index 5c3d1ac898..449978ee15 100644 --- a/packages/actions/src/__tests__/actions/update1.ts +++ b/packages/actions/src/__tests__/actions/update1.ts @@ -2,7 +2,7 @@ import { ActionOptions } from '@nocobase/resourcer'; import { update } from '../../actions/common'; export default { - defaultValues: { + values: { meta: { location: 'Kunming' } @@ -13,4 +13,4 @@ export default { }, handler: update -} as ActionOptions; +} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/actions/update2.ts b/packages/actions/src/__tests__/actions/update2.ts index 7c499408bc..6f3d708cca 100644 --- a/packages/actions/src/__tests__/actions/update2.ts +++ b/packages/actions/src/__tests__/actions/update2.ts @@ -7,4 +7,4 @@ export default { }, handler: update -} as ActionOptions; +} as unknown as ActionOptions; diff --git a/packages/actions/src/__tests__/index.ts b/packages/actions/src/__tests__/index.ts index 06422e4ea1..3b02a37033 100644 --- a/packages/actions/src/__tests__/index.ts +++ b/packages/actions/src/__tests__/index.ts @@ -60,7 +60,7 @@ const tableFiles = glob.sync(`${resolve(__dirname, './tables')}/*.ts`); // resourcer 在内存中是单例,需要谨慎使用 export const resourcer = new Resourcer(); resourcer.use(associated); -resourcer.registerActionHandlers({...actions.associate, ...actions.common }); +resourcer.registerActionHandlers({ ...actions.associate, ...actions.common }); resourcer.define({ name: 'posts', actions: { diff --git a/packages/actions/src/middlewares/associated.ts b/packages/actions/src/middlewares/associated.ts index e081fcc305..c14896da66 100644 --- a/packages/actions/src/middlewares/associated.ts +++ b/packages/actions/src/middlewares/associated.ts @@ -41,8 +41,7 @@ export async function associated(ctx: Context, next: Next) { } }); if (model) { - ctx.action.setParam('associated', model); - ctx.action.setParam('resourceField', field); + ctx.action.mergeParams({ associated: model, resourceField: field }); } } diff --git a/packages/app/src/api/app.ts b/packages/app/src/api/app.ts index 4490b842d6..07baacd0d2 100644 --- a/packages/app/src/api/app.ts +++ b/packages/app/src/api/app.ts @@ -3,12 +3,12 @@ import dotenv from 'dotenv'; import path from 'path'; import actions from '../../../actions/src'; import associated from '../../../actions/src/middlewares/associated'; -import { Op } from 'sequelize'; -const sync = { - force: false, +// @ts-ignore +const sync = global.sync || { + force: true, alter: { - drop: false, + drop: true, }, }; @@ -40,60 +40,31 @@ const api = Api.create({ api.resourcer.use(associated); api.resourcer.registerActionHandlers({...actions.common, ...actions.associate}); -api.resourcer.use(async (ctx: actions.Context, next) => { - const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); - // console.log('user check', ctx.action.params.actionName); - // const { actionName } = ctx.action.params; - if (!token) { - return next(); - } - const User = ctx.db.getModel('users'); - const user = await User.findOne({ - where: { - token, - }, - }); - if (!user) { - return next(); - } - ctx.state.currentUser = user; - // console.log('ctx.state.currentUser', ctx.state.currentUser); - await next(); -}); - -api.resourcer.use(async (ctx: actions.Context, next) => { - const { actionName, resourceField, resourceName, fields = {} } = ctx.action.params; - const table = ctx.db.getTable(resourceField ? resourceField.options.target : resourceName); - // ctx.state.developerMode = {[Op.not]: null}; - ctx.state.developerMode = false; - if (table && table.hasField('developerMode') && ctx.state.developerMode === false) { - ctx.action.setParam('filter.developerMode', ctx.state.developerMode); - } - if (table && ['get', 'list'].includes(actionName)) { - const except = fields.except || []; - const appends = fields.appends || []; - for (const [name, field] of table.getFields()) { - if (field.options.hidden) { - except.push(field.options.name); - } - if (field.options.appends) { - appends.push(field.options.name); - } - } - if (except.length) { - ctx.action.setParam('fields.except', except); - } - if (appends.length) { - ctx.action.setParam('fields.appends', appends); - } - console.log('ctx.action.params.fields', ctx.action.params.fields, except, appends); - } - await next(); -}); +// api.resourcer.use(async (ctx: actions.Context, next) => { +// const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); +// // console.log('user check', ctx.action.params.actionName); +// // const { actionName } = ctx.action.params; +// if (!token) { +// return next(); +// } +// const User = ctx.db.getModel('users'); +// const user = await User.findOne({ +// where: { +// token, +// }, +// }); +// if (!user) { +// return next(); +// } +// ctx.state.currentUser = user; +// // console.log('ctx.state.currentUser', ctx.state.currentUser); +// await next(); +// }); api.registerPlugin('plugin-collections', [path.resolve(__dirname, '../../../plugin-collections'), {}]); api.registerPlugin('plugin-pages', [path.resolve(__dirname, '../../../plugin-pages'), {}]); api.registerPlugin('plugin-users', [path.resolve(__dirname, '../../../plugin-users'), {}]); api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plugin-file-manager'), {}]); +api.registerPlugin('plugin-permissions', [path.resolve(__dirname, '../../../plugin-permissions'), {}]); export default api; diff --git a/packages/app/src/api/index.ts b/packages/app/src/api/index.ts index c7d26d5603..abbb89f2e8 100644 --- a/packages/app/src/api/index.ts +++ b/packages/app/src/api/index.ts @@ -1,65 +1,5 @@ -import Api from '../../../server/src'; -import dotenv from 'dotenv'; -import path from 'path'; +import api from './app'; import actions from '../../../actions/src'; -import associated from '../../../actions/src/middlewares/associated'; -import { Op } from 'sequelize'; - -const sync = { - force: false, - alter: { - drop: false, - }, -}; - -console.log('process.env.NOCOBASE_ENV', process.env.NOCOBASE_ENV); - -dotenv.config(); - -const api = Api.create({ - database: { - username: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - host: process.env.DB_HOST, - port: process.env.DB_PORT, - dialect: process.env.DB_DIALECT, - dialectOptions: { - charset: 'utf8mb4', - collate: 'utf8mb4_unicode_ci', - }, - logging: false, - define: {}, - sync, - }, - resourcer: { - prefix: '/api', - }, -}); - -api.resourcer.use(associated); -api.resourcer.registerActionHandlers({...actions.common, ...actions.associate}); - -api.resourcer.use(async (ctx: actions.Context, next) => { - const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); - // console.log('user check', ctx.action.params.actionName); - // const { actionName } = ctx.action.params; - if (!token) { - return next(); - } - const User = ctx.db.getModel('users'); - const user = await User.findOne({ - where: { - token, - }, - }); - if (!user) { - return next(); - } - ctx.state.currentUser = user; - // console.log('ctx.state.currentUser', ctx.state.currentUser); - await next(); -}); api.resourcer.use(async (ctx: actions.Context, next) => { const { actionName, resourceField, resourceName, fields = {} } = ctx.action.params; @@ -67,11 +7,11 @@ api.resourcer.use(async (ctx: actions.Context, next) => { // ctx.state.developerMode = {[Op.not]: null}; ctx.state.developerMode = false; if (table && table.hasField('developerMode') && ctx.state.developerMode === false) { - ctx.action.setParam('filter.developerMode.$isFalsy', true); + ctx.action.mergeParams({ filter: { developerMode: ctx.state.developerMode } }, { filter: 'and' }); } if (table && ['get', 'list'].includes(actionName)) { - const except = fields.except || []; - // const appends = fields.appends || []; + const except = []; + const appends = []; for (const [name, field] of table.getFields()) { if (field.options.hidden) { except.push(field.options.name); @@ -80,12 +20,10 @@ api.resourcer.use(async (ctx: actions.Context, next) => { // appends.push(field.options.name); // } } - if (except.length) { - ctx.action.setParam('fields.except', except); - } - // if (appends.length) { - // ctx.action.setParam('fields.appends', appends); - // } + ctx.action.mergeParams({ fields: { + except, + appends + } }, { fields: 'append' }); // console.log('ctx.action.params.fields', ctx.action.params.fields, except, appends); } await next(); @@ -112,11 +50,6 @@ api.resourcer.use(async (ctx: actions.Context, next) => { // await next(); // }); -api.registerPlugin('plugin-collections', [path.resolve(__dirname, '../../../plugin-collections'), {}]); -api.registerPlugin('plugin-pages', [path.resolve(__dirname, '../../../plugin-pages'), {}]); -api.registerPlugin('plugin-users', [path.resolve(__dirname, '../../../plugin-users'), {}]); -api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plugin-file-manager'), {}]); - (async () => { await api.loadPlugins(); diff --git a/packages/app/src/api/migrate.ts b/packages/app/src/api/migrate.ts index 5dedebadab..19a55a998f 100644 --- a/packages/app/src/api/migrate.ts +++ b/packages/app/src/api/migrate.ts @@ -1,5 +1,14 @@ const args = process.argv; + +// @ts-ignore +global.sync = { + force: true, + alter: { + drop: true, + }, +}; + const fileName: string = args.pop(); require(`./migrations/${fileName}`); diff --git a/packages/app/src/api/collections/authors.ts b/packages/app/src/api/migrations/collections/authors.ts similarity index 100% rename from packages/app/src/api/collections/authors.ts rename to packages/app/src/api/migrations/collections/authors.ts diff --git a/packages/app/src/api/collections/books.ts b/packages/app/src/api/migrations/collections/books.ts similarity index 100% rename from packages/app/src/api/collections/books.ts rename to packages/app/src/api/migrations/collections/books.ts diff --git a/packages/app/src/api/collections/example.ts b/packages/app/src/api/migrations/collections/example.ts similarity index 100% rename from packages/app/src/api/collections/example.ts rename to packages/app/src/api/migrations/collections/example.ts diff --git a/packages/app/src/api/migrations/init.ts b/packages/app/src/api/migrations/init.ts index b6592c382d..ffd087ee10 100644 --- a/packages/app/src/api/migrations/init.ts +++ b/packages/app/src/api/migrations/init.ts @@ -1,46 +1,5 @@ -import Api from '../../../../server/src'; -import dotenv from 'dotenv'; -import path from 'path'; -import Database, { Model } from '@nocobase/database'; -import actions from '../../../../actions/src'; -import associated from '../../../../actions/src/middlewares/associated'; - -console.log(process.argv); - -const clean = true; - -const sync = { - force: clean, - alter: { - drop: clean, - }, -}; - -dotenv.config(); - -const api = Api.create({ - database: { - username: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - host: process.env.DB_HOST, - port: process.env.DB_PORT, - dialect: process.env.DB_DIALECT, - dialectOptions: { - charset: 'utf8mb4', - collate: 'utf8mb4_unicode_ci', - }, - logging: false, - define: {}, - sync, - }, - resourcer: { - prefix: '/api', - }, -}); - -api.resourcer.use(associated); -api.resourcer.registerActionHandlers({...actions.common, ...actions.associate}); +import api from '../app'; +import Database from '@nocobase/database'; const data = [ { @@ -135,6 +94,15 @@ const data = [ sort: 110, showInMenu: true, }, + { + title: '权限组配置', + type: 'collection', + collection: 'roles', + path: '/settings/roles', + icon: 'TableOutlined', + sort: 120, + showInMenu: true, + }, ] }, ], @@ -157,11 +125,6 @@ const data = [ } ]; -api.registerPlugin('plugin-collections', [path.resolve(__dirname, '../../../plugin-collections'), {}]); -api.registerPlugin('plugin-pages', [path.resolve(__dirname, '../../../plugin-pages'), {}]); -api.registerPlugin('plugin-users', [path.resolve(__dirname, '../../../plugin-users'), {}]); -api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plugin-file-manager'), {}]); - (async () => { await api.loadPlugins(); const database: Database = api.database; @@ -171,6 +134,7 @@ api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plu const [Collection, Page, User] = database.getModels(['collections', 'pages', 'users']); const tables = database.getTables([]); for (let table of tables) { + console.log(table.getName()); await Collection.import(table.getOptions(), { update: true, migrate: false }); } await Page.import(data); @@ -195,18 +159,32 @@ api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plu // baseUrl: process.env.LOCAL_STORAGE_BASE_URL, // default: true // }); - await Storage.create({ - name: `ali-oss`, - type: 'ali-oss', - baseUrl: process.env.STORAGE_BASE_URL, - options: { - region: process.env.ALIYUN_OSS_REGION,// 'oss-cn-beijing', - accessKeyId: process.env.ALIYUN_OSS_ACCESS_KEY_ID,// 'LTAI4GEGDJsdGantisvSaz47', - accessKeySecret: process.env.ALIYUN_OSS_ACCESS_KEY_SECRET,//'cDFaOUwigps7ohRmsfBFXGDxNm8uIq', - bucket: process.env.ALIYUN_OSS_BUCKET, //'nocobase' + const storage = await Storage.findOne({ + where: { + name: "ali-oss", }, - default: true }); + if (!storage) { + await Storage.create({ + name: `ali-oss`, + type: 'ali-oss', + baseUrl: process.env.STORAGE_BASE_URL, + options: { + region: process.env.ALIYUN_OSS_REGION,// 'oss-cn-beijing', + accessKeyId: process.env.ALIYUN_OSS_ACCESS_KEY_ID,// 'LTAI4GEGDJsdGantisvSaz47', + accessKeySecret: process.env.ALIYUN_OSS_ACCESS_KEY_SECRET,//'cDFaOUwigps7ohRmsfBFXGDxNm8uIq', + bucket: process.env.ALIYUN_OSS_BUCKET, //'nocobase' + }, + default: true + }); + } + const Role = database.getModel('roles'); + const roles = await Role.bulkCreate([ + { title: '系统开发组' }, + { title: '数据管理组' }, + { title: '普通用户组' }, + { title: '未登录用户组' }, + ]); await database.getModel('collections').import(require('./collections/example').default); await database.getModel('collections').import(require('./collections/authors').default); await database.getModel('collections').import(require('./collections/books').default); diff --git a/packages/app/src/components/form.fields/permissions/Scope.tsx b/packages/app/src/components/form.fields/permissions/Scope.tsx new file mode 100644 index 0000000000..b2cb08a919 --- /dev/null +++ b/packages/app/src/components/form.fields/permissions/Scope.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react' +import { connect } from '@formily/react-schema-renderer' +import { Select, Drawer, Button, Space } from 'antd' +import { + mapStyledProps, + mapTextComponent, + compose, + isStr, + isArr +} from '../shared' +import ViewFactory from '@/components/views' + +function transform({value, multiple, labelField, valueField = 'id'}) { + let selectedKeys = []; + let selectedValue = []; + const values = Array.isArray(value) ? value : []; + selectedKeys = values.map(item => item[valueField]); + selectedValue = values.map(item => { + return { + value: item[valueField], + label: item[labelField], + } + }); + if (!multiple) { + return [selectedKeys.shift(), selectedValue.shift()]; + } + return [selectedKeys, selectedValue]; +} + +export function Scope(props) { + const { target, multiple, associatedName, labelField, valueField = 'id', value, onChange } = props; + const [selectedKeys, selectedValue] = transform({value, multiple, labelField, valueField }); + const [visible, setVisible] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState(multiple ? selectedKeys : [selectedKeys]); + const [selectedRows, setSelectedRows] = useState(selectedValue); + const [options, setOptions] = useState(selectedValue); + // console.log('valuevaluevaluevaluevaluevalue', value); + return ( + <> + + { + setVisible(false); + }} + footer={[ +
+ + + + +
+ + ]} + > + { + // 需要返回的是 array + const [selectedKeys, selectedValue] = transform({value: values, multiple: true, labelField, valueField }); + setSelectedRows(selectedValue); + setSelectedRowKeys(selectedKeys); + // console.log('valuevaluevaluevaluevaluevalue', {values, selectedKeys, selectedValue}); + }} + // associatedKey={} + // associatedName={associatedName} + viewName={'table'} + /> +
+ + ); +} diff --git a/packages/app/src/components/form.fields/permissions/index.tsx b/packages/app/src/components/form.fields/permissions/index.tsx new file mode 100644 index 0000000000..ca78b74074 --- /dev/null +++ b/packages/app/src/components/form.fields/permissions/index.tsx @@ -0,0 +1,227 @@ +import { connect } from '@formily/react-schema-renderer' +import React, { useEffect, useState } from 'react'; +import { Input as AntdInput, Table, Checkbox, Select } from 'antd' +import { acceptEnum, mapStyledProps, mapTextComponent } from '../shared' +import api from '@/api-client'; +import { useRequest } from 'umi'; +import { useDynamicList } from 'ahooks'; +import findIndex from 'lodash/findIndex'; +import get from 'lodash/get'; +import { Scope } from './Scope'; + +export const Permissions = {} as {Actions: any, Fields: any, Tabs: any}; + +Permissions.Actions = connect({ + getProps: mapStyledProps, + getComponent: mapTextComponent +})(({onChange, value = [], resourceKey, ...restProps}) => { + const { data = [], loading = true } = useRequest(() => { + return api.resource('collections.actions').list({ + associatedKey: resourceKey, + }); + }, { + refreshDeps: [resourceKey] + }); + + return { + const values = [...value||[]]; + const index = findIndex(values, (item: any) => item && item.name === `${resourceKey}:${record.name}`); + console.log(values); + return ( + = 0} onChange={(e) => { + // const index = findIndex(values, (item: any) => item && item.name === `${resourceKey}:${record.name}`); + if (index >= 0) { + if (!e.target.checked) { + values.splice(index, 1); + } + } else { + values.push({ + name: `${resourceKey}:${record.name}`, + }); + } + onChange(values); + }}/> + ) + } + }, + { + title: '可操作的数据范围', + dataIndex: ['scope'], + render: (value, record) => + }, + ]} loading={loading}/> +}) + +Permissions.Fields = connect<'TextArea'>({ + getProps: mapStyledProps, + getComponent: mapTextComponent +})(({onChange, value = [], resourceKey, ...restProps}) => { + + const actions = {}; + value.forEach(item => { + actions[item.field_id] = item.actions; + }); + + // console.log(actions); + + const [fields, setFields] = useState(value||[]); + + const { data = [], loading = true } = useRequest(() => { + return api.resource('collections.fields').list({ + associatedKey: resourceKey, + }); + }, { + refreshDeps: [resourceKey] + }); + console.log({resourceKey, data}); + + const columns = [ + { + title: '字段名称', + dataIndex: ['title'], + } + ].concat([ + { + title: '查看', + action: `${resourceKey}:list`, + }, + { + title: '编辑', + action: `${resourceKey}:update`, + }, + { + title: '新增', + action: `${resourceKey}:create`, + }, + ].map(({title, action}) => { + let checked = value.filter(({ actions = [] }) => actions.indexOf(action) !== -1).length === data.length; + return { + title: <> { + const values = data.map(field => { + const items = actions[field.id] || []; + const index = items.indexOf(action); + if (index > -1) { + if (!e.target.checked) { + items.splice(index, 1); + } + } else { + if (e.target.checked) { + items.push(action); + } + } + return { + field_id: field.id, + actions: items, + } + }); + // console.log(values); + setFields([...values]); + onChange([...values]); + }}/> {title}, + dataIndex: ['id'], + render: (val, record) => { + const items = actions[record.id]||[] + // console.log({items}, items.indexOf(action)); + return ( + { + const values = [...value]; + const index = findIndex(values, ({field_id, actions = []}) => { + return field_id === record.id; + }); + if (e.target.checked && index === -1) { + values.push({ + field_id: record.id, + actions: [action], + }); + } else { + const items = values[index].actions || []; + const actionIndex = items.indexOf(action); + if (!e.target.checked && actionIndex > -1) { + items.splice(actionIndex, 1); + // values[index].actions = items; + } else if (e.target.checked && actionIndex === -1) { + items.push(action); + } + } + onChange(values); + setFields(values); + }}/> + ) + } + } + }) as any) + + return
+}) + +Permissions.Tabs = connect<'TextArea'>({ + getProps: mapStyledProps, + getComponent: mapTextComponent +})(({onChange, value = [], resourceKey, ...restProps}) => { + + const { data = [], loading = true, mutate } = useRequest(() => { + return api.resource('collections.tabs').list({ + associatedKey: resourceKey, + }); + }, { + refreshDeps: [resourceKey] + }); + + // const [checked, setChecked] = useState(false); + + // console.log(checked); + + // useEffect(() => { + // setChecked(data.length === value.length); + // console.log({resourceKey, data, value}, data.length === value.lengh); + // }, [ + // data, + // ]); + + return
+ { + onChange(e.target.checked ? data.map(record => record.id) : []); + }}/> 查看 + + ), + dataIndex: ['id'], + render: (val, record) => { + const values = [...value]; + return ( + { + const index = values.indexOf(record.id); + if (index !== -1) { + if (!e.target.checked) { + values.splice(index, 1); + } + } else { + values.push(record.id); + } + onChange(values); + }}/> + ) + } + }, + ]} loading={loading}/> +}); diff --git a/packages/app/src/components/form.fields/registry.ts b/packages/app/src/components/form.fields/registry.ts index 42c77cdb02..8e0af0b6af 100644 --- a/packages/app/src/components/form.fields/registry.ts +++ b/packages/app/src/components/form.fields/registry.ts @@ -19,6 +19,7 @@ import { DrawerSelect } from './drawer-select' import { SubTable } from './sub-table' import { Icon } from './icons' import { ColorSelect } from './color-select' +import { Permissions } from './permissions' export const setup = () => { registerFormFields({ @@ -52,5 +53,8 @@ export const setup = () => { drawerSelect: DrawerSelect, colorSelect: ColorSelect, subTable: SubTable, + 'permissions.actions': Permissions.Actions, + 'permissions.fields': Permissions.Fields, + 'permissions.tabs': Permissions.Tabs, }) } diff --git a/packages/app/src/components/pages/CollectionLoader/CollectionTabPane.tsx b/packages/app/src/components/pages/CollectionLoader/CollectionTabPane.tsx index 573745c70a..7d3fa62479 100644 --- a/packages/app/src/components/pages/CollectionLoader/CollectionTabPane.tsx +++ b/packages/app/src/components/pages/CollectionLoader/CollectionTabPane.tsx @@ -20,6 +20,8 @@ export function CollectionTabPane(props) { params['resourceKey'] = item.itemId; } + console.log({params}); + if (loading) { return ; } diff --git a/packages/app/src/components/views/Details.tsx b/packages/app/src/components/views/Details.tsx index 4fe6f16176..83038f8b75 100644 --- a/packages/app/src/components/views/Details.tsx +++ b/packages/app/src/components/views/Details.tsx @@ -65,7 +65,7 @@ export function Details(props: any) { {fields.map((field: any) => { return ( - + ) })} diff --git a/packages/app/src/components/views/Field/index.tsx b/packages/app/src/components/views/Field/index.tsx index 0e2512d199..f0890cabae 100644 --- a/packages/app/src/components/views/Field/index.tsx +++ b/packages/app/src/components/views/Field/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import moment from 'moment'; -import { Tag, Popover, Table, Drawer, Modal } from 'antd'; +import { Tag, Popover, Table, Drawer, Modal, Checkbox, message } from 'antd'; import Icon from '@/components/icons'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; @@ -14,6 +14,8 @@ import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; import marked from 'marked'; import Lightbox from 'react-image-lightbox'; import 'react-image-lightbox/style.css'; +import api from '@/api-client'; +import { useRequest } from 'umi'; marked.setOptions({ gfm: true, @@ -87,9 +89,28 @@ export function TextareaField(props: any) { <>{value} ); } - +// const { data = [], loading = true } = useRequest(() => { +// return api.resource('collections.actions').list({ +// associatedKey: resourceKey, +// }); +// }, { +// refreshDeps: [resourceKey] +// }); export function BooleanField(props: any) { - const { value } = props; + const { data = {}, value, schema: { name, editable, resource } } = props; + if (editable) { + return { + await api.resource(resource).update({ + associatedKey: data.id, + values: { + [name]: e.target.checked, + }, + }); + message.success('保存成功'); + // console.log(props); + }}/> + } + console.log(props); return ( <>{value ? : } ); @@ -166,19 +187,19 @@ export function DataSourceField(props: any) { if (Array.isArray(value)) { return value.map(val => { const item = items.find(item => item.value === val); - return ( + return item ? ( {item ? item.label : val} - ) + ) : {val}; }); } const item = items.find(item => item.value === value); - return ( + return item ? ( {item ? item.label : value} - ) + ) : {value}; } export function RealtionField(props: any) { diff --git a/packages/app/src/components/views/Form/DrawerForm.tsx b/packages/app/src/components/views/Form/DrawerForm.tsx index 7f9d9f9e2f..ab12f03cd6 100644 --- a/packages/app/src/components/views/Form/DrawerForm.tsx +++ b/packages/app/src/components/views/Form/DrawerForm.tsx @@ -69,7 +69,7 @@ export const DrawerForm = forwardRef((props: any, ref) => { onClose={() => { actions.getFormState(state => { const values = cleanDeep(state.values); - const others = Object.keys(data).length ? cleanDeep({...data, associatedKey}) : cleanDeep(state.initialValues); + const others = Object.keys(data).length ? cleanDeep({...data, associatedKey, resourceKey}) : cleanDeep(state.initialValues); if (isEqual(values, others)) { setVisible(false); return; @@ -132,7 +132,7 @@ export const DrawerForm = forwardRef((props: any, ref) => { colon={true} layout={'vertical'} // 暂时先这么处理,如果有 associatedKey 注入表单里 - initialValues={{associatedKey, ...data}} + initialValues={{associatedKey, resourceKey, ...data}} actions={actions} schema={{ type: 'object', diff --git a/packages/app/src/components/views/SortableTable/index.tsx b/packages/app/src/components/views/SortableTable/index.tsx index 859e80adcd..477efa26cd 100644 --- a/packages/app/src/components/views/SortableTable/index.tsx +++ b/packages/app/src/components/views/SortableTable/index.tsx @@ -61,7 +61,7 @@ export const components = ({data = {}, rowKey, mutate, onMoved, isFieldComponent export function fields2columns(fields) { const columns: any[] = fields.map(item => { const field = cloneDeep(item); - field.render = (value) => field.interface === 'sort' ? : ; + field.render = (value, record) => field.interface === 'sort' ? : ; field.className = `${field.className||''} noco-field-${field.interface}`; return { ...field, diff --git a/packages/app/src/components/views/Table.tsx b/packages/app/src/components/views/Table.tsx index 15390937a3..b6895f0db9 100644 --- a/packages/app/src/components/views/Table.tsx +++ b/packages/app/src/components/views/Table.tsx @@ -42,6 +42,7 @@ export function Table(props: TableProps) { actions = [], paginated = true, defaultPerPage = 10, + disableRowClick, } = schema; // const { data, mutate } = useRequest(() => api.resource(name).list({ // associatedKey, @@ -162,7 +163,7 @@ export function Table(props: TableProps) { data, mutate, rowKey, - isFieldComponent, + isFieldComponent: disableRowClick || isFieldComponent, onMoved: async ({resourceKey, target}) => { await api.resource(name).sort({ associatedKey, @@ -178,7 +179,7 @@ export function Table(props: TableProps) { })} onRow={(data) => ({ onClick: () => { - if (isFieldComponent) { + if (disableRowClick || isFieldComponent) { return; } if (mode === 'simple') { diff --git a/packages/client/.fatherrc.ts b/packages/client/.fatherrc.ts new file mode 100644 index 0000000000..86bce87c0e --- /dev/null +++ b/packages/client/.fatherrc.ts @@ -0,0 +1,5 @@ +export default { + target: 'node', + cjs: { type: 'babel', lazy: true }, + disableTypeCheck: true, +}; diff --git a/packages/database/src/database.ts b/packages/database/src/database.ts index d4b28e355f..77ef72e620 100644 --- a/packages/database/src/database.ts +++ b/packages/database/src/database.ts @@ -30,6 +30,9 @@ export interface ImportOptions { extensions?: string[]; } +export interface DatabaseOptions extends Options { +} + export type HookType = 'beforeTableInit' | 'afterTableInit' | 'beforeAddField' | 'afterAddField'; export default class Database { @@ -48,11 +51,11 @@ export default class Database { protected tables = new Map(); - protected options: Options; + protected options: DatabaseOptions; protected hooks = {}; - constructor(options: Options) { + constructor(options: DatabaseOptions) { this.options = options; this.sequelize = new Sequelize(options); } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 4555b98af8..a1300edbf5 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -5,5 +5,6 @@ export * from './model'; export * from './table'; export * from './fields'; export * from './utils'; +export { default as Operator } from './op'; export default Database; diff --git a/packages/database/src/op.ts b/packages/database/src/op.ts index 99a1a89f04..580f9607fe 100644 --- a/packages/database/src/op.ts +++ b/packages/database/src/op.ts @@ -141,4 +141,16 @@ op.set('$notMatch', (values: any[], options) => { // return Sequelize.literal(`(not (${sql})) AND ${column} IS NULL`); }); -export default op; +export default class Operator { + public static get(key: string) { + return op.get(key); + } + + public static has(key: string) { + return op.has(key); + } + + public static register(key: string, fn: Function) { + op.set(key, fn); + } +}; diff --git a/packages/database/src/utils.ts b/packages/database/src/utils.ts index f315effcac..ce5f62e7fd 100644 --- a/packages/database/src/utils.ts +++ b/packages/database/src/utils.ts @@ -54,7 +54,8 @@ export function toWhere(options: any, context: ToWhereContext = {}) { switch (typeof opKey) { case 'function': const name = model ? model.name : ''; - const result = opKey(items[key], { + const result = opKey(items[key], { + ctx, model, database, fieldPath: name ? `${name}.${prefix}` : prefix, diff --git a/packages/plugin-collections/src/collections/collections.ts b/packages/plugin-collections/src/collections/collections.ts index 49dae45e3c..e258890afe 100644 --- a/packages/plugin-collections/src/collections/collections.ts +++ b/packages/plugin-collections/src/collections/collections.ts @@ -336,15 +336,23 @@ export default { actionNames: ['update'], developerMode: true, }, - // { - // type: 'table', - // name: 'simple', - // title: '简易模式', - // template: 'SimpleTable', - // actionNames: ['create', 'destroy'], - // detailsViewName: 'details', - // updateViewName: 'form', - // }, + { + type: 'table', + name: 'permissionTable', + title: '权限设置表格', + mode: 'simple', + template: 'SimpleTable', + // actionNames: ['create', 'destroy'], + detailsViewName: 'details', + updateViewName: 'permissionForm', + }, + { + type: 'form', + name: 'permissionForm', + title: '权限设置表单', + mode: 'simple', + template: 'DrawerForm', + }, { type: 'table', name: 'table', diff --git a/packages/plugin-file-manager/src/__tests__/index.ts b/packages/plugin-file-manager/src/__tests__/index.ts index 3e29fd7284..4cee60d2e3 100644 --- a/packages/plugin-file-manager/src/__tests__/index.ts +++ b/packages/plugin-file-manager/src/__tests__/index.ts @@ -4,8 +4,8 @@ import supertest from 'supertest'; import bodyParser from 'koa-bodyparser'; import { Dialect } from 'sequelize'; import Database from '@nocobase/database'; -import { actions, middlewares } from '@nocobase/actions'; -import { Application } from '@nocobase/server'; +import { actions, middlewares } from '@nocobase/actions/src'; +import { Application } from '@nocobase/server/src'; import middleware from '@nocobase/server/src/middleware' import plugin from '../server'; import { FILE_FIELD_NAME } from '../constants'; @@ -62,7 +62,7 @@ export async function getApp() { }); app.resourcer.use(middlewares.associated); app.resourcer.registerActionHandlers({...actions.associate, ...actions.common}); - app.registerPlugins({ + app.registerPlugin({ 'collections': [path.resolve(__dirname, '../../../plugin-collections')], 'file-manager': [plugin] }); diff --git a/packages/plugin-pages/src/actions/getCollection.ts b/packages/plugin-pages/src/actions/getCollection.ts index e821785234..6fc0226684 100644 --- a/packages/plugin-pages/src/actions/getCollection.ts +++ b/packages/plugin-pages/src/actions/getCollection.ts @@ -17,9 +17,11 @@ export default async (ctx, next) => { }); collection.setDataValue('defaultViewName', defaultView.get('name')); const options = Tab.parseApiJson({ - filter: { + filter: ctx.state.developerMode ? { enabled: true, - developerMode: ctx.state.developerMode ? {'$isTruly': true} : {'$isFalsy': true}, + } : { + enabled: true, + developerMode: {'$isFalsy': true}, }, fields: { appends: ['associationField'], diff --git a/packages/plugin-pages/src/actions/getRoutes.ts b/packages/plugin-pages/src/actions/getRoutes.ts index 65547fa563..258a66224d 100644 --- a/packages/plugin-pages/src/actions/getRoutes.ts +++ b/packages/plugin-pages/src/actions/getRoutes.ts @@ -40,9 +40,13 @@ export default async function getRoutes(ctx, next) { const database: Database = ctx.database; const Page = database.getModel('pages'); const Collection = database.getModel('collections'); - let pages = await Page.findAll(Page.parseApiJson({ + let pages = await Page.findAll(Page.parseApiJson(ctx.state.developerMode ? { filter: { - developerMode: ctx.state.developerMode ? {'$isTruly': true} : {'$isFalsy': true}, + }, + sort: ['sort'], + } : { + filter: { + developerMode: {'$isFalsy': true}, }, sort: ['sort'], })); @@ -50,9 +54,14 @@ export default async function getRoutes(ctx, next) { for (const page of pages) { items.push(page.toJSON()); if (page.get('path') === '/collections') { - const collections = await Collection.findAll(Collection.parseApiJson({ + const collections = await Collection.findAll(Collection.parseApiJson(ctx.state.developerMode ? { filter: { - developerMode: ctx.state.developerMode ? {'$isTruly': true} : {'$isFalsy': true}, + showInDataMenu: true, + }, + sort: ['sort'], + }: { + filter: { + developerMode: {'$isFalsy': true}, showInDataMenu: true, }, sort: ['sort'], diff --git a/packages/plugin-pages/src/actions/getView.ts b/packages/plugin-pages/src/actions/getView.ts index 75eb0474d0..19e3fd6dfc 100644 --- a/packages/plugin-pages/src/actions/getView.ts +++ b/packages/plugin-pages/src/actions/getView.ts @@ -38,10 +38,15 @@ const transforms = { } if (field.get('name') === 'filter' && field.get('collection_name') === 'views') { const { values } = ctx.action.params; - const options = Field.parseApiJson({ + const options = Field.parseApiJson(ctx.state.developerMode ? { filter: { collection_name: get(values, 'associatedKey'), - developerMode: ctx.state.developerMode ? {'$isTruly': true} : {'$isFalsy': true}, + }, + sort: 'sort', + } : { + filter: { + collection_name: get(values, 'associatedKey'), + developerMode: {'$isFalsy': true}, }, sort: 'sort', }); @@ -200,8 +205,9 @@ export default async (ctx, next) => { // const where: any = { // developerMode: ctx.state.developerMode, // } - const filter: any = { - developerMode: ctx.state.developerMode ? {'$isTruly': true} : {'$isFalsy': true}, + const filter: any = {} + if (!ctx.state.developerMode) { + filter.developerMode = {'$isFalsy': true} } if (!view.get('draggable')) { filter.type = { @@ -221,15 +227,18 @@ export default async (ctx, next) => { } return true; }) - const options = Action.parseApiJson({ + const options = Action.parseApiJson(ctx.state.developerMode ? { + filter: {}, + sort: 'sort', + } : { filter: { - developerMode: ctx.state.developerMode ? {'$isTruly': true} : {'$isFalsy': true}, + developerMode: {'$isFalsy': true}, }, sort: 'sort', }); const actions = await collection.getActions(options); let actionNames = view.get('actionNames') || []; - if (actionNames.length === 0) { + if (actionNames.length === 0 && resourceKey !== 'permissionTable') { actionNames = ['filter', 'create', 'destroy']; } const defaultTabs = await collection.getTabs({ @@ -301,17 +310,156 @@ export default async (ctx, next) => { } } actionDefaultParams['fields[appends]'] = appends.join(','); - ctx.body = { - ...view.get(), - title, - actionDefaultParams, - original: fields, - fields: await (transforms[view.type]||transforms.table)(fields, ctx), - actions: actions.filter(action => actionNames.includes(action.name)).map(action => ({ - ...action.toJSON(), - ...action.options, - // viewCollectionName: action.collection_name, - })), - }; + if (resourceFieldName === 'pages' && resourceKey === 'permissionTable') { + ctx.body = { + ...view.get(), + title, + actionDefaultParams, + original: fields, + disableRowClick: true, + fields: [ + { + "title": "页面", + "name": "title", + "interface": "string", + "type": "string", + "parent_id": null, + "required": true, + "developerMode": false, + "component": { + "type": "string", + "className": "drag-visible", + "showInForm": true, + "showInTable": true, + "showInDetail": true + }, + "dataIndex": ["title"] + }, + { + "title": "访问权限", + "name": "accessable", + "interface": "boolean", + "type": "boolean", + "parent_id": null, + "required": true, + "editable": true, + "resource": 'roles.pages', + "developerMode": false, + "component": { + "type": "boolean", + "showInTable": true, + }, + "dataIndex": ["accessable"] + } + ], + }; + } else if (resourceFieldName === 'collections' && resourceKey === 'permissionTable') { + ctx.body = { + ...view.get(), + title, + actionDefaultParams, + original: fields, + rowKey: 'name', + fields: [ + { + "title": "数据表名称", + "name": "title", + "interface": "string", + "type": "string", + "parent_id": null, + "required": true, + "developerMode": false, + "component": { + "type": "string", + "className": "drag-visible", + "showInForm": true, + "showInTable": true, + "showInDetail": true + }, + "dataIndex": ["title"] + } + ], + }; + } else if (resourceFieldName === 'collections' && resourceKey === 'permissionForm') { + ctx.body = { + ...view.get(), + title, + actionDefaultParams, + original: fields, + fields: { + actions: { + type: 'permissions.actions', + title: '数据操作权限', + 'x-linkages': [ + { + type: "value:schema", + target: "actions", + schema: { + "x-component-props": { + resourceKey: "{{ $form.values && $form.values.resourceKey }}" + }, + }, + }, + ], + 'x-component-props': { + dataSource: [], + }, + }, + fields: { + type: 'permissions.fields', + title: '字段权限', + 'x-linkages': [ + { + type: "value:schema", + target: "fields", + schema: { + "x-component-props": { + resourceKey: "{{ $form.values && $form.values.resourceKey }}" + }, + }, + }, + ], + 'x-component-props': { + dataSource: [], + } + }, + tabs: { + type: 'permissions.tabs', + title: '标签页权限', + 'x-linkages': [ + { + type: "value:schema", + target: "tabs", + schema: { + "x-component-props": { + resourceKey: "{{ $form.values && $form.values.resourceKey }}" + }, + }, + }, + ], + 'x-component-props': { + dataSource: [], + } + }, + description: { + type: 'textarea', + title: '权限描述', + }, + }, + }; + } else { + ctx.body = { + ...view.get(), + title, + actionDefaultParams, + original: fields, + fields: await (transforms[view.type]||transforms.table)(fields, ctx), + actions: actions.filter(action => actionNames.includes(action.name)).map(action => ({ + ...action.toJSON(), + ...action.options, + // viewCollectionName: action.collection_name, + })), + }; + } await next(); }; diff --git a/packages/plugin-pages/src/collections/pages.ts b/packages/plugin-pages/src/collections/pages.ts index bcf8b26b61..0bced642ab 100644 --- a/packages/plugin-pages/src/collections/pages.ts +++ b/packages/plugin-pages/src/collections/pages.ts @@ -74,7 +74,7 @@ export default { type: 'string', name: 'type', title: '类型', - options: [ + dataSource: [ { label: '页面', value: 'page', @@ -118,7 +118,7 @@ export default { type: 'string', name: 'template', title: '模板', - options: [ + dataSource: [ { label: '顶部菜单布局', value: 'TopMenuLayout', @@ -241,11 +241,12 @@ export default { actionNames: ['update'], }, { - type: 'simple', + type: 'table', name: 'simple', title: '简易模式', template: 'SimpleTable', default: true, + mode: 'simple', actionNames: ['create', 'destroy'], detailsViewName: 'details', updateViewName: 'form', @@ -258,5 +259,15 @@ export default { template: 'Table', actionNames: ['create', 'destroy'], }, + { + type: 'table', + name: 'permissionTable', + title: '菜单权限', + template: 'Table', + mode: 'simple', + detailsViewName: 'details', + updateViewName: 'form', + paginated: false, + }, ], } as TableOptions; diff --git a/packages/plugin-permissions/package.json b/packages/plugin-permissions/package.json index 59bf79cf53..66ebd4b11f 100644 --- a/packages/plugin-permissions/package.json +++ b/packages/plugin-permissions/package.json @@ -4,7 +4,9 @@ "main": "lib/index.js", "license": "MIT", "dependencies": { + "@nocobase/actions": "^0.3.0-alpha.0", "@nocobase/database": "^0.3.0-alpha.0", - "@nocobase/resourcer": "^0.3.0-alpha.0" + "@nocobase/resourcer": "^0.3.0-alpha.0", + "@nocobase/server": "^0.3.0-alpha.0" } } diff --git a/packages/plugin-permissions/src/__tests__/index.ts b/packages/plugin-permissions/src/__tests__/index.ts new file mode 100644 index 0000000000..d369cab092 --- /dev/null +++ b/packages/plugin-permissions/src/__tests__/index.ts @@ -0,0 +1,145 @@ +import path from 'path'; +import qs from 'qs'; +import supertest from 'supertest'; +import bodyParser from 'koa-bodyparser'; +import { Dialect } from 'sequelize'; +import Database from '@nocobase/database'; +import { actions, middlewares } from '@nocobase/actions'; +import { Application } from '@nocobase/server'; +import middleware from '@nocobase/server/src/middleware'; +import plugin from '../server'; + +function getTestKey() { + const { id } = require.main; + const key = id + .replace(`${process.env.PWD}/packages`, '') + .replace(/src\/__tests__/g, '') + .replace('.test.ts', '') + .replace(/[^\w]/g, '_') + .replace(/_+/g, '_'); + return key +} + +const config = { + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + host: process.env.DB_HOST, + port: Number.parseInt(process.env.DB_PORT, 10), + dialect: process.env.DB_DIALECT as Dialect, + logging: process.env.DB_LOG_SQL === 'on', + sync: { + force: true, + alter: { + drop: true, + }, + }, + hooks: { + beforeDefine(columns, model) { + model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`; + } + }, +}; + +export function getDatabase() { + return new Database(config); +}; + +export async function getApp() { + const app = new Application({ + database: config, + resourcer: { + prefix: '/api', + }, + }); + app.resourcer.use(middlewares.associated); + app.resourcer.registerActionHandlers({...actions.associate, ...actions.common}); + app.registerPlugin({ + collections: path.resolve(__dirname, '../../../plugin-collections'), + users: path.resolve(__dirname, '../../../plugin-users'), + permissions: plugin + }); + await app.loadPlugins(); + const testTables = app.database.import({ + directory: path.resolve(__dirname, './tables') + }); + try { + await app.database.sync(); + }catch(err) { + console.error(err); + } + + for (const table of testTables.values()) { + // TODO(bug): 由于每个用例结束后不会清理用于测试的数据表,导致再次创建和更新 + // 创建和更新里面仍会再次创建 fields,导致创建相关的数据重复,数据库报错。 + await app.database.getModel('collections').import(table.getOptions(), { update: true, migrate: false }); + } + app.context.db = app.database; + app.use(bodyParser()); + app.use(middleware({ + prefix: '/api', + resourcer: app.resourcer, + database: app.database, + })); + return app; +} + +interface ActionParams { + resourceKey?: string | number; + // resourceName?: string; + // associatedName?: string; + associatedKey?: string | number; + fields?: any; + filter?: any; + values?: any; + [key: string]: any; +} + +interface Handler { + get: (params?: ActionParams) => Promise; + list: (params?: ActionParams) => Promise; + create: (params?: ActionParams) => Promise; + update: (params?: ActionParams) => Promise; + destroy: (params?: ActionParams) => Promise; + [name: string]: (params?: ActionParams) => Promise; +} + +export interface Agent { + resource: (name: string) => Handler; +} + +export function getAgent(app: Application) { + return supertest.agent(app.callback()); +} + +export function getAPI(app: Application) { + const agent = getAgent(app); + return { + resource(name: string): any { + return new Proxy({}, { + get(target, method: string, receiver) { + return (params: ActionParams = {}) => { + const { associatedKey, resourceKey, values = {}, filePath, ...restParams } = params; + let url = `/api/${name}`; + if (associatedKey) { + url = `/api/${name.split('.').join(`/${associatedKey}/`)}`; + } + url += `:${method as string}`; + if (resourceKey) { + url += `/${resourceKey}`; + } + + switch (method) { + case 'list': + case 'get': + return agent.get(`${url}?${qs.stringify(restParams)}`); + + default: + return agent.post(`${url}?${qs.stringify(restParams)}`).send(values); + } + } + } + }); + } + }; +} diff --git a/packages/plugin-permissions/src/__tests__/list.test.ts b/packages/plugin-permissions/src/__tests__/list.test.ts new file mode 100644 index 0000000000..4ffc2d3d54 --- /dev/null +++ b/packages/plugin-permissions/src/__tests__/list.test.ts @@ -0,0 +1,189 @@ +import { getApp, getAgent, getAPI } from '.'; + +describe.skip('list', () => { + let app; + let agent; + let api; + let db; + let userAgents; + + beforeEach(async () => { + app = await getApp(); + agent = getAgent(app); + db = app.database; + + const User = db.getModel('users'); + const users = await User.bulkCreate([ + { username: 'user1', token: 'token1' }, + { username: 'user2', token: 'token2' }, + { username: 'user3', token: 'token3' }, + { username: 'user4', token: 'token4' }, + ]); + + userAgents = users.map(user => { + const userAgent = getAgent(app); + userAgent.set('Authorization', `Bearer ${users[0].token}`); + return userAgent; + }); + + const Role = db.getModel('roles'); + const roles = await Role.bulkCreate([ + { title: '匿名用户', type: 0 }, + { title: '普通用户' }, + { title: '编辑' }, + { title: '管理员', type: -1 }, + ]); + + const Field = db.getModel('fields'); + const postTitleField = await Field.findOne({ + where: { + name: 'title', + collection_name: 'posts' + } + }); + const postStatusField = await Field.findOne({ + where: { + name: 'status', + collection_name: 'posts' + } + }); + const postCategoryField = await Field.findOne({ + where: { + name: 'category', + collection_name: 'posts' + } + }); + + // 匿名用户 + await roles[0].updateAssociations({ + permissions: [ + { + collection_name: 'posts', + actions_permissions: [ + { + name: 'list', + scope: { filter: { status: 'published' }, collection_name: 'posts' }, + } + ], + fields: [ + { + id: postTitleField.id, + fields_permissions: { actions: ['posts:list'] } + } + ] + }, + { + collection_name: 'categories', + actions_permissions: [ + { name: 'list' } + ] + } + ] + }); + + // 普通用户 + await roles[1].updateAssociations({ + users: [users[0], users[3]], + permissions: [ + { + collection_name: 'posts', + actions_permissions: [ + { + name: 'list', + // TODO(bug): 字段应使用 'created_by' 名称,通过程序映射成外键 + scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' }, + }, + { + name: 'update', + scope: { filter: { status: 'draft', 'created_by_id.$currentUser': true }, collection_name: 'posts' }, + } + ], + fields: [ + { + id: postTitleField.id, + fields_permissions: { actions: ['posts:list', 'posts:create', 'posts:update'] } + }, + { + id: postStatusField.id, + fields_permissions: { actions: ['posts:list'] } + }, + { + id: postCategoryField.id, + fields_permissions: { actions: ['posts:list'] } + }, + ] + } + ] + }); + + // 编辑 + await roles[2].updateAssociations({ + users: [users[1], users[3]], + permissions: [ + { + collection_name: 'posts', + actions_permissions: [ + { + name: 'update' + } + ], + fields: [ + { + id: postTitleField.id, + fields_permissions: { actions: ['posts:update'] } + }, + { + id: postStatusField.id, + fields_permissions: { actions: ['posts:update'] } + }, + { + id: postCategoryField.id, + fields_permissions: { actions: ['posts:update'] } + }, + ] + }, + { + collection_name: 'categories', + actions_permissions: [ + { name: 'create' }, + { name: 'update' }, + { name: 'destroy' }, + ] + } + ] + }); + + // 管理员 + + const Post = db.getModel('posts'); + await Post.bulkCreate([ + { title: 'title1', created_by_id: users[0].id }, + { title: 'title2', created_by_id: users[0].id }, + { title: 'title3', created_by_id: users[1].id }, + { title: 'title4', created_by_id: users[3].id }, + ]); + }); + + afterEach(() => db.close()); + + describe('anonymous', () => { + it('fetch all drafts only get empty list', async () => { + const response = await agent.get('/api/posts'); + expect(response.status).toBe(200); + expect(response.body.count).toBe(0); + }); + }); + + // TODO(bug): 单独执行可以通过。 + // 由于 app.database.getModel('collections').import() 有 bug,无法正确的完成数据构造。 + describe('normal user', () => { + it('user could get posts created by self and limited fields', async () => { + const response = await userAgents[0].get('/api/posts?sort=title'); + expect(response.body.count).toBe(2); + expect(response.body.rows).toEqual([ + { title: 'title1', status: 'draft', category: null }, + { title: 'title2', status: 'draft', category: null } + ]); + }); + }); +}); diff --git a/packages/plugin-permissions/src/__tests__/tables/categories.ts b/packages/plugin-permissions/src/__tests__/tables/categories.ts new file mode 100644 index 0000000000..7d1e0f501d --- /dev/null +++ b/packages/plugin-permissions/src/__tests__/tables/categories.ts @@ -0,0 +1,15 @@ +import { TableOptions } from "@nocobase/database"; + +export default { + name: 'categories', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'hasMany', + name: 'posts', + }, + ] +} as TableOptions; diff --git a/packages/plugin-permissions/src/__tests__/tables/comments.ts b/packages/plugin-permissions/src/__tests__/tables/comments.ts new file mode 100644 index 0000000000..1253e4da4a --- /dev/null +++ b/packages/plugin-permissions/src/__tests__/tables/comments.ts @@ -0,0 +1,19 @@ +import { TableOptions } from "@nocobase/database"; + +export default { + name: 'comments', + fields: [ + { + type: 'string', + name: 'content', + }, + { + type: 'belongsTo', + name: 'post', + }, + { + type: 'belongsTo', + name: 'user', + }, + ] +} as TableOptions; diff --git a/packages/plugin-permissions/src/__tests__/tables/posts.ts b/packages/plugin-permissions/src/__tests__/tables/posts.ts new file mode 100644 index 0000000000..a4588ede1a --- /dev/null +++ b/packages/plugin-permissions/src/__tests__/tables/posts.ts @@ -0,0 +1,26 @@ +import { TableOptions } from "@nocobase/database"; + +export default { + name: 'posts', + // 目前默认就带了 + // createdBy: true, + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'string', + name: 'status', + defaultValue: 'draft', + }, + { + type: 'belongsTo', + name: 'category', + }, + { + type: 'hasMany', + name: 'comments', + }, + ] +} as TableOptions; diff --git a/packages/plugin-permissions/src/actions/roles.collections.ts b/packages/plugin-permissions/src/actions/roles.collections.ts new file mode 100644 index 0000000000..f8ff08476c --- /dev/null +++ b/packages/plugin-permissions/src/actions/roles.collections.ts @@ -0,0 +1,85 @@ +import { actions } from '@nocobase/actions'; + +export async function list(ctx: actions.Context, next: actions.Next) { + // TODO: 暂时 action 中间件就这么写了 + ctx.action.mergeParams({associated: null}); + return actions.common.list(ctx, next); +} + +export async function get(ctx: actions.Context, next: actions.Next) { + const { + resourceKey, + associated + } = ctx.action.params; + + const [permission] = await associated.getPermissions({ + where: { + collection_name: resourceKey + }, + include: [ + { + association: 'actions_permissions', + // 对 hasMany 关系可以进行拆分查询,避免联表过多标识符超过 PG 的 64 字符限制 + separate: true, + include: [ + { + association: 'scope', + attribute: ['filter'] + }, + ] + }, + { + association: 'fields_permissions', + separate: true, + } + ], + limit: 1 + }); + + const result = permission + ? { + actions: permission.actions_permissions || [], + fields: permission.fields_permissions || [], + tabs: permission.tabs_permissions || [], + } + : { + actions: [], + fields: [], + tabs: [] + }; + + ctx.body = result; + + await next(); +} + +export async function update(ctx: actions.Context, next: actions.Next) { + const { + resourceKey, + associated, + values + } = ctx.action.params; + + let [permission] = await associated.getPermissions({ + where: { + collection_name: resourceKey + } + }); + if (!permission) { + permission = await associated.createPermission({ + collection_name: resourceKey, + description: values.description + }); + } else { + await permission.update({ description: values.description }); + } + + await permission.updateAssociations({ + actions_permissions: values.actions, + fields_permissions: values.fields, + tabs: values.tabs + }); + + ctx.body = permission; + await next(); +} diff --git a/packages/plugin-permissions/src/actions/roles.pages.ts b/packages/plugin-permissions/src/actions/roles.pages.ts new file mode 100644 index 0000000000..24f3bf201b --- /dev/null +++ b/packages/plugin-permissions/src/actions/roles.pages.ts @@ -0,0 +1,12 @@ +import { actions } from '@nocobase/actions'; + +export async function list(ctx: actions.Context, next: actions.Next) { + // TODO: 暂时 action 中间件就这么写了 + ctx.action.mergeParams({associated: null}); + return actions.common.list(ctx, next); +} + +export async function update(ctx: actions.Context, next: actions.Next) { + ctx.body = {}; + await next(); +} diff --git a/packages/plugin-permissions/src/collections/actions_premissions.ts b/packages/plugin-permissions/src/collections/actions_premissions.ts new file mode 100644 index 0000000000..88f2f486a1 --- /dev/null +++ b/packages/plugin-permissions/src/collections/actions_premissions.ts @@ -0,0 +1,25 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'actions_permissions', + title: '表操作权限', + developerMode: true, + internal: true, + fields: [ + { + comment: '程序操作名称("list", "create" 等)', + type: 'string', + name: 'name', + }, + { + comment: '操作范围', + type: 'belongsTo', + name: 'scope', + target: 'actions_scopes' + }, + { + type: 'belongsTo', + name: 'permission', + }, + ], +} as TableOptions; diff --git a/packages/plugin-permissions/src/collections/actions_scopes.ts b/packages/plugin-permissions/src/collections/actions_scopes.ts new file mode 100644 index 0000000000..2aafdab604 --- /dev/null +++ b/packages/plugin-permissions/src/collections/actions_scopes.ts @@ -0,0 +1,39 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'actions_scopes', + title: '表操作范围', + developerMode: true, + internal: true, + fields: [ + { + comment: '范围名称', + type: 'string', + name: 'title', + title: '名称', + component: { + type: 'filter', + showInTable: true, + showInForm: true, + }, + }, + { + interface: 'json', + type: 'jsonb', + name: 'filter', + title: '条件', + developerMode: false, + mode: 'replace', + defaultValue: {}, + component: { + type: 'filter', + showInForm: true, + }, + }, + { + type: 'belongsTo', + name: 'collection', + targetKey: 'name' + } + ], +} as TableOptions; diff --git a/packages/plugin-permissions/src/collections/fields_permissions.ts b/packages/plugin-permissions/src/collections/fields_permissions.ts new file mode 100644 index 0000000000..724dc527de --- /dev/null +++ b/packages/plugin-permissions/src/collections/fields_permissions.ts @@ -0,0 +1,22 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'fields_permissions', + title: '列操作权限', + developerMode: true, + internal: true, + fields: [ + { + type: 'belongsTo', + name: 'permission', + }, + { + type: 'belongsTo', + name: 'field' + }, + { + type: 'jsonb', + name: 'actions' + } + ], +} as TableOptions; diff --git a/packages/plugin-permissions/src/collections/permissions.ts b/packages/plugin-permissions/src/collections/permissions.ts new file mode 100644 index 0000000000..f4446ff088 --- /dev/null +++ b/packages/plugin-permissions/src/collections/permissions.ts @@ -0,0 +1,107 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'permissions', + title: '权限集', + developerMode: true, + internal: true, + fields: [ + // TODO(feature): 黑白名单控制 + // { + // comment: '白名单或黑名单', + // type: 'boolean', + // name: 'mode', + // defaultValue: false + // }, + { + type: 'integer', + name: 'id', + primaryKey: true, + autoIncrement: true + }, + { + type: 'string', + name: 'desctiption', + }, + { + comment: '关联的角色', + type: 'belongsTo', + name: 'role', + }, + { + comment: '关联的表名', + type: 'belongsTo', + name: 'collection', + targetKey: 'name' + }, + { + comment: '允许的操作集', + type: 'hasMany', + name: 'actions_permissions', + }, + { + type: 'belongsToMany', + name: 'fields', + through: 'fields_permissions', + }, + { + type: 'hasMany', + name: 'fields_permissions' + }, + // { + // comment: '允许的 views 列表', + // type: 'belongsToMany', + // name: 'views', + // through: 'views_permissions' + // }, + // { + // comment: '视图集(方便访问)', + // type: 'hasMany', + // name: 'views_permissions', + // }, + // { + // comment: '允许的 tabs 列表', + // type: 'belongsToMany', + // name: 'tabs', + // through: 'tabs_permissions' + // }, + // { + // comment: '标签页集(方便访问)', + // type: 'hasMany', + // name: 'tabs_permissions', + // }, + ], + views: [ + { + type: 'form', + name: 'form', + title: '表单', + template: 'DrawerForm', + }, + { + type: 'details', + name: 'details', + title: '详情', + template: 'Details', + actionNames: ['update'], + }, + { + type: 'simple', + name: 'simple', + title: '简易模式', + mode: 'simple', + template: 'SimpleTable', + actionNames: ['create', 'destroy'], + detailsViewName: 'details', + updateViewName: 'form', + }, + { + type: 'table', + name: 'table', + title: '列表', + template: 'Table', + actionNames: ['create', 'destroy'], + default: true, + }, + ], +} as TableOptions; diff --git a/packages/plugin-permissions/src/collections/roles.ts b/packages/plugin-permissions/src/collections/roles.ts index ad61000904..65506aef60 100644 --- a/packages/plugin-permissions/src/collections/roles.ts +++ b/packages/plugin-permissions/src/collections/roles.ts @@ -2,43 +2,59 @@ import { TableOptions } from '@nocobase/database'; export default { name: 'roles', - title: '权限配置', + title: '权限组配置', + developerMode: true, + internal: true, fields: [ { + interface: 'string', + title: '权限组名称', + comment: '角色名称', type: 'string', name: 'title', + component: { + showInTable: true, + showInForm: true, + showInDetail: true, + }, }, { - type: 'string', - name: 'name', + interface: 'boolean', + comment: '支持匿名用户', + type: 'boolean', + name: 'anonymous', + defaultValue: false + }, + // TODO(feature): 用户组后续考虑 + // TODO(feature): 用户表应通过插件配置关联,考虑到今后会有多账户系统的情况 + { + interface: 'linkTo', + title: '用户', + comment: '关联的用户表', + type: 'belongsToMany', + name: 'users', + target: 'users', + through: 'users_roles' }, { - type: 'json', - name: 'options', - }, - ], - actions: [ - { - type: 'list', - name: 'list', - title: '查看', + interface: 'linkTo', + title: '数据表', + comment: '包含的以数据表分组的权限集', + type: 'belongsToMany', + name: 'collections', + through: 'permissions', + targetKey: 'name' }, { - type: 'create', - name: 'create', - title: '新增', - viewName: 'form', + interface: 'linkTo', + title: '页面', + type: 'belongsToMany', + name: 'pages', }, { - type: 'update', - name: 'update', - title: '编辑', - viewName: 'form', - }, - { - type: 'destroy', - name: 'destroy', - title: '删除', + comment: '权限集(方便访问)', + type: 'hasMany', + name: 'permissions' }, ], views: [ @@ -48,12 +64,6 @@ export default { title: '表单', template: 'DrawerForm', }, - { - type: 'form', - name: 'permission_form', - title: '数据表配置', - template: 'PermissionForm', - }, { type: 'details', name: 'details', @@ -62,22 +72,14 @@ export default { actionNames: ['update'], }, { - type: 'simple', + type: 'table', name: 'simple', title: '简易模式', + mode: 'simple', template: 'SimpleTable', actionNames: ['create', 'destroy'], detailsViewName: 'details', - updateViewName: 'permission_form', - }, - { - type: 'simple', - name: 'simple2', - title: '简易模式', - template: 'SimpleTable', - detailsViewName: 'details', - updateViewName: 'permission_form', - paginated: false, + updateViewName: 'form', }, { type: 'table', @@ -96,10 +98,31 @@ export default { viewName: 'details', default: true, }, + // { + // type: 'details', + // name: 'collections', + // title: '数据表权限', + // viewName: 'simple', + // }, { - type: 'details', + type: 'association', name: 'collections', title: '数据表权限', + association: 'collections', + viewName: 'permissionTable', + }, + { + type: 'association', + name: 'pages', + title: '页面权限', + association: 'pages', + viewName: 'permissionTable', + }, + { + type: 'association', + name: 'users', + title: '当前组用户', + association: 'users', viewName: 'simple', }, ], diff --git a/packages/plugin-permissions/src/server.ts b/packages/plugin-permissions/src/server.ts index 16e788eff7..13ba544686 100644 --- a/packages/plugin-permissions/src/server.ts +++ b/packages/plugin-permissions/src/server.ts @@ -1,12 +1,184 @@ import path from 'path'; -import Database from '@nocobase/database'; +import { Op } from 'sequelize'; +import { Application } from '@nocobase/server'; +import Database, { Operator } from '@nocobase/database'; import Resourcer from '@nocobase/resourcer'; +import * as rolesCollectionsActions from './actions/roles.collections'; +import * as rolesPagesActions from './actions/roles.pages'; + +// API +// const permissions = ctx.app.getPluginInstance('permissions'); +// const result: boolean = permissions.check(ctx); + +class Permissions { + readonly app: Application; + readonly options: any; + + static check(roles) { + return Boolean(this.getActionPermissions(roles).length); + } + + static getActionPermissions(roles) { + const permissions = roles.reduce((permissions, role) => permissions.concat(role.get('permissions')), []); + return permissions.reduce((actions, permission) => actions.concat(permission.get('actions_permissions')), []); + } + + static getFieldPermissions(roles) { + const permissions = roles.reduce((permissions, role) => permissions.concat(role.get('permissions')), []); + return permissions.reduce((fields, permission) => fields.concat(permission.get('fields_permissions')), []); + } + + constructor(app: Application, options) { + this.app = app; + this.options = options; + + const database: Database = app.database; + const resourcer: Resourcer = app.resourcer; + + database.import({ + directory: path.resolve(__dirname, 'collections'), + }); + + Object.keys(rolesCollectionsActions).forEach(actionName => { + resourcer.registerActionHandler(`roles.collections:${actionName}`, rolesCollectionsActions[actionName]); + }); + + Object.keys(rolesPagesActions).forEach(actionName => { + resourcer.registerActionHandler(`roles.pages:${actionName}`, rolesPagesActions[actionName]); + }); + + // TODO(optimize): 临时处理,相关逻辑还需要更严格 + const usersTable = database.getTable('users'); + if (!usersTable.hasField('roles')) { + usersTable.addField({ + type: 'belongsToMany', + name: 'roles', + through: 'users_roles' + }); + } + + // 针对“自己创建的” scope 添加特殊的操作符以生成查询条件 + if (!Operator.has('$currentUser')) { + Operator.register('$currentUser', (value, { ctx }) => { + const user = this.getCurrentUser(ctx); + return { [Op.eq]: user[user.constructor.primaryKeyAttribute] }; + }); + } + + // resourcer.use(this.middleware()); + } + + middleware() { + return async (ctx, next) => { + const { + resourceName, + actionName + } = ctx.action.params; + + const roles = await this.getRolesWithPermissions(ctx); + const actionPermissions = Permissions.getActionPermissions(roles); + + if (!actionPermissions.length) { + return ctx.throw(404); + } + + const filters = actionPermissions + .filter(item => Boolean(item.scope) && Object.keys(item.scope.filter).length) + .map(item => item.scope.filter); + + const fields = new Set(); + const fieldPermissions = Permissions.getFieldPermissions(roles); + fieldPermissions.forEach(item => { + const actions = item.get('actions'); + if (actions && actions.includes(`${resourceName}:${actionName}`)) { + fields.add(item.get('field').get('name')); + } + }); + + ctx.action.mergeParams({ + ...(filters.length + ? { filter: filters.length > 1 ? { or: filters } : filters[0] } + : {}), + fields: Array.from(fields) + }); + + return next(); + }; + } + + getCurrentUser(ctx) { + // TODO: 获取当前用户应调用当前依赖用户插件的相关方法 + return ctx.state.currentUser; + } + + async getRolesWithPermissions(ctx) { + // TODO: 还未定义关联数据的权限如何表达 + const { + resourceName, + associated, + associatedName, + associatedKey, + actionName + } = ctx.action.params; + + const Role = ctx.db.getModel('roles'); + const permissionInclusion = { + association: 'permissions', + where: { + collection_name: resourceName + }, + required: true, + include: [ + { + association: 'actions_permissions', + where: { + name: actionName + }, + required: true, + // 对 hasMany 关系可以进行拆分查询,避免联表过多标识符超过 PG 的 64 字符限制 + separate: true, + include: [ + { + association: 'scope', + attribute: ['filter'] + } + ] + }, + { + association: 'fields_permissions', + include: [ + { + association: 'field', + attributes: ['name'] + } + ], + separate: true, + } + ], + }; + // 获取匿名用户的角色及权限 + const anonymousRoles = await Role.findAll({ + where: { + anonymous: true + }, + include: [ + permissionInclusion + ] + }); + // 获取登入用户的角色及权限 + const currentUser = this.getCurrentUser(ctx); + const userRoles = currentUser ? await currentUser.getRoles({ + include: [ + permissionInclusion + ] + }) : []; + + return [...anonymousRoles, ...userRoles]; + } +} export default async function (options = {}) { - const database: Database = this.database; - const resourcer: Resourcer = this.resourcer; + const instance = new Permissions(this, options); - database.import({ - directory: path.resolve(__dirname, 'collections'), - }); + return instance; } diff --git a/packages/plugin-users/package.json b/packages/plugin-users/package.json index a5eae06717..b77b337a45 100644 --- a/packages/plugin-users/package.json +++ b/packages/plugin-users/package.json @@ -3,22 +3,13 @@ "version": "0.3.0-alpha.0", "main": "lib/index.js", "license": "MIT", - "resolutions": { - "@types/react": "16.14.0" - }, - "peerDependencies": { - "umi": "^3.2.23" - }, "devDependencies": { "@nocobase/actions": "^0.3.0-alpha.0", "@nocobase/server": "^0.3.0-alpha.0", - "crypto-random-string": "^3.3.0", - "umi": "^3.2.23" + "crypto-random-string": "^3.3.0" }, "dependencies": { - "@nocobase/client": "^0.3.0-alpha.0", "@nocobase/database": "^0.3.0-alpha.0", - "@nocobase/resourcer": "^0.3.0-alpha.0", - "@types/react": "16.14.0" + "@nocobase/resourcer": "^0.3.0-alpha.0" } } diff --git a/packages/plugin-users/src/__tests__/index.ts b/packages/plugin-users/src/__tests__/index.ts index 032bc19afc..0fd51b1651 100644 --- a/packages/plugin-users/src/__tests__/index.ts +++ b/packages/plugin-users/src/__tests__/index.ts @@ -59,7 +59,7 @@ export async function getApp() { app.resourcer.use(middlewares.associated); app.resourcer.registerActionHandlers({...actions.associate, ...actions.common}); app.registerPlugin('collections', [path.resolve(__dirname, '../../../plugin-collections')]); - app.registerPlugin('file-manager', [plugin]); + app.registerPlugin('users', [plugin]); await app.loadPlugins(); await app.database.sync(); app.use(async (ctx, next) => { diff --git a/packages/plugin-users/src/collections/users.ts b/packages/plugin-users/src/collections/users.ts index cbf2b779a8..e0e8bddbe2 100644 --- a/packages/plugin-users/src/collections/users.ts +++ b/packages/plugin-users/src/collections/users.ts @@ -122,10 +122,11 @@ export default { developerMode: true, }, { - type: 'simple', + type: 'table', name: 'simple', title: '简易模式', - template: 'SimpleTable', + mode: 'simple', + template: 'Table', actionNames: ['create', 'destroy'], detailsViewName: 'details', updateViewName: 'form', diff --git a/packages/plugin-users/src/index.ts b/packages/plugin-users/src/index.ts index 65fb02d8e5..e69de29bb2 100644 --- a/packages/plugin-users/src/index.ts +++ b/packages/plugin-users/src/index.ts @@ -1,35 +0,0 @@ -// @ts-ignore -import { history, request } from 'umi'; - -export async function getInitialState() { - const { pathname, search } = location; - console.log(location); - let redirect = ''; - // if (href.includes('?')) { - redirect = `?redirect=${pathname}${search}`; - // } - - if (pathname !== '/login' && pathname !== '/register') { - try { - const { data = {} } = await request('/users:check', { - method: 'post', - }); - - if (!data.data) { - history.push('/login' + redirect); - return { - currentUser: {}, - }; - } - - return { - currentUser: data.data, - }; - } catch (error) { - console.log(error) - history.push('/login' + redirect); - } - } - - return {}; -} \ No newline at end of file diff --git a/packages/plugin-users/src/middlewares/check.ts b/packages/plugin-users/src/middlewares/check.ts new file mode 100644 index 0000000000..f60923a5c9 --- /dev/null +++ b/packages/plugin-users/src/middlewares/check.ts @@ -0,0 +1,11 @@ +// TODO(usage): 拦截用户的处理暂时作为一个中间件导出,应用需要的时候可以直接使用这个中间件 +export default function (options) { + return async (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 0000000000..d6faff0fe1 --- /dev/null +++ b/packages/plugin-users/src/middlewares/index.ts @@ -0,0 +1,2 @@ +export { default as parseToken } from './parseToken'; +export { default as check } from './check'; diff --git a/packages/plugin-users/src/middlewares/parseToken.ts b/packages/plugin-users/src/middlewares/parseToken.ts new file mode 100644 index 0000000000..0352a1d1cb --- /dev/null +++ b/packages/plugin-users/src/middlewares/parseToken.ts @@ -0,0 +1,19 @@ +// TODO(feature): 表名应在 options 中配置 +// 中间件默认只解决解析 token 和附加对应 user 的工作,不解决是否提前报 401 退出。 +// 因为是否提供匿名访问资源是应用决定的,不是使用插件就一定不能匿名访问。 +export default function (options) { + return async (ctx, next) => { + const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); + const User = ctx.db.getModel('users'); + const user = await User.findOne({ + where: { + token, + }, + }); + if (user) { + ctx.state.currentUser = user; + } + + return next(); + }; +} diff --git a/packages/plugin-users/src/resources/users.ts b/packages/plugin-users/src/resources/users.ts deleted file mode 100644 index 70160325c7..0000000000 --- a/packages/plugin-users/src/resources/users.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ResourceOptions } from "@nocobase/resourcer"; - -export default { - name: 'users', - middlewares: [ - { - // only: ['check'], - handler: async (ctx, next) => { - const token = ctx.get('Authorization').replace(/^Bearer\s+/gi, ''); - console.log(ctx.action.params.actionName); - const { actionName } = ctx.action.params; - if (actionName !== 'check') { - return next(); - } - if (!token) { - ctx.throw(401, 'Unauthorized'); - } - const User = ctx.db.getModel('users'); - const user = await User.findOne({ - where: { - token, - }, - }); - if (!user) { - ctx.throw(401, 'Unauthorized'); - } - ctx.state.currentUser = user; - await next(); - }, - }, - ], -} as ResourceOptions; diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts index 97cc3f9634..a19e539c64 100644 --- a/packages/plugin-users/src/server.ts +++ b/packages/plugin-users/src/server.ts @@ -10,6 +10,7 @@ import register from './actions/register'; import logout from './actions/logout'; import check from './actions/check'; import { makeOptions } from './hooks/collection-after-create'; +import * as middlewares from './middlewares'; export default async function (options = {}) { const database: Database = this.database; @@ -38,8 +39,6 @@ export default async function (options = {}) { .map(type => table.addField(makeOptions(type, fieldsToMake[type]))); }); - // hooks.call(this); - resourcer.registerActionHandlers({ 'users:login': login, 'users:register': register, @@ -47,7 +46,5 @@ export default async function (options = {}) { 'users:check': check, }); - // resourcer.import({ - // directory: path.resolve(__dirname, 'resources'), - // }); + resourcer.use(middlewares.parseToken(options)); } diff --git a/packages/resourcer/src/__tests__/koa.test.ts b/packages/resourcer/src/__tests__/koa.test.ts index 382b0ea8fd..38fc152dd8 100644 --- a/packages/resourcer/src/__tests__/koa.test.ts +++ b/packages/resourcer/src/__tests__/koa.test.ts @@ -1,16 +1,13 @@ import Koa from 'koa'; -import Router from '@koa/router'; -import request from 'supertest'; -import http from 'http'; +import supertest from 'supertest'; import Resourcer from '../resourcer'; -import qs from 'qs'; import bodyParser from 'koa-bodyparser'; describe('koa middleware', () => { it('shound work', async () => { const app = new Koa(); - const resourcer = new Resourcer(); + const agent = supertest.agent(app.callback()); resourcer.define({ name: 'test', @@ -39,14 +36,14 @@ describe('koa middleware', () => { } }); - const response = await request(http.createServer(app.callback())).get('/api/test'); + const response = await agent.get('/api/test'); expect(response.body.arr).toEqual([5,3,4,6]); }); it('shound work', async () => { const app = new Koa(); - const resourcer = new Resourcer(); + const agent = supertest.agent(app.callback()); resourcer.registerActionHandlers({ async index(ctx, next) { @@ -79,22 +76,23 @@ describe('koa middleware', () => { } }); - const response = await request(http.createServer(app.callback())).get('/api/test'); + const response = await agent.get('/api/test'); expect(response.body.arr).toEqual([5,3,4,6]); }); it('shound be 404', async () => { const app = new Koa(); const resourcer = new Resourcer(); + const agent = supertest.agent(app.callback()); app.use(resourcer.middleware()); - const response = await request(http.createServer(app.callback())).get('/test'); + const response = await agent.get('/test'); expect(response.status).toBe(404); }); it('shound work', async () => { const app = new Koa(); - const resourcer = new Resourcer(); + const agent = supertest.agent(app.callback()); resourcer.define({ name: 'test', @@ -126,13 +124,14 @@ describe('koa middleware', () => { } }); - const response = await request(http.createServer(app.callback())).get('/api/test'); + const response = await agent.get('/api/test'); expect(response.body.arr).toEqual([5,3,4,6]); }); it('shound work', async () => { const app = new Koa(); const resourcer = new Resourcer(); + const agent = supertest.agent(app.callback()); resourcer.define({ name: 'tables.fields', @@ -149,13 +148,14 @@ describe('koa middleware', () => { app.use(resourcer.middleware()); - const response = await request(http.createServer(app.callback())).get('/tables/demos/fields'); + const response = await agent.get('/tables/demos/fields'); expect(response.body.arr).toEqual([3,4]); }); it('shound work', async () => { const app = new Koa(); const resourcer = new Resourcer(); + const agent = supertest.agent(app.callback()); resourcer.define({ name: 'tables#fields', @@ -174,13 +174,14 @@ describe('koa middleware', () => { nameRule: ({resourceName, associatedName}) => associatedName ? `${associatedName}#${resourceName}` : resourceName, })); - const response = await request(http.createServer(app.callback())).get('/tables/demos/fields'); + const response = await agent.get('/tables/demos/fields'); expect(response.body.arr).toEqual([3,4]); }); describe('action options', () => { - let app: Koa; let resourcer: Resourcer; + let app: Koa; + let agent; beforeAll(() => { app = new Koa(); resourcer = new Resourcer(); @@ -200,6 +201,7 @@ describe('koa middleware', () => { }); app.use(bodyParser()); app.use(resourcer.middleware()); + agent = supertest.agent(app.callback()); }); it('options1', async () => { resourcer.define({ @@ -215,7 +217,7 @@ describe('koa middleware', () => { }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/tests') .query({ filter: { @@ -242,13 +244,13 @@ describe('koa middleware', () => { name: 'tests', actions: { create: { - defaultValues: { + values: { col1: 'val1', }, }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/tests') .send({'aa': 'aa'}); expect(response.body).toEqual({ @@ -262,13 +264,13 @@ describe('koa middleware', () => { name: 'tests', actions: { create: { - defaultValues: { + values: { col1: 'val1', }, }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/resourcer/tests:create') .send({ values: {'aa': 'aa'} @@ -284,13 +286,13 @@ describe('koa middleware', () => { name: 'tests', actions: { update: { - defaultValues: { + values: { col1: 'val1', }, }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/resourcer/tests:update') .send({ resourceKey: 1, @@ -311,7 +313,7 @@ describe('koa middleware', () => { }); }); it('get', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/users/1/settings'); expect(response.body).toEqual({ associatedName: 'users', @@ -321,7 +323,7 @@ describe('koa middleware', () => { }); }); it('update', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/users/1/settings'); expect(response.body).toEqual({ associatedName: 'users', @@ -331,7 +333,7 @@ describe('koa middleware', () => { }); }); it('destroy', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .delete('/users/1/settings'); expect(response.body).toEqual({ associatedName: 'users', @@ -349,7 +351,7 @@ describe('koa middleware', () => { }); }); it('list', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/users/1/posts'); expect(response.body).toEqual({ associatedName: 'users', @@ -359,7 +361,7 @@ describe('koa middleware', () => { }); }); it('get', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/users/1/posts/1'); expect(response.body).toEqual({ associatedName: 'users', @@ -370,7 +372,7 @@ describe('koa middleware', () => { }); }); it('create', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/users/1/posts'); expect(response.body).toEqual({ associatedName: 'users', @@ -380,7 +382,7 @@ describe('koa middleware', () => { }); }); it('update', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .put('/users/1/posts/1'); expect(response.body).toEqual({ associatedName: 'users', @@ -391,7 +393,7 @@ describe('koa middleware', () => { }); }); it('destroy', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .delete('/users/1/posts/1'); expect(response.body).toEqual({ associatedName: 'users', @@ -410,7 +412,7 @@ describe('koa middleware', () => { }); }); it('get', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/posts/1/user'); expect(response.body).toEqual({ associatedName: 'posts', @@ -420,7 +422,7 @@ describe('koa middleware', () => { }); }); it('set', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/posts/1/user/1'); expect(response.body).toEqual({ associatedName: 'posts', @@ -431,7 +433,7 @@ describe('koa middleware', () => { }); }); it('remove', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .delete('/posts/1/user'); expect(response.body).toEqual({ associatedName: 'posts', @@ -449,7 +451,7 @@ describe('koa middleware', () => { }); }); it('list', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/posts/1/tags'); expect(response.body).toEqual({ associatedName: 'posts', @@ -459,7 +461,7 @@ describe('koa middleware', () => { }); }); it('get', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/posts/1/tags/1'); expect(response.body).toEqual({ associatedName: 'posts', @@ -470,7 +472,7 @@ describe('koa middleware', () => { }); }); it('set', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/posts/1/tags'); expect(response.body).toEqual({ associatedName: 'posts', @@ -480,7 +482,7 @@ describe('koa middleware', () => { }); }); it('add', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .post('/posts/1/tags/1'); expect(response.body).toEqual({ associatedName: 'posts', @@ -491,7 +493,7 @@ describe('koa middleware', () => { }); }); it('update', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .put('/posts/1/tags/1'); expect(response.body).toEqual({ associatedName: 'posts', @@ -502,7 +504,7 @@ describe('koa middleware', () => { }); }); it('remove', async () => { - const response = await request(http.createServer(app.callback())) + const response = await agent .delete('/posts/1/tags/1'); expect(response.body).toEqual({ associatedName: 'posts', @@ -520,7 +522,7 @@ describe('koa middleware', () => { list: {}, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/test1') .query({ fields: ['id', 'col1'], @@ -540,7 +542,7 @@ describe('koa middleware', () => { }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/test1') .query({ fields: ['id', 'col1'], @@ -562,7 +564,7 @@ describe('koa middleware', () => { }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/test1') .query({ fields: ['id', 'col1', 'password'], @@ -585,7 +587,7 @@ describe('koa middleware', () => { }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/test1') .query({ fields: ['id', 'col1', 'password', 'col2'], @@ -608,7 +610,7 @@ describe('koa middleware', () => { }, }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/test1').query({ fields: { appends: ['relation1'], @@ -623,9 +625,11 @@ describe('koa middleware', () => { it('fields6', async () => { resourcer.define({ name: 'test1', - list: {}, + actions: { + list: {} + }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/test1').query({ 'fields[appends]': 'rel1,rel2', }); @@ -638,9 +642,11 @@ describe('koa middleware', () => { it('fields7', async () => { resourcer.define({ name: 'users.posts', - list: {}, + actions: { + list: {} + }, }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/users/name/posts').query({ 'fields[appends]': 'rel1,rel2', }); @@ -658,13 +664,13 @@ describe('koa middleware', () => { actions: { list: { async middleware(ctx, next) { - ctx.action.setParam('filter.user_name', ctx.action.params.associatedKey); + ctx.action.mergeParams({ filter: { user_name: ctx.action.params.associatedKey } }); await next(); }, }, } }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/users/name/posts'); expect(response.body).toEqual({ associatedName: 'users', @@ -680,13 +686,13 @@ describe('koa middleware', () => { actions: { list: { async middleware(ctx, next) { - ctx.action.setParam('fields.only[]', ctx.action.params.associatedKey); + ctx.action.mergeParams({ fields: { only: [ctx.action.params.associatedKey] } }, { fields: 'append' }); await next(); }, }, } }); - const response = await request(http.createServer(app.callback())) + const response = await agent .get('/users/name/posts'); expect(response.body).toEqual({ associatedName: 'users', diff --git a/packages/resourcer/src/action.ts b/packages/resourcer/src/action.ts index d908223eb5..999da3b34a 100644 --- a/packages/resourcer/src/action.ts +++ b/packages/resourcer/src/action.ts @@ -1,9 +1,10 @@ -import _, { isArray } from 'lodash'; +import _ from 'lodash'; import compose from 'koa-compose'; import Resource from './resource'; -import { requireModule, mergeFields } from './utils'; +import { requireModule } from './utils'; import { HandlerType } from './resourcer'; import Middleware, { MiddlewareType } from './middleware'; +import { ActionParameterTypes, ActionParameter, UnknownParameter, PageParameter } from './parameter'; export type ActionType = string | HandlerType | ActionOptions; @@ -43,7 +44,7 @@ export interface ActionOptions { /** * 默认数据 */ - defaultValues?: any; + values?: any; /** * 字段 * @@ -171,9 +172,9 @@ export interface ActionParams { [key: string]: any; } -export const DEFAULT_PAGE = 1; -export const DEFAULT_PER_PAGE = 20; -export const MAX_PER_PAGE = 100; +export const DEFAULT_PAGE = PageParameter.DEFAULT_PAGE; +export const DEFAULT_PER_PAGE = PageParameter.DEFAULT_PER_PAGE; +export const MAX_PER_PAGE = PageParameter.MAX_PER_PAGE; export class Action { @@ -185,7 +186,7 @@ export class Action { protected options: ActionOptions; - protected parameters: ActionParams = {}; + protected parameters: Map = new Map(); protected context: ActionContext = {}; @@ -196,14 +197,24 @@ export class Action { if (typeof options === 'function') { options = { handler: options }; } - const { middleware, middlewares = [], handler } = options; + const { + middleware, + middlewares = [], + handler, + ...params + } = options; this.middlewares = Middleware.toInstanceArray(middleware || middlewares); this.handler = handler; this.options = options; + this.mergeParams(params, {}); } get params(): ActionParams { - return this.parameters; + const result = this.parameters.get('_').get(); + for (const paramType of this.parameters.values()) { + Object.assign(result, paramType.get()); + } + return result; } clone() { @@ -221,81 +232,36 @@ export class Action { this.context = context; } - setParam(key: string, value: any) { - if (/\[\]$/.test(key)) { - key = key.substr(0, key.length - 2); - let values = _.get(this.parameters, key); - if (_.isArray(values)) { - values.push(value); - } else { - values = []; - values.push(value); + async mergeParams(params, strategies = {}) { + const typeKeys = new Set(); + Object.keys(params).forEach(key => { + for (const typeKey of ActionParameterTypes.getAllKeys()) { + if (typeof params[key] !== 'undefined' && key in ActionParameterTypes.get(typeKey).picking) { + typeKeys.add(typeKey); + } } - value = values; + }); + let type; + for (const key of typeKeys) { + const strategy = strategies[key]; + type = this.parameters.get(key); + if (!type) { + const Type = ActionParameterTypes.get(key); + if (!Type) { + throw new Error(`parameter type ${key} is unregistered`); + } + // @ts-ignore + type = new Type(params); + this.parameters.set(key, type); + } + type.merge(params, strategy); } - _.set(this.parameters, key, value); - } - - async mergeParams(params: ActionParams) { - const { - filter, - fields, - values, - page: paramPage, - perPage: paramPerPage, - per_page, - ...restPrams - } = params; - const { - filter: optionsFilter, - fields: optionsFields, - page = DEFAULT_PAGE, - perPage = DEFAULT_PER_PAGE, - maxPerPage = MAX_PER_PAGE - } = this.options; - const options = _.omit(this.options, [ - 'defaultValues', - 'filter', - 'fields', - 'maxPerPage', - 'page', - 'perPage', - 'handler', - 'middlewares', - 'middleware', - ]); - const data: ActionParams = { - ...options, - ...restPrams, - }; - if (!_.isEmpty(this.options.defaultValues) || !_.isEmpty(values)) { - data.values = _.merge(_.cloneDeep(this.options.defaultValues), values); + type = this.parameters.get('_'); + if (!type) { + type = new UnknownParameter(params); + this.parameters.set('_', type); } - // TODO: to be unified by style funciton - if (per_page || paramPerPage) { - data.perPage = per_page || paramPerPage; - } - if (paramPage || data.perPage) { - data.page = paramPage || page; - data.perPage = data.perPage == -1 ? maxPerPage : Math.min(data.perPage || perPage, maxPerPage); - } - // if (typeof optionsFilter === 'function') { - // this.parameters = _.cloneDeep(data); - // optionsFilter = await optionsFilter(this.context); - // } - if (!_.isEmpty(optionsFilter) || !_.isEmpty(filter)) { - const filterOptions = [_.cloneDeep(optionsFilter), filter].filter(Boolean); - // TODO(feature): change 'and' to symbol - data.filter = filterOptions.length > 1 ? { and: filterOptions } : filterOptions[0]; - } - // if (typeof optionsFields === 'function') { - // this.parameters = _.cloneDeep(data); - // optionsFields = await optionsFields(this.context); - // } - if (!_.isEmpty(optionsFields) || !_.isEmpty(fields)) { - data.fields = mergeFields(optionsFields, fields); - } - this.parameters = _.cloneDeep(data); + type.merge(params, strategies['_']); } setResource(resource: Resource) { diff --git a/packages/resourcer/src/parameter.ts b/packages/resourcer/src/parameter.ts new file mode 100644 index 0000000000..9b0e86c366 --- /dev/null +++ b/packages/resourcer/src/parameter.ts @@ -0,0 +1,297 @@ +import _ from 'lodash'; + + + +const types = new Map(); + +export class ActionParameterTypes { + static register(type: Exclude, Type: typeof ActionParameter) { + types.set(type, Type); + } + + static get(type: string): typeof ActionParameter { + return types.get(type); + } + + static getAllKeys(): Iterable { + return types.keys(); + } + + static getAllTypes(): Iterable { + return types.values(); + } +} + + + +// TODO(optimize) 当前要兼容 resourcer 和 action 已有的参数形式 +// 更好的情况可以定义参数从 ctx 对象中的来源路径,适应性可能更好 +export abstract class ActionParameter { + // TODO(optimize): 设计稍微有点奇怪 + // 选取未来传入参数集中的哪些键,同时值为默认值 + static picking: { [key: string]: any } + + // 默认逻辑是直接选取传入对象中的对应键 + static pick(params: { [key: string]: any }) { + const result: { [key: string]: any } = {}; + Object.keys(this.picking).forEach(key => { + if (typeof this.picking[key] !== 'undefined') { + result[key] = this.picking[key]; + } + if (typeof params[key] !== 'undefined') { + result[key] = params[key]; + } + }); + + return result; + } + + // 暂时以对象加一层包装,以便类似 pager 这样有多个参数的情况 + params: { [key: string]: any } = {}; + + constructor(params) { + this.merge(params); + } + + get() { + return _.cloneDeep(this.params); + } + + merge(params, strategy?: string): void { + this.params = ActionParameter.pick(params); + } +} + + + +export class FieldsParameter extends ActionParameter { + static picking = { fields: {} }; + + static parse(fields: any) { + if (!fields) { + return {} + } + if (typeof fields === 'string') { + fields = fields.split(',').map(field => field.trim()); + } + if (Array.isArray(fields)) { + const onlyFields = []; + const output: any = {}; + fields.forEach(item => { + if (typeof item === 'string') { + onlyFields.push(item); + } else if (typeof item === 'object') { + if (item.only) { + onlyFields.push(...item.only.toString().split(',')); + } + Object.assign(output, this.parse(item)); + } + }); + if (onlyFields.length) { + output.only = onlyFields; + } + return output; + } + if (fields.only && typeof fields.only === 'string') { + fields.only = fields.only.split(',').map(field => field.trim()); + } + if (fields.except && typeof fields.except === 'string') { + fields.except = fields.except.split(',').map(field => field.trim()); + } + if (fields.appends && typeof fields.appends === 'string') { + fields.appends = fields.appends.split(',').map(field => field.trim()); + } + return fields; + } + + static intersect(defaults: any, inputs: any) { + let fields: any = {}; + defaults = this.parse(defaults); + inputs = this.parse(inputs); + if (inputs.only) { + // 前端提供 only,后端提供 only + if (defaults.only) { + fields.only = defaults.only.filter(field => inputs.only.includes(field)); + } + // 前端提供 only,后端提供 except,输出 only 排除 except + else if (defaults.except) { + fields.only = inputs.only.filter(field => !defaults.except.includes(field)); + } + // 前端提供 only,后端没有提供 only 或 except + else { + fields.only = inputs.only; + } + } else if (inputs.except) { + // 前端提供 except,后端提供 only,只输出 only 里排除 except 的字段 + if (defaults.only) { + fields.only = defaults.only.filter(field => !inputs.except.includes(field)); + } + // 前端提供 except,后端提供 except 或不提供,合并 except + else { + fields.except = _.uniq([...inputs.except, ...(defaults.except||[])]); + } + } + // 前端没提供 only 或 except + else { + fields = defaults; + } + // 如果前端提供了 appends + if (!_.isEmpty(inputs.appends)) { + fields.appends = _.uniq([...inputs.appends, ...(defaults.appends||[])]); + } + if (!fields.appends) { + fields.appends = []; + } + return fields; + } + + static append(defaults, inputs) { + let fields: any = {}; + defaults = this.parse(defaults); + inputs = this.parse(inputs); + + ['only', 'except', 'appends'].forEach(key => { + if (defaults[key] && defaults[key].length || inputs[key] && inputs[key].length) { + fields[key] = _.uniq([...(defaults[key] || []), ...(inputs[key] || [])]); + } + }); + return fields; + } + + params = { fields: {} }; + + merge(params, strategy: 'intersect' | 'append' | 'replace' = 'intersect') { + switch (strategy) { + case 'intersect': + this.params = { + fields: FieldsParameter.intersect(this.params.fields, FieldsParameter.pick(params).fields) + }; + break; + case 'append': + this.params = { + fields: FieldsParameter.append(this.params.fields, FieldsParameter.pick(params).fields) + }; + break; + default: + throw new Error('not implemented yet'); + } + } +} + + + +export class FilterParameter extends ActionParameter { + static picking = { filter: {} }; + + params = { filter: {} }; + + merge(params, strategy: 'and' | 'or' = 'and') { + const { filter } = FilterParameter.pick(params); + if (!filter || _.isEmpty(filter)) { + return; + } + if (_.isEmpty(this.params.filter)) { + this.params.filter = filter; + } else { + this.params.filter = { + [strategy]: [this.params.filter, filter] + }; + } + } +} + + + +export class PageParameter extends ActionParameter { + static picking = { page: undefined, perPage: undefined, per_page: undefined }; + + static DEFAULT_PAGE = 1; + static DEFAULT_PER_PAGE = 20; + static MAX_PER_PAGE = 100; + + params = { page: undefined, perPage: undefined }; + + maxPerPage = PageParameter.MAX_PER_PAGE; + + constructor(options = { maxPerPage: PageParameter.MAX_PER_PAGE }) { + super(options); + + if (typeof options.maxPerPage !== 'undefined') { + this.maxPerPage = options.maxPerPage; + } + } + + merge(params) { + const data = PageParameter.pick(params); + if (typeof params.per_page !== 'undefined') { + data.perPage = params.per_page; + } + + if (data.page || data.perPage) { + data.page = data.page || this.params.page || PageParameter.DEFAULT_PAGE; + data.perPage = data.perPage == -1 ? this.maxPerPage : Math.min(data.perPage || this.params.perPage || PageParameter.DEFAULT_PER_PAGE, this.maxPerPage); + + Object.assign(this.params, data); + } + } +} + + + +export class PayloadParameter extends ActionParameter { + static picking = { values: undefined }; + + merge(params, strategy: 'replace' | 'merge' | 'intersect' = 'merge') { + const data = PayloadParameter.pick(params); + switch (strategy) { + case 'replace': + this.params.values = data.values; + break; + case 'merge': + _.merge(this.params, _.cloneDeep(data)); + break; + default: + throw new Error('not implemented yet'); + } + } +} + + + +export class UnknownParameter { + params: { [key: string]: any } = {}; + + constructor(options) { + this.merge(options); + } + + get() { + return _.cloneDeep(this.params); + } + + merge(params, strategy: 'replace' | 'merge' | 'intersect' = 'merge') { + const knownKeys = new Set(); + for (const key of ActionParameterTypes.getAllKeys()) { + Object.keys(ActionParameterTypes.get(key).picking).forEach(key => knownKeys.add(key)); + } + const data = _.omit(params, Array.from(knownKeys)); + + switch (strategy) { + case 'merge': + _.merge(this.params, _.cloneDeep(data)); + break; + case 'replace': + this.params = data; + break; + default: + throw new Error('not implemented yet'); + } + } +} + + + +ActionParameterTypes.register('fields', FieldsParameter); +ActionParameterTypes.register('filter', FilterParameter); +ActionParameterTypes.register('page', PageParameter); +ActionParameterTypes.register('payload', PayloadParameter); diff --git a/packages/resourcer/src/resourcer.ts b/packages/resourcer/src/resourcer.ts index c5f8ff2160..d875a94e23 100644 --- a/packages/resourcer/src/resourcer.ts +++ b/packages/resourcer/src/resourcer.ts @@ -5,6 +5,7 @@ import Action, { ActionName } from './action'; import Resource, { ResourceOptions } from './resource'; import { parseRequest, getNameByParams, ParsedParams, requireModule, parseQuery } from './utils'; import { pathToRegexp } from 'path-to-regexp'; +import _ from 'lodash'; export interface ResourcerContext { resourcer?: Resourcer; @@ -279,7 +280,7 @@ export class Resourcer { await ctx.action.mergeParams({ ...query, ...params, - values: ctx.request.body, + ...(_.isEmpty(ctx.request.body) ? {} : { values: ctx.request.body }), }); } return compose(ctx.action.getHandlers())(ctx, next); diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index 39e85ba525..183ef5ff3a 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -1,50 +1,10 @@ import Koa from 'koa'; -import Database from '@nocobase/database'; +import Database, { DatabaseOptions } from '@nocobase/database'; import Resourcer from '@nocobase/resourcer'; export interface ApplicationOptions { - database: any; - resourcer: any; -} - -export class PluginManager { - - protected application: Application; - - protected plugins = new Map(); - - constructor(application: Application) { - this.application = application; - } - - register(key: string | object, plugin?: any) { - if (typeof key === 'object') { - Object.keys(key).forEach((k) => { - this.register(k, key[k]); - }); - } else { - this.plugins.set(key, plugin); - } - } - - async load() { - for (const pluginOptions of this.plugins.values()) { - if (Array.isArray(pluginOptions)) { - const [entry, options = {}] = pluginOptions; - await this.call(entry, options); - } else { - await this.call(pluginOptions); - } - } - } - - async call(entry: string | Function, options: any = {}) { - const main = typeof entry === 'function' - ? entry - : require(`${entry}/${__filename.endsWith('.ts') ? 'src' : 'lib'}/server`).default; - - await main.call(this.application, options); - } + database: DatabaseOptions; + resourcer?: any; } export class Application extends Koa { @@ -53,26 +13,49 @@ export class Application extends Koa { public readonly resourcer: Resourcer; - public readonly pluginManager: PluginManager; + protected plugins = new Map(); constructor(options: ApplicationOptions) { super(); this.database = new Database(options.database); this.resourcer = new Resourcer(); - this.pluginManager = new PluginManager(this); // this.runHook('afterInit'); } - registerPlugin(key: string, plugin: any) { - this.pluginManager.register(key, plugin); + registerPlugin(key: string | object, plugin?: any) { + if (typeof key === 'object') { + Object.keys(key).forEach((k) => { + this.registerPlugin(k, key[k]); + }); + } else { + const config = {}; + if (Array.isArray(plugin)) { + const [entry, options = {}] = plugin; + Object.assign(config, { entry, options }); + } else { + Object.assign(config, { entry: plugin, options: {} }); + } + this.plugins.set(key, config); + } } - registerPlugins(plugins: object) { - this.pluginManager.register(plugins); + getPluginInstance(key: string) { + const plugin = this.plugins.get(key); + return plugin && plugin.instance; } async loadPlugins() { - return this.pluginManager.load(); + for (const plugin of this.plugins.values()) { + plugin.instance = await this.loadPlugin(plugin); + } + } + + protected async loadPlugin({ entry, options = {} }: { entry: string | Function, options: any }) { + const main = typeof entry === 'function' + ? entry + : require(`${entry}/${__filename.endsWith('.ts') ? 'src' : 'lib'}/server`).default; + + return await main.call(this, options); } }