├── example ├── package.json ├── tsconfig.json ├── global.d.ts ├── pages │ ├── index.css │ └── index.tsx ├── app.ts ├── access.ts └── .umirc.ts ├── .fatherrc.ts ├── .prettierrc ├── .gitignore ├── jest.config.js ├── src ├── utils │ ├── getRootContainerContent.ts │ ├── getContextContent.ts │ ├── __tests__ │ │ ├── getAccessContent.test.ts │ │ ├── getContextContent.test.ts │ │ ├── getRootContainerContent.test.ts │ │ ├── getAccessProviderContent.test.ts │ │ └── index.test.ts │ ├── index.ts │ ├── getAccessContent.ts │ └── getAccessProviderContent.ts ├── __mocks__ │ └── fs.ts ├── __tests__ │ └── index.test.ts └── index.ts ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yml ├── LICENSE ├── circle.yml ├── package.json └── README.md /example/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /example/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | -------------------------------------------------------------------------------- /example/pages/index.css: -------------------------------------------------------------------------------- 1 | 2 | .normal { 3 | background: #79F2AA; 4 | } 5 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | cjs: 'babel', 4 | disableTypeCheck: true, 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 90 5 | } 6 | -------------------------------------------------------------------------------- /example/app.ts: -------------------------------------------------------------------------------- 1 | export async function getInitialState() { 2 | return { 3 | groupName: 'umijs', 4 | }; 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /yarn.lock 3 | /package-lock.json 4 | /example/dist 5 | /test/fixtures/*/.umi 6 | /lib 7 | /coverage 8 | .umi 9 | .umi-production 10 | node_modules 11 | .idea 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | collectCoverageFrom: [ 4 | `src/**/*.{js,jsx,ts,tsx}`, 5 | '!**/fixtures/**', 6 | ], 7 | coveragePathIgnorePatterns: [ 8 | '/test', 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /example/access.ts: -------------------------------------------------------------------------------- 1 | import { InitialState } from 'umi'; 2 | 3 | export default function accessFactory(initialState: InitialState) { 4 | console.log(initialState); 5 | return { 6 | readArticle: true, 7 | updateArticle: () => false, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/getRootContainerContent.ts: -------------------------------------------------------------------------------- 1 | 2 | export default function() { 3 | return `\ 4 | import React from 'react'; 5 | import AccessProvider from './AccessProvider'; 6 | 7 | export function rootContainer(container: React.ReactNode) { 8 | return React.createElement(AccessProvider, null, container); 9 | } 10 | `; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/getContextContent.ts: -------------------------------------------------------------------------------- 1 | 2 | export default function() { 3 | return `\ 4 | import React from 'react'; 5 | import accessFactory from '@/access'; 6 | 7 | export type AccessInstance = ReturnType; 8 | 9 | const AccessContext = React.createContext(null!); 10 | 11 | export default AccessContext; 12 | `; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/__tests__/getAccessContent.test.ts: -------------------------------------------------------------------------------- 1 | import getAccessContent from '../getAccessContent'; 2 | 3 | describe('getAccessContent', () => { 4 | it('should return content string when call getAccessContent', () => { 5 | const result = getAccessContent(); 6 | expect(typeof result).toBe('string'); 7 | expect(result.length).toBeGreaterThan(0); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/__tests__/getContextContent.test.ts: -------------------------------------------------------------------------------- 1 | import getContextContent from '../getContextContent'; 2 | 3 | describe('getContextContent', () => { 4 | it('should return content string when call getContextContent', () => { 5 | const result = getContextContent(); 6 | expect(typeof result).toBe('string'); 7 | expect(result.length).toBeGreaterThan(0); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/__tests__/getRootContainerContent.test.ts: -------------------------------------------------------------------------------- 1 | import getRootContainerContent from '../getRootContainerContent'; 2 | 3 | describe('getRootContainerContent', () => { 4 | it('should return content string when call getRootContainerContent', () => { 5 | const result = getRootContainerContent(); 6 | expect(typeof result).toBe('string'); 7 | expect(result.length).toBeGreaterThan(0); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/__tests__/getAccessProviderContent.test.ts: -------------------------------------------------------------------------------- 1 | import getAccessProviderContent from '../getAccessProviderContent'; 2 | 3 | describe('getAccessProviderContent', () => { 4 | it('should return content string when call getAccessProviderContent', () => { 5 | const result = getAccessProviderContent(); 6 | expect(typeof result).toBe('string'); 7 | expect(result.length).toBeGreaterThan(0); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /example/.umirc.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { IConfig } from 'umi-types'; 3 | 4 | export default { 5 | routes: [ 6 | { path: '/', component: './index', access: 'readArticle' }, 7 | { 8 | path: '/update', 9 | component: './index', 10 | access: 'updateArticle', 11 | routes: [{ path: 'test', component: './index' }], 12 | }, 13 | ], 14 | plugins: [ 15 | join(__dirname, '..', require('../package').main || 'index.js'), 16 | ['@umijs/plugin-initial-state'], 17 | ['@umijs/plugin-model'], 18 | ], 19 | } as IConfig; 20 | -------------------------------------------------------------------------------- /example/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAccess, Access } from 'umi'; 3 | import styles from './index.css'; 4 | 5 | export default function() { 6 | const access = useAccess(); 7 | 8 | return ( 9 |
10 |

Page index

11 | Can not read article.
}> 12 |
Can read article.
13 | 14 | Can not update article.}> 15 |
Can update article.
16 |
17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build/dist", 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["esnext", "dom"], 7 | "sourceMap": true, 8 | "baseUrl": ".", 9 | "jsx": "react", 10 | "allowSyntheticDefaultImports": true, 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "suppressImplicitAnyIndexErrors": true, 15 | "noUnusedLocals": true, 16 | "allowJs": true, 17 | "experimentalDecorators": true, 18 | "strict": true 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | "build", 23 | "dist", 24 | "script" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node_version: [10.x, 12.x] 11 | os: [ubuntu-latest] 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js ${{ matrix.node_version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node_version }} 18 | - run: yarn 19 | - run: yarn build 20 | - run: yarn test --forceExit 21 | env: 22 | CI: true 23 | HEADLESS: false 24 | PROGRESS: none 25 | NODE_ENV: test 26 | NODE_OPTIONS: --max_old_space_size=4096 27 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export function getScriptPath(filepath: string): string { 4 | let realFilePath: string = ''; 5 | if (fs.existsSync(`${filepath}.ts`)) { 6 | realFilePath = `${filepath}.ts`; 7 | } else if (fs.existsSync(`${filepath}.js`)) { 8 | realFilePath = `${filepath}.js`; 9 | } 10 | return realFilePath; 11 | } 12 | 13 | export function checkIfHasDefaultExporting(filepath: string): boolean { 14 | const scriptPath = getScriptPath(filepath); 15 | if (!scriptPath) { 16 | return false; 17 | } 18 | 19 | const fileContent = fs.readFileSync(scriptPath, 'utf8'); 20 | const validationRegExp = /(export\s*default)|(exports\.default)|(module.exports[\s\S]*default)|(module.exports[\s\n]*=)/m; 21 | 22 | return validationRegExp.test(fileContent); 23 | } 24 | -------------------------------------------------------------------------------- /src/__mocks__/fs.ts: -------------------------------------------------------------------------------- 1 | interface FS { 2 | existsSync: (filePath: string) => boolean; 3 | readFileSync: (filePath: string) => string; 4 | } 5 | 6 | const fs = jest.genMockFromModule('fs'); 7 | 8 | const PathToAccess = 'path/to/access'; 9 | 10 | function existsSync(filePath: string): boolean { 11 | if (filePath.startsWith(PathToAccess)) { 12 | return true; 13 | } else if (filePath.endsWith('path/to/js.js')) { 14 | return true; 15 | } else if (filePath.endsWith('path/to/no/export/access.ts')) { 16 | return true; 17 | } 18 | return false; 19 | } 20 | 21 | function readFileSync(filePath: string): string { 22 | if (filePath.startsWith(PathToAccess)) { 23 | return 'export default'; 24 | } 25 | return 'Invalid content'; 26 | } 27 | 28 | fs.existsSync = existsSync; 29 | fs.readFileSync = readFileSync; 30 | 31 | export default fs; 32 | -------------------------------------------------------------------------------- /src/utils/getAccessContent.ts: -------------------------------------------------------------------------------- 1 | export default function() { 2 | return `\ 3 | import React, { useContext } from 'react'; 4 | import AccessContext, { AccessInstance as AccessInstanceType } from './context'; 5 | 6 | export type AccessInstance = AccessInstanceType; 7 | 8 | export const useAccess = () => { 9 | const access = useContext(AccessContext); 10 | 11 | return access; 12 | }; 13 | 14 | export interface AccessProps { 15 | accessible: boolean; 16 | fallback?: React.ReactNode; 17 | } 18 | 19 | export const Access: React.FC = props => { 20 | const { accessible, fallback, children } = props; 21 | 22 | if (process.env.NODE_ENV === 'development' && typeof accessible === 'function') { 23 | console.warn( 24 | '[plugin-access]: provided "accessible" prop is a function named "' + 25 | (accessible as Function).name + 26 | '" instead of a boolean, maybe you need check it.', 27 | ); 28 | } 29 | 30 | return <>{accessible ? children : fallback}; 31 | }; 32 | `; 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present () 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getScriptPath, checkIfHasDefaultExporting } from '../index'; 2 | 3 | jest.mock('fs'); 4 | 5 | describe('Utils', () => { 6 | describe('getScriptPath', () => { 7 | it('should return script file path when file exist', () => { 8 | const tsScriptPath = getScriptPath('path/to/access'); 9 | const jsScriptPath = getScriptPath('path/to/js'); 10 | expect(tsScriptPath).toBe('path/to/access.ts'); 11 | expect(jsScriptPath).toBe('path/to/js.js'); 12 | }); 13 | 14 | it('should return empty path when file does not exist', () => { 15 | const scriptPath = getScriptPath('path/not/exist'); 16 | expect(scriptPath).toBe(''); 17 | }); 18 | }); 19 | 20 | describe('checkIfHasDefaultExporting', () => { 21 | it('should return true if file path has default exporting member', () => { 22 | const hasDefaultExporting: boolean = checkIfHasDefaultExporting('path/to/access'); 23 | expect(hasDefaultExporting).toBe(true); 24 | }); 25 | 26 | it('should return false if file path does not has default exporting member', () => { 27 | const hasDefaultExporting: boolean = checkIfHasDefaultExporting('path/not/exist'); 28 | expect(hasDefaultExporting).toBe(false); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2.0 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:latest-browsers 11 | environment: 12 | CI: true 13 | NODE_ENV: test 14 | NODE_OPTIONS: --max_old_space_size=4096 15 | NPM_CONFIG_LOGLEVEL: error 16 | JOBS: max # https://gist.github.com/ralphtheninja/f7c45bdee00784b41fed 17 | branches: 18 | ignore: 19 | - gh-pages # list of branches to ignore 20 | - /release\/.*/ # or ignore regexes 21 | working_directory: ~/father 22 | 23 | steps: 24 | - checkout 25 | - restore_cache: 26 | key: node-modules-{{ checksum "package.json" }} 27 | - run: sudo npm install -g cnpm 28 | - run: cnpm install --registry=https://registry.npmjs.org 29 | - run: cnpm run build 30 | - run: 31 | command: npm run test -- --forceExit --detectOpenHandles --runInBand --maxWorkers=2 32 | no_output_timeout: 300m 33 | - run: bash <(curl -s https://codecov.io/bash) 34 | - save_cache: 35 | key: node-modules-{{ checksum "package.json" }} 36 | paths: 37 | - ./node_modules 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@umijs/plugin-access", 3 | "version": "1.1.0", 4 | "description": "Umi plugin for access management.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/umijs/plugin-access" 9 | }, 10 | "homepage": "https://github.com/umijs/plugin-access", 11 | "authors": [ 12 | "James Tsang " 13 | ], 14 | "bugs": { 15 | "url": "https://github.com/umijs/plugin-access/issues" 16 | }, 17 | "peerDependencies": { 18 | "@umijs/plugin-initial-state": "^0.1.0", 19 | "@umijs/plugin-model": "^0.1.0", 20 | "react": "^16.0.0", 21 | "umi": "2.x" 22 | }, 23 | "main": "lib/index.js", 24 | "scripts": { 25 | "start": "cross-env APP_ROOT=$PWD/example umi dev", 26 | "build": "father-build", 27 | "test": "umi-test --coverage", 28 | "debug": "umi-test", 29 | "prepublishOnly": "npm run build && np --no-cleanup --yolo --no-publish" 30 | }, 31 | "devDependencies": { 32 | "@testing-library/jest-dom": "^4.2.4", 33 | "@testing-library/react": "^9.3.2", 34 | "@types/jest": "^24.0.23", 35 | "@types/node": "^12.12.11", 36 | "@umijs/plugin-initial-state": "^0.2.0", 37 | "@umijs/plugin-model": "^0.1.0", 38 | "cross-env": "^6.0.3", 39 | "father-build": "^1.15.0", 40 | "np": "^5.2.1", 41 | "umi": "^2.9.0", 42 | "umi-test": "^1.3.3", 43 | "umi-types": "^0.5.7" 44 | }, 45 | "files": [ 46 | "lib", 47 | "src" 48 | ], 49 | "publishConfig": { 50 | "access": "public" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { IApi } from 'umi-types'; 2 | import registerAccessPlugin, { Options } from '../index'; 3 | 4 | jest.mock('fs'); 5 | 6 | let mockApi: IApi; 7 | 8 | describe('PluginAccess', () => { 9 | beforeEach(() => { 10 | mockApi = { 11 | paths: { 12 | absTmpDirPath: '/workspace/project/src/page/.umi', 13 | absSrcPath: '/workspace/project/src', 14 | }, 15 | log: { 16 | warn: jest.fn(), 17 | }, 18 | onGenerateFiles: (cb: () => void) => { 19 | cb(); 20 | }, 21 | writeTmpFile: jest.fn(), 22 | addRuntimePlugin: jest.fn(), 23 | addUmiExports: jest.fn(), 24 | addPageWatcher: jest.fn(), 25 | winPath: jest.fn(), 26 | onOptionChange: (cb: (opts: Options) => void) => { 27 | cb({ showWarning: true }); 28 | }, 29 | rebuildTmpFiles: jest.fn(), 30 | } as any; 31 | }); 32 | 33 | it('should call rebuildTmpFiles when optionChange', () => { 34 | registerAccessPlugin(mockApi); 35 | expect(mockApi.rebuildTmpFiles).toHaveBeenCalledTimes(1); 36 | }); 37 | 38 | it('should call log.warn when access file does not exist', () => { 39 | registerAccessPlugin(mockApi); 40 | expect(mockApi.log.warn).toHaveBeenCalledTimes(1); 41 | }); 42 | 43 | it('should call log.warn when access file exist but has not default exporting', () => { 44 | mockApi.paths.absSrcPath = 'path/to/no/export'; 45 | registerAccessPlugin(mockApi); 46 | expect(mockApi.log.warn).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | it('should NOT call log.warn when access file does not exist but showWarning option is false', () => { 50 | registerAccessPlugin(mockApi, { showWarning: false }); 51 | expect(mockApi.log.warn).not.toHaveBeenCalled(); 52 | }); 53 | 54 | it('should run correctly when access file is defined and default exporting a function', () => { 55 | mockApi.paths.absSrcPath = 'path/to'; 56 | registerAccessPlugin(mockApi, { showWarning: true }); 57 | expect(mockApi.log.warn).not.toHaveBeenCalled(); 58 | expect(mockApi.writeTmpFile).toHaveBeenCalledTimes(4); 59 | expect(mockApi.addUmiExports).toHaveBeenCalledTimes(1); 60 | expect(mockApi.addPageWatcher).toHaveBeenCalledTimes(1); 61 | expect(mockApi.winPath).toHaveBeenCalledTimes(1); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { IApi } from 'umi-types'; 2 | import { join } from 'path'; 3 | import getContextContent from './utils/getContextContent'; 4 | import getAccessProviderContent from './utils/getAccessProviderContent'; 5 | import getAccessContent from './utils/getAccessContent'; 6 | import getRootContainerContent from './utils/getRootContainerContent'; 7 | import { getScriptPath, checkIfHasDefaultExporting } from './utils'; 8 | 9 | const ACCESS_DIR = 'plugin-access'; // plugin-access 插件创建临时文件的专有文件夹 10 | 11 | export interface Options { 12 | showWarning: boolean; // 表明插件本身是否检测调用合法性并给出警告 13 | } 14 | 15 | const defaultOptions: Options = { showWarning: true }; 16 | 17 | export default function(api: IApi, opts: Options = defaultOptions) { 18 | const umiTmpDir = api.paths.absTmpDirPath; 19 | const srcDir = api.paths.absSrcPath; 20 | const accessTmpDir = join(umiTmpDir, ACCESS_DIR); 21 | const accessFilePath = join(srcDir, 'access'); 22 | 23 | api.onGenerateFiles(() => { 24 | // 判断 access 工厂函数存在并且 default 暴露了一个函数 25 | if (checkIfHasDefaultExporting(accessFilePath)) { 26 | // 创建 access 的 context 以便跨组件传递 access 实例 27 | api.writeTmpFile(`${ACCESS_DIR}/context.ts`, getContextContent()); 28 | 29 | // 创建 AccessProvider,1. 生成 access 实例; 2. 遍历修改 routes; 3. 传给 context 的 Provider 30 | api.writeTmpFile(`${ACCESS_DIR}/AccessProvider.ts`, getAccessProviderContent()); 31 | 32 | // 创建 access 的 hook 33 | api.writeTmpFile(`${ACCESS_DIR}/access.tsx`, getAccessContent()); 34 | 35 | // 生成 rootContainer 运行时配置 36 | api.writeTmpFile(`${ACCESS_DIR}/rootContainer.ts`, getRootContainerContent()); 37 | } else { 38 | if (opts.showWarning) { 39 | api.log.warn( 40 | `[plugin-access]: access.js or access.ts file should be defined at srcDir and default exporting a factory function.`, 41 | ); 42 | } 43 | } 44 | }); 45 | 46 | // * api.register() 不能在初始化之后运行 47 | if (checkIfHasDefaultExporting(accessFilePath)) { 48 | // 增加 rootContainer 运行时配置 49 | // TODO: eliminate this workaround 50 | api.addRuntimePlugin(join(umiTmpDir, '@tmp', ACCESS_DIR, 'rootContainer.ts')); 51 | 52 | api.addUmiExports([ 53 | { 54 | exportAll: true, 55 | source: api.winPath(join(accessTmpDir, 'access')), 56 | }, 57 | ]); 58 | 59 | api.addPageWatcher([`${accessFilePath}.ts`, `${accessFilePath}.js`]); 60 | } 61 | 62 | api.onOptionChange(newOpts => { 63 | opts = newOpts || defaultOptions; 64 | api.rebuildTmpFiles(); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/getAccessProviderContent.ts: -------------------------------------------------------------------------------- 1 | export default function() { 2 | return `\ 3 | import React, { useMemo } from 'react'; 4 | import { useModel } from 'umi'; 5 | import { IRoute } from 'umi-types'; 6 | import accessFactory from '@/access'; 7 | import AccessContext, { AccessInstance } from './context'; 8 | 9 | if (typeof useModel !== 'function') { 10 | throw new Error('[plugin-access]: useModel is not a function, @umijs/plugin-initial-state is needed.') 11 | } 12 | 13 | const _routes = require('../router').routes; 14 | 15 | type Routes = IRoute[]; 16 | 17 | function traverseModifyRoutes(routes: Routes, access: AccessInstance = {} as AccessInstance) { 18 | const resultRoutes: Routes = [].concat(routes as any); 19 | const notHandledRoutes: Routes = []; 20 | 21 | notHandledRoutes.push(...resultRoutes); 22 | 23 | for (let i = 0; i < notHandledRoutes.length; i++) { 24 | const currentRoute = notHandledRoutes[i]; 25 | let currentRouteAccessible = typeof currentRoute.unaccessible === 'boolean' ? !currentRoute.unaccessible : true; 26 | if (currentRoute && currentRoute.access) { 27 | if (typeof currentRoute.access !== 'string') { 28 | throw new Error('[plugin-access]: "access" field set in "' + currentRoute.path + '" route should be a string.'); 29 | } 30 | const accessProp = access[currentRoute.access]; 31 | if (typeof accessProp === 'function') { 32 | currentRouteAccessible = accessProp(currentRoute) 33 | } else if (typeof accessProp === 'boolean') { 34 | currentRouteAccessible = accessProp; 35 | } 36 | currentRoute.unaccessible = !currentRouteAccessible; 37 | } 38 | 39 | if (currentRoute.routes || currentRoute.childRoutes) { 40 | const childRoutes: Routes = currentRoute.routes || currentRoute.childRoutes; 41 | if (!Array.isArray(childRoutes)) { 42 | continue; 43 | } 44 | childRoutes.forEach(childRoute => { childRoute.unaccessible = !currentRouteAccessible }); // Default inherit from parent route 45 | notHandledRoutes.push(...childRoutes); 46 | } 47 | } 48 | 49 | return resultRoutes; 50 | } 51 | 52 | interface Props { 53 | children: React.ReactNode; 54 | } 55 | 56 | const AccessProvider: React.FC = props => { 57 | const { children } = props; 58 | const { initialState } = useModel('@@initialState'); 59 | 60 | const access = useMemo(() => accessFactory(initialState as any), [initialState]); 61 | 62 | if (process.env.NODE_ENV === 'development' && (access === undefined || access === null)) { 63 | console.warn('[plugin-access]: the access instance created by access.ts(js) is nullish, maybe you need check it.'); 64 | } 65 | 66 | _routes.splice(0, _routes.length, ...traverseModifyRoutes(_routes, access)); 67 | 68 | return React.createElement( 69 | AccessContext.Provider, 70 | { value: access }, 71 | children, 72 | ); 73 | }; 74 | 75 | export default AccessProvider; 76 | `; 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @umijs/plugin-access 2 | 3 | [![codecov](https://codecov.io/gh/umijs/plugin-access/branch/master/graph/badge.svg)](https://codecov.io/gh/umijs/plugin-access) 4 | [![NPM version](https://img.shields.io/npm/v/@umijs/plugin-access.svg?style=flat)](https://npmjs.org/package/@umijs/plugin-access) 5 | [![CircleCI](https://circleci.com/gh/umijs/plugin-access/tree/master.svg?style=svg)](https://circleci.com/gh/umijs/plugin-access/tree/master) 6 | [![GitHub Actions status](https://github.com/umijs/plugin-access/workflows/Node%20CI/badge.svg)](https://github.com/umijs/plugin-access) 7 | [![NPM downloads](http://img.shields.io/npm/dm/@umijs/plugin-access.svg?style=flat)](https://npmjs.org/package/@umijs/plugin-access) 8 | 9 | Umi plugin for access management. 10 | 11 | ## Prerequisites 12 | 13 | Before using this plugin, you need install and enable [@umijs/plugin-initial-state](https://www.npmjs.com/package/@umijs/plugin-initial-state) and [@umijs/plugin-model](https://www.npmjs.com/package/@umijs/plugin-model). 14 | 15 | ## Install 16 | 17 | ```bash 18 | # or yarn 19 | $ npm install @umijs/plugin-access --save 20 | ``` 21 | 22 | ## Usage 23 | 24 | Getting started in 3 steps. 25 | 26 | ### 1. Configure in `.umirc.js` 27 | 28 | **Caution**: `@umijs/plugin-access`, `@umijs/plugin-initial-state` and `@umijs/plugin-model` must be in this order. 29 | 30 | ```js 31 | export default { 32 | plugins: [ 33 | ['@umijs/plugin-access'], 34 | ['@umijs/plugin-initial-state'], 35 | ['@umijs/plugin-model'], 36 | ], 37 | }; 38 | ``` 39 | 40 | ### 2. Export `getInitialState()` function in `src/app.js` 41 | 42 | You can fetch some data asynchronously or synchronously then return whatever value in the `getInitialState()` function, the returned value would be saved as initial state (basic information) by umi. For example: 43 | 44 | ```js 45 | // src/app.js 46 | 47 | export async function getInitialState() { 48 | const { userId, fole } = await getCurrentRole(); 49 | return { 50 | userId, 51 | role, 52 | }; 53 | } 54 | ``` 55 | 56 | ### 3. Create `src/access.js` and defaultly export a function to define the access feature of your application 57 | 58 | With the initial state (basic information) prepared, you can define the access feature of your application, like "can create something", "can't update something", just return the definition in the function: 59 | 60 | ```js 61 | // src/access.js 62 | 63 | export default function(initialState) { 64 | const { userId, role } = initialState; // the initialState is the returned value in step 2 65 | 66 | return { 67 | canReadFoo: true, 68 | canUpdateFoo: role === 'admin', 69 | canDeleteFoo: foo => { 70 | return foo.ownerId === userId; 71 | }, 72 | }; 73 | } 74 | ``` 75 | 76 | ### 4. Consume the access feature definition 77 | 78 | After step 3, now you get the access feature definition of your application, then you can use the definition in your component: 79 | 80 | ```jsx 81 | import React from 'react'; 82 | import { useAccess, Access } from 'umi'; 83 | 84 | const PageA = props => { 85 | const { foo } = props; 86 | const access = useAccess(); // members of access: canReadFoo, canUpdateFoo, canDeleteFoo 87 | 88 | if (access.canReadFoo) { 89 | // Do something... 90 | } 91 | 92 | return ( 93 |
94 | Can not read foo content.
} 97 | > 98 | Foo content. 99 | 100 | Can not update foo.} 103 | > 104 | Update foo. 105 | 106 | Can not delete foo.} 109 | > 110 | Delete foo. 111 | 112 | 113 | ); 114 | }; 115 | ``` 116 | 117 | You can use the `access` instance to control the execution flow, use `` component to control the rendering, when `accessible` is true, children is rendered, otherwise `fallback` is rendered. 118 | 119 | **Full example can find in [./example](https://github.com/umijs/plugin-access/tree/master/example).** 120 | 121 | ## Options 122 | 123 | * `options.showWarning` 124 | 125 | A boolean value, default to be `true`. When `showWarning` is `true`, this plugin would check if `src/access.js` is exist and defaultly exports a function, if no function exported, a warning info would be shown, otherwise if `showWarning` is `false`, no warning info would be shown. 126 | 127 | ## LICENSE 128 | 129 | MIT 130 | --------------------------------------------------------------------------------