├── vite.config.js ├── src ├── Can.jsx ├── main.jsx ├── ability.js ├── App.css ├── App.jsx ├── TodoApp.jsx ├── PermissionController.jsx └── index.css ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── package.json ├── README.md └── public └── vite.svg /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/Can.jsx: -------------------------------------------------------------------------------- 1 | // create a context for permissionscreat and abilities 2 | 3 | import { createContext } from "react"; 4 | import { createContextualCan } from "@casl/react"; 5 | 6 | export const AbilityContext = createContext(); 7 | export const Can = createContextualCan(AbilityContext.Consumer); 8 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react-refresh/only-export-components': [ 16 | 'warn', 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/ability.js: -------------------------------------------------------------------------------- 1 | import { defineAbility } from "@casl/ability"; 2 | 3 | // "manage" and "all" are special keywords in CASL. 4 | // "manage" represents any action and "all" represents any subject. 5 | 6 | // const ability = defineAbility((can, cannot) => { 7 | // can("manage", "all"); 8 | // cannot("delete", "user"); 9 | // }); // better definer 👇 10 | 11 | // Dynamic function to define abilities 12 | export const defineAbilityFor = (permissions) => { 13 | return defineAbility((can) => { 14 | Object.keys(permissions).forEach((resource) => { 15 | permissions[resource].forEach((permission) => { 16 | can(permission, resource); 17 | }); 18 | }); 19 | }); 20 | }; 21 | 22 | export default defineAbilityFor; 23 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "casl", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@casl/ability": "^6.5.0", 14 | "@casl/react": "^3.1.0", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.2.43", 20 | "@types/react-dom": "^18.2.17", 21 | "@vitejs/plugin-react": "^4.2.1", 22 | "eslint": "^8.55.0", 23 | "eslint-plugin-react": "^7.33.2", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.5", 26 | "vite": "^5.0.8" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import TodoApp from "./TodoApp"; 4 | import { AbilityContext } from "./Can"; 5 | import defineAbilityFor from "./ability"; 6 | 7 | import "./App.css"; 8 | import PermissionController from "./PermissionController"; 9 | 10 | function App() { 11 | // list of permissions 12 | const [permissions, setPermissions] = useState({ 13 | Todo: ["read", "update", "delete", "create"], 14 | }); 15 | 16 | // ability instance 17 | const ability = defineAbilityFor(permissions); 18 | 19 | return ( 20 | <> 21 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /src/TodoApp.jsx: -------------------------------------------------------------------------------- 1 | import { Can } from "./Can"; 2 | 3 | // read - update - delete - create 4 | function TodoApp() { 5 | const fakeTodos = [ 6 | { id: 1, title: "Todo #1" }, 7 | { id: 2, title: "Todo #2" }, 8 | { id: 3, title: "Todo #3" }, 9 | ]; 10 | 11 | return ( 12 |
13 |

Todo Form

14 |
15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 | {fakeTodos.map((todo) => ( 23 |
24 |

{todo.title}

25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ))} 33 |
34 |
35 |
36 | ); 37 | } 38 | 39 | export default TodoApp; 40 | -------------------------------------------------------------------------------- /src/PermissionController.jsx: -------------------------------------------------------------------------------- 1 | // This component add/remove items of permissions state 2 | // to show or hide elements using component 3 | 4 | function PermissionController({ permissions: { Todo }, setPermissions }) { 5 | const changeHandler = (event) => { 6 | const name = event.target.name; 7 | const checked = event.target.checked; 8 | 9 | if (checked) { 10 | setPermissions({ Todo: [...Todo, name] }); 11 | } else { 12 | setPermissions({ Todo: Todo.filter((p) => p !== name) }); 13 | } 14 | }; 15 | 16 | return ( 17 |
18 |

Permission Editor

19 |
20 | {["read", "create", "update", "delete"].map((i) => ( 21 |
22 | 23 | 29 |
30 | ))} 31 |
32 |
33 | ); 34 | } 35 | 36 | export default PermissionController; 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CASL 2 | 3 | CASL operates on the abilities level, that is what a user can actually do in the application. An ability itself depends on the 4 parameters (last 3 are optional): 4 | 5 | ### User Action 6 | 7 | Describes what user can actually do in the app. User action is a word (usually a verb) which depends on the business logic (e.g., prolong, read). Very often it will be a list of words from CRUD - create, read, update and delete. 8 | 9 | ### Subject 10 | 11 | The subject or subject type which you want to check user action on. Usually this is a business (or domain) entity (e.g., Subscription, Article, User). The relation between subject and subject type is the same as relation between an object instance and its class. 12 | 13 | ### Fields 14 | 15 | Can be used to restrict user action only to matched subject's fields (e.g., to allow moderator to update status field of an Article and disallow to update description or title) 16 | 17 | ### Conditions 18 | 19 | Criteria which restricts user action only to matched subjects. This is useful when you need to give a permission on specific subjects (e.g., to allow user to manage own Article) 20 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button, 39 | input { 40 | margin: 5px; 41 | border-radius: 8px; 42 | border: 1px solid transparent; 43 | padding: 0.6em 1.2em; 44 | font-size: 1em; 45 | font-weight: 500; 46 | font-family: inherit; 47 | background-color: #1a1a1a; 48 | cursor: pointer; 49 | transition: border-color 0.25s; 50 | } 51 | button:hover { 52 | border-color: #646cff; 53 | } 54 | button:focus, 55 | button:focus-visible { 56 | outline: 4px auto -webkit-focus-ring-color; 57 | } 58 | 59 | section { 60 | display: flex; 61 | } 62 | 63 | h4 { 64 | margin: 50px 0 10px; 65 | } 66 | 67 | label { 68 | margin-left: 20px; 69 | } 70 | 71 | @media (prefers-color-scheme: light) { 72 | :root { 73 | color: #213547; 74 | background-color: #ffffff; 75 | } 76 | a:hover { 77 | color: #747bff; 78 | } 79 | button, 80 | input { 81 | background-color: #f3f3f3; 82 | } 83 | } 84 | --------------------------------------------------------------------------------