From cfa27b1833ceb032fd02b4da83c48494f16f8808 Mon Sep 17 00:00:00 2001 From: ddcat1115 Date: Fri, 5 Jan 2018 00:52:46 +0800 Subject: [PATCH] Authority management (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * temp save * rebase master * fix same error * Add a new user and different permissions * fix eol-last * add Secured decorator * fix list redirect bug (#507) * Mobile menu (#463) * Increase the sliding menu * Add a simple animation * update mobile menu * update * update * update * rebase master * recovery import/first * fix error * Fix some bugs Change "ALL" to "NONE" Remove the "!" Support After landing successfully reload Reset the format * Pump your public logic * add some test * Add documents * use default currentRole in Authorized/AuthorizedRoute * rename props & change some authority setting * A big change 😄 unified router and Secured parameters 😭 loginOut logout also changed to reload * fix siderMeun bugs * Decoupled SiderMenu * Remove the handsome head of information * Add a simple error * rebase master --- .gitignore | 1 + .roadhogrc.mock.js | 21 ++- package.json | 7 +- src/common/menu.js | 9 +- src/common/router.js | 21 +-- src/components/Authorized/Authorized.js | 16 +++ src/components/Authorized/AuthorizedRoute.js | 23 +++ src/components/Authorized/CheckPermissions.js | 62 ++++++++ .../Authorized/CheckPermissions.test.js | 42 ++++++ src/components/Authorized/PromiseRender.js | 39 ++++++ src/components/Authorized/Secured.js | 49 +++++++ src/components/Authorized/index.js | 32 +++++ src/components/SiderMenu/SiderMenu.js | 132 +++++++++++------- src/layouts/BasicLayout.js | 33 +++-- src/models/login.js | 15 +- src/router.js | 19 ++- src/routes/Dashboard/Monitor.js | 32 +++-- src/routes/User/Login.js | 4 +- src/utils/Authorized.js | 5 + src/utils/authority.js | 8 ++ src/utils/utils.js | 2 +- 21 files changed, 473 insertions(+), 99 deletions(-) create mode 100644 src/components/Authorized/Authorized.js create mode 100644 src/components/Authorized/AuthorizedRoute.js create mode 100644 src/components/Authorized/CheckPermissions.js create mode 100644 src/components/Authorized/CheckPermissions.test.js create mode 100644 src/components/Authorized/PromiseRender.js create mode 100644 src/components/Authorized/Secured.js create mode 100644 src/components/Authorized/index.js create mode 100644 src/utils/Authorized.js create mode 100644 src/utils/authority.js diff --git a/.gitignore b/.gitignore index aecc7945..be1d6d2a 100755 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ npm-debug.log* /coverage .idea +yarn.lock diff --git a/.roadhogrc.mock.js b/.roadhogrc.mock.js index cf82620d..d0d0ccf6 100644 --- a/.roadhogrc.mock.js +++ b/.roadhogrc.mock.js @@ -70,13 +70,30 @@ const proxy = { 'GET /api/profile/advanced': getProfileAdvancedData, 'POST /api/login/account': (req, res) => { const { password, userName, type } = req.body; + if(password === '888888' && userName === 'admin'){ + res.send({ + status: 'ok', + type, + currentAuthority: 'admin' + }); + return ; + } + if(password === '123456' && userName === 'user'){ + res.send({ + status: 'ok', + type, + currentAuthority: 'user' + }); + return ; + } res.send({ - status: password === '888888' && userName === 'admin' ? 'ok' : 'error', + status: 'error', type, + currentAuthority: 'guest' }); }, 'POST /api/register': (req, res) => { - res.send({ status: 'ok' }); + res.send({ status: 'ok', currentAuthority: 'user' }); }, 'GET /api/notices': getNotices, 'GET /api/500': (req, res) => { diff --git a/package.json b/package.json index 5492cd6e..3267b3dc 100755 --- a/package.json +++ b/package.json @@ -16,11 +16,12 @@ "lint-staged": "lint-staged", "lint-staged:js": "eslint --ext .js", "test": "jest", + "test:comps": "jest ./src/components", "test:all": "node ./tests/run-tests.js" }, "dependencies": { "@antv/data-set": "^0.8.0", - "antd": "^3.0.0", + "antd": "^3.1.0", "babel-polyfill": "^6.26.0", "babel-runtime": "^6.9.2", "bizcharts": "^3.1.0-beta.4", @@ -37,10 +38,10 @@ "prop-types": "^15.5.10", "qs": "^6.5.0", "rc-drawer-menu": "^0.5.0", - "react": "^16.0.0", + "react": "^16.2.0", "react-container-query": "^0.9.1", "react-document-title": "^2.0.3", - "react-dom": "^16.0.0", + "react-dom": "^16.2.0", "react-fittext": "^1.0.0" }, "devDependencies": { diff --git a/src/common/menu.js b/src/common/menu.js index e910d764..64b8c594 100644 --- a/src/common/menu.js +++ b/src/common/menu.js @@ -25,6 +25,7 @@ const menuData = [{ path: 'step-form', }, { name: '高级表单', + authority: 'admin', path: 'advanced-form', }], }, { @@ -64,6 +65,7 @@ const menuData = [{ }, { name: '高级详情页', path: 'advanced', + authority: 'admin', }], }, { name: '结果页', @@ -83,6 +85,7 @@ const menuData = [{ children: [{ name: '403', path: '403', + authority: 'user', }, { name: '404', path: '404', @@ -97,6 +100,7 @@ const menuData = [{ name: '账户', icon: 'user', path: 'user', + authority: 'guest', children: [{ name: '登录', path: 'login', @@ -114,14 +118,15 @@ const menuData = [{ target: '_blank', }]; -function formatter(data, parentPath = '') { +function formatter(data, parentPath = '', parentAuthority) { return data.map((item) => { const result = { ...item, path: `${parentPath}${item.path}`, + authority: item.authority || parentAuthority, }; if (item.children) { - result.children = formatter(item.children, `${parentPath}${item.path}/`); + result.children = formatter(item.children, `${parentPath}${item.path}/`, item.authority); } return result; }); diff --git a/src/common/router.js b/src/common/router.js index 1608323d..69d33927 100644 --- a/src/common/router.js +++ b/src/common/router.js @@ -21,17 +21,17 @@ function getFlatMenuData(menus) { let keys = {}; menus.forEach((item) => { if (item.children) { - keys[item.path] = item.name; + keys[item.path] = { ...item }; keys = { ...keys, ...getFlatMenuData(item.children) }; } else { - keys[item.path] = item.name; + keys[item.path] = { ...item }; } }); return keys; } export const getRouterData = (app) => { - const routerData = { + const routerConfig = { '/': { component: dynamicWrapper(app, ['user', 'login'], () => import('../layouts/BasicLayout')), }, @@ -45,6 +45,7 @@ export const getRouterData = (app) => { component: dynamicWrapper(app, ['project', 'activities', 'chart'], () => import('../routes/Dashboard/Workplace')), // hideInBreadcrumb: true, // name: '工作台', + // authority: 'admin', }, '/form/basic-form': { component: dynamicWrapper(app, ['form'], () => import('../routes/Forms/BasicForm')), @@ -127,12 +128,14 @@ export const getRouterData = (app) => { }; // Get name from ./menu.js or just set it in the router data. const menuData = getFlatMenuData(getMenuData()); - const routerDataWithName = {}; - Object.keys(routerData).forEach((item) => { - routerDataWithName[item] = { - ...routerData[item], - name: routerData[item].name || menuData[item.replace(/^\//, '')], + const routerData = {}; + Object.keys(routerConfig).forEach((item) => { + const menuItem = menuData[item.replace(/^\//, '')] || {}; + routerData[item] = { + ...routerConfig[item], + name: routerConfig[item].name || menuItem.name, + authority: routerConfig[item].authority || menuItem.authority, }; }); - return routerDataWithName; + return routerData; }; diff --git a/src/components/Authorized/Authorized.js b/src/components/Authorized/Authorized.js new file mode 100644 index 00000000..d9a2a781 --- /dev/null +++ b/src/components/Authorized/Authorized.js @@ -0,0 +1,16 @@ +import React from 'react'; +import CheckPermissions from './CheckPermissions'; + +class Authorized extends React.Component { + render() { + const { children, authority, noMatch = null } = this.props; + const childrenRender = typeof children === 'undefined' ? null : children; + return CheckPermissions( + authority, + childrenRender, + noMatch + ); + } +} + +export default Authorized; diff --git a/src/components/Authorized/AuthorizedRoute.js b/src/components/Authorized/AuthorizedRoute.js new file mode 100644 index 00000000..09c98d60 --- /dev/null +++ b/src/components/Authorized/AuthorizedRoute.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { Route, Redirect } from 'dva/router'; +import Authorized from './Authorized'; + +class AuthorizedRoute extends React.Component { + render() { + const { component: Component, render, authority, + redirectPath, ...rest } = this.props; + return ( + } />} + > + (Component ? : render(props))} + /> + + ); + } +} + +export default AuthorizedRoute; diff --git a/src/components/Authorized/CheckPermissions.js b/src/components/Authorized/CheckPermissions.js new file mode 100644 index 00000000..e9664550 --- /dev/null +++ b/src/components/Authorized/CheckPermissions.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PromiseRender from './PromiseRender'; +import { CURRENT } from './index'; +/** + * 通用权限检查方法 + * Common check permissions method + * @param { 权限判定 Permission judgment type string |array | Promise | Function } authority + * @param { 你的权限 Your permission description type:string} currentAuthority + * @param { 通过的组件 Passing components } target + * @param { 未通过的组件 no pass components } Exception + */ +const checkPermissions = (authority, currentAuthority, target, Exception) => { + // 没有判定权限.默认查看所有 + // Retirement authority, return target; + if (!authority) { + return target; + } + // 数组处理 + if (authority.constructor.name === 'Array') { + if (authority.includes(currentAuthority)) { + return target; + } + return Exception; + } + + // string 处理 + if (authority.constructor.name === 'String') { + if (authority === currentAuthority) { + return target; + } + return Exception; + } + + // Promise 处理 + if (authority.constructor.name === 'Promise') { + return () => ( + + ); + } + + // Function 处理 + if (authority.constructor.name === 'Function') { + try { + const bool = authority(); + if (bool) { + return target; + } + return Exception; + } catch (error) { + throw error; + } + } + throw new Error('unsupported parameters'); +}; + +export { checkPermissions }; + +const check = (authority, target, Exception) => { + return checkPermissions(authority, CURRENT, target, Exception); +}; + +export default check; diff --git a/src/components/Authorized/CheckPermissions.test.js b/src/components/Authorized/CheckPermissions.test.js new file mode 100644 index 00000000..ee686b32 --- /dev/null +++ b/src/components/Authorized/CheckPermissions.test.js @@ -0,0 +1,42 @@ +import { checkPermissions } from './CheckPermissions.js'; + +const target = 'ok'; +const error = 'error'; + +describe('test CheckPermissions', () => { + it('Correct string permission authentication', () => { + expect(checkPermissions('user', 'user', target, error)).toEqual('ok'); + }); + it('Correct string permission authentication', () => { + expect(checkPermissions('user', 'NULL', target, error)).toEqual('error'); + }); + it('authority is undefined , return ok', () => { + expect(checkPermissions(null, 'NULL', target, error)).toEqual('ok'); + }); + it('Wrong string permission authentication', () => { + expect(checkPermissions('admin', 'user', target, error)).toEqual('error'); + }); + it('Correct Array permission authentication', () => { + expect(checkPermissions(['user', 'admin'], 'user', target, error)).toEqual( + 'ok' + ); + }); + it('Wrong Array permission authentication,currentAuthority error', () => { + expect( + checkPermissions(['user', 'admin'], 'user,admin', target, error) + ).toEqual('error'); + }); + it('Wrong Array permission authentication', () => { + expect(checkPermissions(['user', 'admin'], 'guest', target, error)).toEqual( + 'error' + ); + }); + it('Wrong Function permission authentication', () => { + expect(checkPermissions(() => false, 'guest', target, error)).toEqual( + 'error' + ); + }); + it('Correct Function permission authentication', () => { + expect(checkPermissions(() => true, 'guest', target, error)).toEqual('ok'); + }); +}); diff --git a/src/components/Authorized/PromiseRender.js b/src/components/Authorized/PromiseRender.js new file mode 100644 index 00000000..ec7890cc --- /dev/null +++ b/src/components/Authorized/PromiseRender.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Spin } from 'antd'; + +export default class PromiseRender extends React.PureComponent { + state = { + component: false, + }; + async componentDidMount() { + this.props.promise + .then(() => { + this.setState({ + component: this.props.ok, + }); + }) + .catch(() => { + this.setState({ + component: this.props.error, + }); + }); + } + render() { + const C = this.state.component; + return C ? ( + + ) : ( +
+ +
+ ); + } +} diff --git a/src/components/Authorized/Secured.js b/src/components/Authorized/Secured.js new file mode 100644 index 00000000..1dc6fb17 --- /dev/null +++ b/src/components/Authorized/Secured.js @@ -0,0 +1,49 @@ +import React from 'react'; +import Exception from '../Exception/index'; +import CheckPermissions from './CheckPermissions'; +/** + * 默认不能访问任何页面 + * default is "NULL" + */ +const Exception403 = () => ( + +); + +/** + * 用于判断是否拥有权限访问此view权限 + * authority 支持传入 string ,funtion:()=>boolean|Promise + * e.g. 'user' 只有user用户能访问 + * e.g. 'user,admin' user和 admin 都能访问 + * e.g. ()=>boolean 返回true能访问,返回false不能访问 + * e.g. Promise then 能访问 catch不能访问 + * e.g. authority support incoming string, funtion: () => boolean | Promise + * e.g. 'user' only user user can access + * e.g. 'user, admin' user and admin can access + * e.g. () => boolean true to be able to visit, return false can not be accessed + * e.g. Promise then can not access the visit to catch + * @param {string | function | Promise} authority + * @param {ReactNode} error 非必需参数 + */ +const authorize = (authority, error) => { + /** + * conversion into a class + * 防止传入字符串时找不到staticContext造成报错 + * String parameters can cause staticContext not found error + */ + let classError = false; + if (error) { + classError = () => error; + } + if (!authority) { + throw new Error('authority is required'); + } + return function decideAuthority(targer) { + return CheckPermissions( + authority, + targer, + classError || Exception403 + ); + }; +}; + +export default authorize; diff --git a/src/components/Authorized/index.js b/src/components/Authorized/index.js new file mode 100644 index 00000000..c39de291 --- /dev/null +++ b/src/components/Authorized/index.js @@ -0,0 +1,32 @@ +import Authorized from './Authorized'; +import AuthorizedRoute from './AuthorizedRoute'; +import Secured from './Secured'; +import check from './CheckPermissions.js'; + +/* eslint-disable import/no-mutable-exports */ +let CURRENT = 'NULL'; + +Authorized.Secured = Secured; +Authorized.AuthorizedRoute = AuthorizedRoute; +Authorized.check = check; + +/** + * use authority or getAuthority + * @param {string|()=>String} currentAuthority + */ +const renderAuthorize = (currentAuthority) => { + if (currentAuthority) { + if (currentAuthority.constructor.name === 'Function') { + CURRENT = currentAuthority(); + } + if (currentAuthority.constructor.name === 'String') { + CURRENT = currentAuthority; + } + } else { + CURRENT = 'NULL'; + } + return Authorized; +}; + +export { CURRENT }; +export default renderAuthorize; diff --git a/src/components/SiderMenu/SiderMenu.js b/src/components/SiderMenu/SiderMenu.js index 813086cb..005ab3cf 100644 --- a/src/components/SiderMenu/SiderMenu.js +++ b/src/components/SiderMenu/SiderMenu.js @@ -77,62 +77,98 @@ export default class SiderMenu extends PureComponent { return itemRegExp.test(path.replace(/^\//, '').replace(/\/$/, '')); }); } - getNavMenuItems(menusData) { + /** + * 判断是否是http链接.返回 Link 或 a + * Judge whether it is http link.return a or Link + * @memberof SiderMenu + */ + getMenuItemPath = (item) => { + const itemPath = this.conversionPath(item.path); + const icon = getIcon(item.icon); + const { target, name } = item; + // Is it a http link + if (/^https?:\/\//.test(itemPath)) { + return ( + + {icon}{name} + + ); + } + return ( + { this.props.onCollapse(true); } : undefined} + > + {icon}{name} + + ); + } + /** + * get SubMenu or Item + */ + getSubMenuOrItem=(item) => { + if (item.children && item.children.some(child => child.name)) { + return ( + + {getIcon(item.icon)} + {item.name} + + ) : item.name + } + key={item.key || item.path} + > + {this.getNavMenuItems(item.children)} + + ); + } else { + return ( + + {this.getMenuItemPath(item)} + + ); + } + } + /** + * 获得菜单子节点 + * @memberof SiderMenu + */ + getNavMenuItems = (menusData) => { if (!menusData) { return []; } return menusData.map((item) => { - if (!item.name) { + if (!item.name || item.hideInMenu) { return null; } - let itemPath; - if (item.path && item.path.indexOf('http') === 0) { - itemPath = item.path; - } else { - itemPath = `/${item.path || ''}`.replace(/\/+/g, '/'); - } - if (item.children && item.children.some(child => child.name)) { - return item.hideInMenu ? null : - ( - - {getIcon(item.icon)} - {item.name} - - ) : item.name - } - key={item.key || item.path} - > - {this.getNavMenuItems(item.children)} - - ); - } - const icon = getIcon(item.icon); - return item.hideInMenu ? null : - ( - - { - /^https?:\/\//.test(itemPath) ? ( - - {icon}{item.name} - - ) : ( - { this.props.onCollapse(true); } : undefined} - > - {icon}{item.name} - - ) - } - - ); + const ItemDom = this.getSubMenuOrItem(item); + return this.checkPermissionItem(item.authority, ItemDom); }); } + // conversion Path + // 转化路径 + conversionPath=(path) => { + if (path && path.indexOf('http') === 0) { + return path; + } else { + return `/${path || ''}`.replace(/\/+/g, '/'); + } + } + // permission to check + checkPermissionItem = (authority, ItemDom) => { + if (this.props.Authorized && this.props.Authorized.check) { + const { check } = this.props.Authorized; + return check( + authority, + ItemDom + ); + } + return ItemDom; + } handleOpenChange = (openKeys) => { const lastOpenKey = openKeys[openKeys.length - 1]; const isMainMenu = this.menus.some( diff --git a/src/layouts/BasicLayout.js b/src/layouts/BasicLayout.js index 5eda00f3..83d3552f 100644 --- a/src/layouts/BasicLayout.js +++ b/src/layouts/BasicLayout.js @@ -12,9 +12,13 @@ import GlobalFooter from '../components/GlobalFooter'; import SiderMenu from '../components/SiderMenu'; import NotFound from '../routes/Exception/404'; import { getRoutes } from '../utils/utils'; +import Authorized from '../utils/Authorized'; import { getMenuData } from '../common/menu'; import logo from '../assets/logo.svg'; +const { Content } = Layout; +const { AuthorizedRoute } = Authorized; + /** * 根据菜单取得重定向地址. */ @@ -34,7 +38,6 @@ const getRedirect = (item) => { }; getMenuData().forEach(getRedirect); -const { Content } = Layout; const query = { 'screen-xs': { maxWidth: 575, @@ -130,6 +133,10 @@ class BasicLayout extends React.PureComponent { { - redirectData.map(item => - + getRoutes(match.path, routerData).map(item => + ( + + ) ) } { - getRoutes(match.path, routerData).map(item => ( - - )) + redirectData.map(item => + + ) } diff --git a/src/models/login.js b/src/models/login.js index 55eda43e..ff45a612 100644 --- a/src/models/login.js +++ b/src/models/login.js @@ -1,5 +1,5 @@ -import { routerRedux } from 'dva/router'; import { fakeAccountLogin } from '../services/api'; +import { setAuthority } from '../utils/authority'; export default { namespace: 'login', @@ -21,7 +21,11 @@ export default { }); // Login successfully if (response.status === 'ok') { - yield put(routerRedux.push('/')); + // 非常粗暴的跳转,登陆成功之后权限会变成user或admin,会自动重定向到主页 + // Login success after permission changes to admin or user + // The refresh will automatically redirect to the home page + // yield put(routerRedux.push('/')); + location.reload(); } }, *logout(_, { put }) { @@ -29,14 +33,19 @@ export default { type: 'changeLoginStatus', payload: { status: false, + currentAuthority: 'guest', }, }); - yield put(routerRedux.push('/user/login')); + // yield put(routerRedux.push('/user/login')); + // Login out after permission changes to admin or user + // The refresh will automatically redirect to the login page + location.reload(); }, }, reducers: { changeLoginStatus(state, { payload }) { + setAuthority(payload.currentAuthority); return { ...state, status: payload.status, diff --git a/src/router.js b/src/router.js index 03f8528d..bc030b8c 100644 --- a/src/router.js +++ b/src/router.js @@ -1,12 +1,13 @@ import React from 'react'; -import { Router, Route, Switch } from 'dva/router'; +import { Router, Switch } from 'dva/router'; import { LocaleProvider, Spin } from 'antd'; import zhCN from 'antd/lib/locale-provider/zh_CN'; import dynamic from 'dva/dynamic'; import { getRouterData } from './common/router'; - +import Authorized from './utils/Authorized'; import styles from './index.less'; +const { AuthorizedRoute } = Authorized; dynamic.setDefaultLoadingComponent(() => { return ; }); @@ -19,8 +20,18 @@ function RouterConfig({ history, app }) { - } /> - } /> + } + authority="guest" + redirectPath="/" + /> + } + authority={['admin', 'user']} + redirectPath="/user/login" + /> diff --git a/src/routes/Dashboard/Monitor.js b/src/routes/Dashboard/Monitor.js index 6d3cd64a..2d65bfb4 100644 --- a/src/routes/Dashboard/Monitor.js +++ b/src/routes/Dashboard/Monitor.js @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { connect } from 'dva'; import { Row, Col, Card, Tooltip } from 'antd'; import numeral from 'numeral'; - +import { Secured } from '../../utils/Authorized'; import { Pie, WaterWave, Gauge, TagCloud } from '../../components/Charts'; import NumberInfo from '../../components/NumberInfo'; import CountDown from '../../components/CountDown'; @@ -12,6 +12,7 @@ import styles from './Monitor.less'; const targetTime = new Date().getTime() + 3900000; +@Secured('admin') @connect(state => ({ monitor: state.monitor, })) @@ -40,7 +41,10 @@ export default class Monitor extends PureComponent { /> - + } /> @@ -55,10 +59,7 @@ export default class Monitor extends PureComponent {
- map + map
@@ -140,17 +141,20 @@ export default class Monitor extends PureComponent { - - + + - - + + diff --git a/src/routes/User/Login.js b/src/routes/User/Login.js index a348e536..6df101e5 100644 --- a/src/routes/User/Login.js +++ b/src/routes/User/Login.js @@ -62,8 +62,8 @@ export default class LoginPage extends Component { login.submitting === false && this.renderMessage('账户或密码错误') } - - + + { diff --git a/src/utils/Authorized.js b/src/utils/Authorized.js new file mode 100644 index 00000000..de1396a3 --- /dev/null +++ b/src/utils/Authorized.js @@ -0,0 +1,5 @@ +import RenderAuthorized from '../components/Authorized'; +import { getAuthority } from './authority'; + +const Authorized = RenderAuthorized(getAuthority()); +export default Authorized; diff --git a/src/utils/authority.js b/src/utils/authority.js new file mode 100644 index 00000000..2b5d3b14 --- /dev/null +++ b/src/utils/authority.js @@ -0,0 +1,8 @@ +// use localStorage to store the authority info, which might be sent from server in actual project. +export function getAuthority() { + return localStorage.getItem('antd-pro-authority') || 'guest'; +} + +export function setAuthority(authority) { + return localStorage.setItem('antd-pro-authority', authority); +} diff --git a/src/utils/utils.js b/src/utils/utils.js index 89476c9e..e80cce5b 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -124,9 +124,9 @@ export function getRoutes(path, routerData) { const renderRoutes = renderArr.map((item) => { const exact = !routes.some(route => route !== item && getRelation(route, item) === 1); return { + ...routerData[`${path}${item}`], key: `${path}${item}`, path: `${path}${item}`, - component: routerData[`${path}${item}`].component, exact, }; }); -- GitLab