diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/package-lock.json b/package-lock.json index bd3c08ead..a78f537e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@uiw/codemirror-theme-github": "^4.21.18", "@uiw/react-codemirror": "^4.21.18", "@zesty-io/live-editor": "^2.0.30", - "@zesty-io/material": "^0.12.0", + "@zesty-io/material": "^0.15.6", "@zesty-io/react-autolayout": "^1.0.0-beta.16", "algoliasearch": "^4.20.0", "aos": "^2.3.4", @@ -74,6 +74,7 @@ "@next/bundle-analyzer": "^14.0.1", "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", + "@types/react-window": "^1.8.8", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", "babel-eslint": "^10.1.0", @@ -808,7 +809,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.4", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -1318,7 +1321,9 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.8.1" @@ -2902,11 +2907,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.14.18", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.6.tgz", + "integrity": "sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", - "@mui/utils": "^5.14.18", + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.16.6", "prop-types": "^15.8.1" }, "engines": { @@ -2914,7 +2921,7 @@ }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0", @@ -2957,17 +2964,19 @@ } }, "node_modules/@mui/styles": { - "version": "5.14.18", + "version": "5.16.7", + "resolved": "https://registry.npmjs.org/@mui/styles/-/styles-5.16.7.tgz", + "integrity": "sha512-FfXhHP/2MlqH+vLs2tIHMeCChmqSRgkOALVNLKkPrDsvtoq5J8OraOutCn1scpvRjr9mO8ZhW6jKx2t/vUDxtQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/runtime": "^7.23.2", + "@babel/runtime": "^7.23.9", "@emotion/hash": "^0.9.1", - "@mui/private-theming": "^5.14.18", - "@mui/types": "^7.2.9", - "@mui/utils": "^5.14.18", - "clsx": "^2.0.0", - "csstype": "^3.1.2", + "@mui/private-theming": "^5.16.6", + "@mui/types": "^7.2.15", + "@mui/utils": "^5.16.6", + "clsx": "^2.1.0", + "csstype": "^3.1.3", "hoist-non-react-statics": "^3.3.2", "jss": "^10.10.0", "jss-plugin-camel-case": "^10.10.0", @@ -2984,7 +2993,7 @@ }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0", @@ -2997,7 +3006,9 @@ } }, "node_modules/@mui/styles/node_modules/clsx": { - "version": "2.0.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "peer": true, "engines": { @@ -3050,10 +3061,12 @@ } }, "node_modules/@mui/types": { - "version": "7.2.9", + "version": "7.2.19", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.19.tgz", + "integrity": "sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==", "license": "MIT", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3062,20 +3075,24 @@ } }, "node_modules/@mui/utils": { - "version": "5.14.18", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz", + "integrity": "sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", - "@types/prop-types": "^15.7.10", + "@babel/runtime": "^7.23.9", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.2.0" + "react-is": "^18.3.1" }, "engines": { "node": ">=12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0", @@ -3087,6 +3104,15 @@ } } }, + "node_modules/@mui/utils/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@mui/x-data-grid": { "version": "6.18.1", "license": "MIT", @@ -4007,7 +4033,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.11", + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", "license": "MIT" }, "node_modules/@types/react": { @@ -4034,6 +4062,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.7", "license": "MIT" @@ -4057,7 +4095,9 @@ "license": "MIT" }, "node_modules/@types/stylis": { - "version": "4.2.3", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", "dev": true, "license": "MIT", "peer": true @@ -4812,7 +4852,9 @@ } }, "node_modules/@zesty-io/material": { - "version": "0.12.0", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.6.tgz", + "integrity": "sha512-zMWI1J+ArxhD7VX+A6JGRtwMO4oVNIPm+ZcjqugbGK1u/aaRRkRzhQ/ZsvTwHxOgK3rsatv6QWfYCttBTZF1KQ==", "license": "MIT", "dependencies": { "@emotion/react": "^11.9.0", @@ -5818,6 +5860,8 @@ }, "node_modules/camelize": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", "dev": true, "license": "MIT", "peer": true, @@ -6373,6 +6417,8 @@ }, "node_modules/css-color-keywords": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", "dev": true, "license": "ISC", "peer": true, @@ -6382,6 +6428,8 @@ }, "node_modules/css-to-react-native": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", "dev": true, "license": "MIT", "peer": true, @@ -6393,6 +6441,8 @@ }, "node_modules/css-vendor": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", "license": "MIT", "peer": true, "dependencies": { @@ -6424,7 +6474,9 @@ "license": "MIT" }, "node_modules/csstype": { - "version": "3.1.2", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, "node_modules/cypress": { @@ -9086,7 +9138,9 @@ } }, "node_modules/hyphenate-style-name": { - "version": "1.0.4", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause", "peer": true }, @@ -9517,6 +9571,8 @@ }, "node_modules/is-in-browser": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", "license": "MIT", "peer": true }, @@ -10991,6 +11047,8 @@ }, "node_modules/jquery": { "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", "license": "MIT", "peer": true }, @@ -11168,6 +11226,8 @@ }, "node_modules/jss": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", + "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", "license": "MIT", "peer": true, "dependencies": { @@ -11183,6 +11243,8 @@ }, "node_modules/jss-plugin-camel-case": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", + "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", "license": "MIT", "peer": true, "dependencies": { @@ -11193,6 +11255,8 @@ }, "node_modules/jss-plugin-default-unit": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", + "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", "license": "MIT", "peer": true, "dependencies": { @@ -11202,6 +11266,8 @@ }, "node_modules/jss-plugin-global": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", + "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", "license": "MIT", "peer": true, "dependencies": { @@ -11211,6 +11277,8 @@ }, "node_modules/jss-plugin-nested": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", + "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", "license": "MIT", "peer": true, "dependencies": { @@ -11221,6 +11289,8 @@ }, "node_modules/jss-plugin-props-sort": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", + "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", "license": "MIT", "peer": true, "dependencies": { @@ -11230,6 +11300,8 @@ }, "node_modules/jss-plugin-rule-value-function": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", + "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", "license": "MIT", "peer": true, "dependencies": { @@ -11240,6 +11312,8 @@ }, "node_modules/jss-plugin-vendor-prefixer": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", + "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", "license": "MIT", "peer": true, "dependencies": { @@ -13870,6 +13944,8 @@ }, "node_modules/postcss-value-parser": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true, "license": "MIT", "peer": true @@ -14693,7 +14769,9 @@ } }, "node_modules/react-is": { - "version": "18.2.0", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/react-json-view": { @@ -15497,7 +15575,9 @@ } }, "node_modules/search-insights": { - "version": "2.11.0", + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.2.tgz", + "integrity": "sha512-zFNpOpUO+tY2D85KrxJ+aqwnIfdEGi06UH2+xEb+Bp9Mwznmauqc9djbnBibJO5mpfUPPa8st6Sx65+vbeO45g==", "license": "MIT", "peer": true }, @@ -15577,6 +15657,8 @@ }, "node_modules/shallowequal": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "dev": true, "license": "MIT", "peer": true @@ -15769,7 +15851,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -16149,20 +16233,22 @@ } }, "node_modules/styled-components": { - "version": "6.1.1", + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.13.tgz", + "integrity": "sha512-M0+N2xSnAtwcVAQeFEsGWFFxXDftHUD7XrKla06QbpUMmbmtFBMMTcKWvFXtWxuD5qQkB8iU5gk6QASlx2ZRMw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/unitless": "^0.8.0", - "@types/stylis": "^4.0.2", - "css-to-react-native": "^3.2.0", - "csstype": "^3.1.2", - "postcss": "^8.4.31", - "shallowequal": "^1.1.0", - "stylis": "^4.3.0", - "tslib": "^2.5.0" + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" }, "engines": { "node": ">= 16" @@ -16176,8 +16262,40 @@ "react-dom": ">= 16.8.0" } }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/styled-components/node_modules/stylis": { - "version": "4.3.0", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", "dev": true, "license": "MIT", "peer": true diff --git a/package.json b/package.json index d210b4e99..7a000606d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@uiw/codemirror-theme-github": "^4.21.18", "@uiw/react-codemirror": "^4.21.18", "@zesty-io/live-editor": "^2.0.30", - "@zesty-io/material": "^0.12.0", + "@zesty-io/material": "^0.15.6", "@zesty-io/react-autolayout": "^1.0.0-beta.16", "algoliasearch": "^4.20.0", "aos": "^2.3.4", @@ -122,6 +122,7 @@ "@next/bundle-analyzer": "^14.0.1", "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", + "@types/react-window": "^1.8.8", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", "babel-eslint": "^10.1.0", diff --git a/public/assets/images/add_user.svg b/public/assets/images/add_user.svg new file mode 100644 index 000000000..de76486fa --- /dev/null +++ b/public/assets/images/add_user.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/data_table.svg b/public/assets/images/data_table.svg new file mode 100644 index 000000000..6414f3990 --- /dev/null +++ b/public/assets/images/data_table.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/no_search_results.svg b/public/assets/images/no_search_results.svg new file mode 100644 index 000000000..f2bb006a5 --- /dev/null +++ b/public/assets/images/no_search_results.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/shield.svg b/public/assets/images/shield.svg new file mode 100644 index 000000000..ef9969b31 --- /dev/null +++ b/public/assets/images/shield.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/styles/custom.css b/public/styles/custom.css index 242c797c0..8b046daa4 100644 --- a/public/styles/custom.css +++ b/public/styles/custom.css @@ -1,6 +1,6 @@ html { scroll-behavior: smooth; - font-family: Mulish, Arial, Helvetica,sans-serif; + font-family: Mulish, Arial, Helvetica, sans-serif; } h1 span { @@ -28,7 +28,7 @@ h2 span { flex-shrink: 0; width: 40px; height: 40px; - font-family: 'Mulish', Arial, Helvetica,sans-serif; + font-family: 'Mulish', Arial, Helvetica, sans-serif; font-size: 1.25rem; line-height: 1; border-radius: 4px; @@ -46,74 +46,74 @@ h2 span { margin-right: 10px; } - input:-webkit-autofill, -input:-webkit-autofill:hover, -input:-webkit-autofill:focus, -input:-webkit-autofill:active{ - -webkit-box-shadow: 0 0 0 30px white inset !important; - font-family: Mulish, Arial, sans-serif; - font-size: 14px +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px white inset !important; + font-family: Mulish, Arial, sans-serif; + font-size: 14px; } @font-face { - font-family: 'Mulish'; - src: url(../fonts/Mulish-Regular.woff2) format(woff2); - font-style: normal; - font-weight: 400; + font-family: 'Mulish'; + src: url(../fonts/Mulish-Regular.woff2) format(woff2); + font-style: normal; + font-weight: 400; } - @font-face { - font-family: 'Mulish'; - src: url(../fonts/Mulish-Medium.woff2) format(woff2); - font-style: normal; - font-weight: 500; + font-family: 'Mulish'; + src: url(../fonts/Mulish-Medium.woff2) format(woff2); + font-style: normal; + font-weight: 500; } @font-face { - font-family: 'Mulish'; - src: url(../fonts/Mulish-SemiBold.woff2) format(woff2); - font-style: normal; - font-weight: 600; + font-family: 'Mulish'; + src: url(../fonts/Mulish-SemiBold.woff2) format(woff2); + font-style: normal; + font-weight: 600; } @font-face { - font-family: 'Mulish'; - src: url(../fonts/Mulish-Bold.woff2) format(woff2); - font-style: normal; - font-weight: 700; + font-family: 'Mulish'; + src: url(../fonts/Mulish-Bold.woff2) format(woff2); + font-style: normal; + font-weight: 700; } @font-face { - font-family: 'Mulish'; - src: url(../fonts/Mulish-ExtraBold.woff2) format(woff2); - font-style: normal; - font-weight: 800; + font-family: 'Mulish'; + src: url(../fonts/Mulish-ExtraBold.woff2) format(woff2); + font-style: normal; + font-weight: 800; } @font-face { - font-family: 'Mulish'; - src: url(../fonts/Mulish-Black.woff2) format(woff2); - font-style: normal; - font-weight: 900; + font-family: 'Mulish'; + src: url(../fonts/Mulish-Black.woff2) format(woff2); + font-style: normal; + font-weight: 900; } /* Algolia DocSearch CSS */ /*! @docsearch/css 3.5.2 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */ :root { - --docsearch-primary-color: #FF5D0A !important; - --docsearch-text-color: #5b5b5b !important; - --docsearch-logo-color:#FF5D0A !important; - + --docsearch-primary-color: #ff5d0a !important; + --docsearch-text-color: #5b5b5b !important; + --docsearch-logo-color: #ff5d0a !important; } - -.DocSearch-Container { - z-index: 9999 !important +.DocSearch-Container { + z-index: 9999 !important; } .DocSearch-Button { width: 100% !important; max-width: 350px !important; } +/* Makes sure that the swal is on top of the MUI modals */ +.swal-zindex-override { + z-index: 1301; +} diff --git a/src/blocks/layoutsBlocks/revamp/layoutComponent/Images/index.js b/src/blocks/layoutsBlocks/revamp/layoutComponent/Images/index.js index 07965b791..2ba5cfbbd 100644 --- a/src/blocks/layoutsBlocks/revamp/layoutComponent/Images/index.js +++ b/src/blocks/layoutsBlocks/revamp/layoutComponent/Images/index.js @@ -1,7 +1,13 @@ -import ZestyImage from "blocks/Image/ZestyImage" +import ZestyImage from 'blocks/Image/ZestyImage'; function Images(props) { - - return + return ( + + ); } -export default Images \ No newline at end of file +export default Images; diff --git a/src/blocks/layoutsBlocks/revamp/layoutComponent/Row/index.js b/src/blocks/layoutsBlocks/revamp/layoutComponent/Row/index.js index 1b70290f9..22ff56995 100644 --- a/src/blocks/layoutsBlocks/revamp/layoutComponent/Row/index.js +++ b/src/blocks/layoutsBlocks/revamp/layoutComponent/Row/index.js @@ -1,32 +1,20 @@ -import { Box, Container, Grid, Stack } from "@mui/material" +import { Box, Container, Grid, Stack } from '@mui/material'; function Row(props) { - - if(props.data.name === 'container') { - return ( - - {props.children} - - ) - } - if(props.data.name === 'section') { - return ( - - {props.children} - - ) - } - else { - return ( - - {props.children} - - ) - } - + if (props.data.name === 'container') { + return ( + {props.children} + ); + } + if (props.data.name === 'section') { + return {props.children}; + } else { + return ( + + {props.children} + + ); + } } - -export default Row \ No newline at end of file +export default Row; diff --git a/src/blocks/layoutsBlocks/revamp/layoutComponent/Text/index.js b/src/blocks/layoutsBlocks/revamp/layoutComponent/Text/index.js index fab881b32..df9628e11 100644 --- a/src/blocks/layoutsBlocks/revamp/layoutComponent/Text/index.js +++ b/src/blocks/layoutsBlocks/revamp/layoutComponent/Text/index.js @@ -1,11 +1,18 @@ -import { Typography } from "@mui/material"; +import { Typography } from '@mui/material'; function Text(props) { - /** - * Fix me! - * the !important in attributes break the styles and disregard it + * Fix me! + * the !important in attributes break the styles and disregard it */ - return {props.data.content}; + return ( + + {props.data.content} + + ); } -export default Text \ No newline at end of file +export default Text; diff --git a/src/components/accounts/instances/lang.js b/src/components/accounts/instances/lang.js index ac11d15cc..5ac609245 100644 --- a/src/components/accounts/instances/lang.js +++ b/src/components/accounts/instances/lang.js @@ -3,6 +3,7 @@ export const lang = { tabs: { '': 'Overview', users: 'Users', + roles: 'Roles & Permissions', teams: 'Teams', domains: 'Domains', usage: 'Usage', diff --git a/src/components/accounts/instances/tabs.js b/src/components/accounts/instances/tabs.js index b52aa842d..734cdc0e7 100644 --- a/src/components/accounts/instances/tabs.js +++ b/src/components/accounts/instances/tabs.js @@ -9,6 +9,7 @@ import SupportAgentIcon from '@mui/icons-material/SupportAgent'; // import SettingsIcon from '@mui/icons-material/Settings'; import CreditCardIcon from '@mui/icons-material/CreditCard'; import LeaderboardIcon from '@mui/icons-material/Leaderboard'; +import AccountBoxRoundedIcon from '@mui/icons-material/AccountBoxRounded'; export const instanceTabs = [ { @@ -17,53 +18,59 @@ export const instanceTabs = [ label: 'Overview', sort: 0, }, + { + icon: , + filename: 'roles', + label: 'Roles & Permissions', + sort: 1, + }, { icon: , filename: 'users', label: 'Users', - sort: 1, + sort: 2, }, { icon: , filename: 'teams', label: 'Teams', - sort: 2, + sort: 3, }, { icon: , filename: 'domains', label: 'Domains', - sort: 3, + sort: 4, }, { icon: , filename: 'usage', label: 'Usage', - sort: 4, + sort: 5, }, { icon: , filename: 'locales', label: 'Locales', - sort: 5, + sort: 6, }, { icon: , filename: 'apis', label: 'APIs & Tokens', - sort: 6, + sort: 7, }, { icon: , filename: 'webhooks', label: 'Webhooks', - sort: 7, + sort: 8, }, { icon: , filename: 'support', label: 'Support', - sort: 8, + sort: 9, }, // comment out for now // { @@ -76,6 +83,6 @@ export const instanceTabs = [ icon: , filename: 'settings', label: 'Settings', - sort: 7, + sort: 10, }, ]; diff --git a/src/components/accounts/roles/BaseRoles.tsx b/src/components/accounts/roles/BaseRoles.tsx new file mode 100644 index 000000000..37b673617 --- /dev/null +++ b/src/components/accounts/roles/BaseRoles.tsx @@ -0,0 +1,146 @@ +import { + Box, + Typography, + List, + ListItem, + ListItemText, + ListItemAvatar, + Avatar, +} from '@mui/material'; +import { + AdminPanelSettingsRounded, + CodeRounded, + RecommendRounded, + BorderColorRounded, + PublicRounded, +} from '@mui/icons-material'; +import { Role } from 'store/types'; + +const BASE_ROLES_CONFIG = Object.freeze({ + owner: { + name: 'Owner', + description: + 'Full access to all sections: Content, Schema, Media, Code, Leads, Redirects, Reports, Apps, and Settings. In Accounts they have full access as well which includes the ability to: launch instances, add domains, invite new users and set their roles, add a team, and create tokens.', + avatar: ( + + + + ), + }, + admin: { + name: 'Admin', + description: + 'Have the same privileges as the Owner role except for deleting other users.', + avatar: ( + + theme.palette.success.dark }} + /> + + ), + }, + 'access admin': { + name: 'Access Admin', + description: + 'Have the same privileges as Admins, except they cannot create, update, delete, or publish content.', + avatar: ( + + theme.palette.pink[600] }} + /> + + ), + }, + developer: { + name: 'Developer', + description: + 'Access to Content, Schema, Media, Code, Leads, Redirects, Reports, Apps, and Setting section.', + avatar: ( + + theme.palette.blue[500] }} /> + + ), + }, + 'developer contributor': { + name: 'Developer Contributor', + description: + 'Have the same privileges as Developers except they cannot delete or publish content.', + avatar: ( + + theme.palette.yellow[500] }} /> + + ), + }, + seo: { + name: 'SEO', + description: + 'Access to Content, Media, Leads, Redirects, Reports, and Apps section.', + avatar: ( + + theme.palette.purple[600] }} /> + + ), + }, + publisher: { + name: 'Publisher', + description: 'Access to Content, Media, Leads, Reports, and Apps section.', + avatar: ( + + theme.palette.pink[600] }} /> + + ), + }, + contributor: { + name: 'Contributor', + description: 'Access to Content, Media, and Apps section.', + avatar: ( + + theme.palette.yellow[500] }} + /> + + ), + }, +}); + +type BaseRolesProps = { + baseRoles: Role[]; +}; +export const BaseRoles = ({ baseRoles }: BaseRolesProps) => { + return ( + + + Base Roles + + + {baseRoles?.map((role, index) => ( + `1px solid ${theme.palette.border}`, + borderRadius: 2, + mb: index + 1 < baseRoles?.length ? 1 : 0, + }} + > + + {BASE_ROLES_CONFIG[role.name.toLowerCase()]?.avatar} + + + {BASE_ROLES_CONFIG[role.name.toLowerCase()]?.name} + + } + secondary={ + + {BASE_ROLES_CONFIG[role.name.toLowerCase()]?.description} + + } + /> + + ))} + + + ); +}; diff --git a/src/components/accounts/roles/CreateCustomRoleDialog.tsx b/src/components/accounts/roles/CreateCustomRoleDialog.tsx new file mode 100644 index 000000000..e146e96a8 --- /dev/null +++ b/src/components/accounts/roles/CreateCustomRoleDialog.tsx @@ -0,0 +1,569 @@ +import { useReducer, useState, useMemo } from 'react'; +import { + Typography, + Avatar, + Stack, + Box, + TextField, + Autocomplete, + IconButton, + Dialog, + DialogContent, + DialogActions, + Button, + InputLabel, + Tooltip, +} from '@mui/material'; +import { + LocalPoliceOutlined, + Close, + InfoRounded, + Check, + EditRounded, + ImageRounded, + CodeRounded, + RecentActorsRounded, + BarChartRounded, + HistoryRounded, + SettingsRounded, + ShuffleRounded, + ExtensionRounded, +} from '@mui/icons-material'; +import { LoadingButton } from '@mui/lab'; +import { Database, Block } from '@zesty-io/material'; +import { useRouter } from 'next/router'; + +import { useZestyStore } from 'store'; +import { useRoles } from 'store/roles'; +import { ErrorMsg } from '../ui'; + +export const BASE_ROLE_PERMISSIONS = Object.freeze({ + '31-71cfc74-0wn3r': { + actions: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + grant: true, + super: true, + }, + products: { + content: true, + blocks: true, + schema: true, + media: true, + code: true, + leads: true, + analytics: true, + redirects: true, + activityLog: true, + apps: true, + settings: true, + }, + }, + '31-71cfc74-4dm13': { + actions: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + grant: true, + super: false, + }, + products: { + content: true, + blocks: true, + schema: true, + media: true, + code: true, + leads: true, + analytics: true, + redirects: true, + activityLog: true, + apps: true, + settings: true, + }, + }, + '31-71cfc74-4cc4dm13': { + actions: { + create: false, + read: true, + update: false, + delete: false, + publish: false, + grant: true, + super: true, + }, + products: { + content: true, + blocks: true, + schema: true, + media: true, + code: true, + leads: true, + analytics: true, + redirects: true, + activityLog: true, + apps: true, + settings: true, + }, + }, + '31-71cfc74-d3v3l0p3r': { + actions: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + grant: false, + super: false, + }, + products: { + content: true, + blocks: true, + schema: true, + media: true, + code: true, + leads: true, + analytics: false, + redirects: true, + activityLog: false, + apps: true, + settings: true, + }, + }, + '31-71cfc74-d3vc0n': { + actions: { + create: true, + read: true, + update: true, + delete: false, + publish: false, + grant: false, + super: false, + }, + products: { + content: true, + blocks: true, + schema: true, + media: true, + code: true, + leads: true, + analytics: false, + redirects: true, + activityLog: false, + apps: true, + settings: true, + }, + }, + '31-71cfc74-p0bl1shr': { + actions: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + grant: false, + super: false, + }, + products: { + content: true, + blocks: true, + schema: false, + media: true, + code: false, + leads: true, + analytics: false, + redirects: false, + activityLog: false, + apps: true, + settings: false, + }, + }, + '31-71cfc74-c0ntr1b0t0r': { + actions: { + create: true, + read: true, + update: true, + delete: false, + publish: false, + grant: false, + super: false, + }, + products: { + content: true, + blocks: true, + schema: true, + media: true, + code: true, + leads: true, + analytics: true, + redirects: true, + activityLog: true, + apps: true, + settings: true, + }, + }, + '31-71cfc74-s30': { + actions: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + grant: false, + super: false, + }, + products: { + content: true, + blocks: true, + schema: false, + media: true, + code: false, + leads: true, + analytics: false, + redirects: true, + activityLog: false, + apps: true, + settings: false, + }, + }, +}); +export const PRODUCT_DETAILS = Object.freeze({ + content: { + name: 'Content', + icon: , + }, + blocks: { + name: 'Blocks', + // FIXME: Icon is too small + icon: , + }, + schema: { + name: 'Schema', + icon: , + }, + media: { + name: 'Media', + icon: , + }, + code: { + name: 'Code (Zesty IDE)', + icon: , + }, + leads: { + name: 'Leads', + icon: , + }, + analytics: { + name: 'Analytics', + icon: , + }, + redirects: { + name: 'Redirects', + icon: , + }, + activityLog: { + name: 'Activity Log', + icon: , + }, + apps: { + name: 'Apps', + icon: , + }, + settings: { + name: 'Settings', + icon: , + }, +}); + +export type RoleDetails = { + name: string; + description: string; + systemRoleZUID: string; +}; +type FieldErrors = { + name: string; + description: string; +}; + +type CreateCustomRoleDialogProps = { + onClose: () => void; + onRoleCreated: (ZUID: string) => void; +}; +export const CreateCustomRoleDialog = ({ + onClose, + onRoleCreated, +}: CreateCustomRoleDialogProps) => { + const router = useRouter(); + const { instance } = useZestyStore((state) => state); + const { createRole, getRoles, baseRoles } = useRoles((state) => state); + const [isCreatingRole, setIsCreatingRole] = useState(false); + const [fieldErrors, updateFieldErrors] = useReducer( + (state: FieldErrors, data: Partial) => { + return { + ...state, + ...data, + }; + }, + { name: null, description: null }, + ); + + const [fieldData, updateFieldData] = useReducer( + (state: RoleDetails, data: Partial) => { + return { + ...state, + ...data, + }; + }, + { + name: '', + description: '', + systemRoleZUID: '31-71cfc74-4dm13', + }, + ); + + const baseRoleOptions = useMemo(() => { + if (!baseRoles?.length) return []; + + return baseRoles?.map((role) => ({ + label: role.name, + value: role.systemRoleZUID, + })); + }, [baseRoles]); + + const handleCreateRole = () => { + const instanceZUID = String(router?.query?.zuid); + + if (!fieldData.name) { + updateFieldErrors({ + name: 'Role name is required', + }); + return; + } + + setIsCreatingRole(true); + createRole({ + name: fieldData.name.replace(/[^\w\s\n]/g, ''), + description: fieldData.description.replace(/[^\w\s\n]/g, ''), + systemRoleZUID: fieldData.systemRoleZUID, + instanceZUID, + }) + .then((response: any) => { + getRoles(instanceZUID) + .then(() => { + onRoleCreated(response?.ZUID); + onClose?.(); + }) + .catch(() => ErrorMsg({ title: 'Failed to fetch roles' })); + }) + .catch(() => ErrorMsg({ title: 'Failed to create role' })) + .finally(() => { + setIsCreatingRole(false); + }); + }; + + return ( + onClose?.()} + PaperProps={{ + sx: { + maxWidth: 960, + width: 960, + minHeight: 800, + }, + }} + > + + + + + + + + Create Custom Role + + + Creates a custom role that can have granular permissions applied + to it + + + + onClose?.()}> + + + + + + + Role Name * + + + + + { + updateFieldData({ name: evt.target.value }); + + if (!!evt.target.value) { + updateFieldErrors({ + name: null, + }); + } + }} + placeholder="e.g. Lawyer" + fullWidth + disabled={isCreatingRole} + error={!!fieldErrors?.name} + helperText={fieldErrors?.name} + /> + + + + Role Description + + + + + + updateFieldData({ description: evt.target.value }) + } + placeholder="What is this role going to be used for" + multiline + fullWidth + rows={4} + disabled={isCreatingRole} + /> + + + + Base Role + + + + + role.value === fieldData.systemRoleZUID, + )} + onChange={(_, value) => + updateFieldData({ systemRoleZUID: value.value }) + } + options={baseRoleOptions} + renderInput={(params) => } + disabled={isCreatingRole} + /> + + + + {instance?.name} Base Permissions + + + {Object.entries( + BASE_ROLE_PERMISSIONS[fieldData.systemRoleZUID]?.actions || {}, + )?.map(([name, permission], index) => ( + + + {name} + + {!!permission ? ( + + ) : ( + + )} + + ))} + + + + + + Has access to: + + + {Object.entries( + BASE_ROLE_PERMISSIONS[fieldData.systemRoleZUID]?.products || {}, + )?.map(([product, hasAccess]) => { + if (hasAccess && !!PRODUCT_DETAILS[product]) { + return ( + + {PRODUCT_DETAILS[product]?.icon} + + {PRODUCT_DETAILS[product]?.name} + + + ); + } + })} + + + + + {fieldData.systemRoleZUID === '31-71cfc74-0wn3r' + ? 'Can delete users' + : 'Cannot delete other users'} + + + + + + + + Create + + + + ); +}; diff --git a/src/components/accounts/roles/CustomRoles.tsx b/src/components/accounts/roles/CustomRoles.tsx new file mode 100644 index 000000000..c232fa01a --- /dev/null +++ b/src/components/accounts/roles/CustomRoles.tsx @@ -0,0 +1,148 @@ +import { forwardRef, useState, useImperativeHandle } from 'react'; +import { + List, + ListItemButton, + ListItemText, + ListItemAvatar, + Avatar, + Typography, + Box, + IconButton, + Menu, + MenuItem, + ListItemIcon, +} from '@mui/material'; +import { + LocalPoliceOutlined, + MoreHorizRounded, + EditRounded, + DeleteRounded, +} from '@mui/icons-material'; + +import { EditCustomRoleDialog } from './EditCustomRoleDialog'; +import { DeleteCustomRoleDialog } from './DeleteCustomRoleDialog'; +import { Role } from 'store/types'; + +type CustomRolesProps = { + customRoles: Role[]; +}; +export const CustomRoles = forwardRef( + ({ customRoles }: CustomRolesProps, ref) => { + const [anchorEl, setAnchorEl] = useState(null); + const [ZUIDToEdit, setZUIDToEdit] = useState(null); + const [ZUIDToDelete, setZUIDToDelete] = useState(null); + const [activeZUID, setActiveZUID] = useState(null); + + useImperativeHandle(ref, () => ({ + updateZUIDToEdit: (ZUID: string) => setZUIDToEdit(ZUID), + })); + + return ( + <> + + + Custom Roles + + + {customRoles?.map((role, index) => ( + + `1px solid ${theme.palette.border}`, + borderRadius: 2, + mb: index + 1 < customRoles?.length ? 1 : 0, + }} + onClick={() => { + setZUIDToEdit(role.ZUID); + }} + > + + + + + + + {role.name} + + } + secondary={ + + {role.description || ''} + + } + /> + { + evt.stopPropagation(); + setAnchorEl(evt.currentTarget); + setActiveZUID(role.ZUID); + }} + > + + + + setAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > + { + setAnchorEl(null); + setZUIDToEdit(role.ZUID); + }} + > + + + + Edit + + { + setAnchorEl(null); + setZUIDToDelete(role.ZUID); + }} + > + + + + Delete + + + + ))} + + + {!!ZUIDToEdit && ( + setZUIDToEdit(null)} + /> + )} + {!!ZUIDToDelete && ( + setZUIDToDelete(null)} + /> + )} + + ); + }, +); diff --git a/src/components/accounts/roles/DeleteCustomRoleDialog.tsx b/src/components/accounts/roles/DeleteCustomRoleDialog.tsx new file mode 100644 index 000000000..849cd2c00 --- /dev/null +++ b/src/components/accounts/roles/DeleteCustomRoleDialog.tsx @@ -0,0 +1,128 @@ +import { useMemo, useState } from 'react'; +import { + Avatar, + Box, + Dialog, + DialogContent, + DialogActions, + Button, + TextField, + Typography, + InputLabel, + Autocomplete, +} from '@mui/material'; +import { DeleteRounded } from '@mui/icons-material'; +import { LoadingButton } from '@mui/lab'; +import { useRouter } from 'next/router'; + +import { useRoles } from 'store/roles'; +import { ErrorMsg } from '../ui'; + +type DeleteCustomRoleDialogProps = { + ZUID: string; + onClose: () => void; +}; +export const DeleteCustomRoleDialog = ({ + ZUID, + onClose, +}: DeleteCustomRoleDialogProps) => { + const router = useRouter(); + const { customRoles, baseRoles, deleteRole, getUsersWithRoles, getRoles } = + useRoles((state) => state); + const [isDeleting, setIsDeleting] = useState(false); + + const roleData = useMemo(() => { + return customRoles?.find((role) => role.ZUID === ZUID); + }, [ZUID, customRoles]); + + const roleOptions = useMemo(() => { + const customRolesOpts = customRoles + ?.filter((role) => role.ZUID !== ZUID) + ?.map((role) => ({ + label: role.name, + value: role.ZUID, + })); + const baseRolesOpts = baseRoles?.map((role) => ({ + label: role.name, + value: role.ZUID, + })); + + return [...customRolesOpts, ...baseRolesOpts]; + }, [customRoles, baseRoles]); + + const defaultBaseRole = baseRoles?.find( + (role) => role.systemRoleZUID === roleData?.systemRoleZUID, + ); + + const [selectedTransferRole, setSelectedTransferRole] = useState<{ + label: string; + value: string; + }>({ + label: defaultBaseRole?.name, + value: defaultBaseRole?.ZUID, + }); + + const { zuid: instanceZUID } = router.query; + + const handleConfirmDelete = () => { + setIsDeleting(true); + deleteRole({ + roleZUIDToDelete: ZUID, + roleZUIDToTransferUsers: selectedTransferRole?.value, + }) + .catch(() => ErrorMsg({ title: 'Failed to delete role' })) + .finally(() => { + setIsDeleting(false); + getRoles(String(instanceZUID)); + getUsersWithRoles(String(instanceZUID)); + onClose(); + }); + }; + + return ( + onClose?.()} + PaperProps={{ sx: { width: 480 } }} + > + + + + + + Delete Custom Role:{' '} + + {roleData?.name || ''} + + + + This role and its permissions will be immediately deactivated.
+ Please reassign users currently assigned to this role to a new role. +
+
+ + Role to reassign users to + setSelectedTransferRole(value)} + renderInput={(params) => } + /> + + + + + Delete Custom Role + + +
+ ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/index.tsx b/src/components/accounts/roles/EditCustomRoleDialog/index.tsx new file mode 100644 index 000000000..cf1607fdd --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/index.tsx @@ -0,0 +1,492 @@ +import { useMemo, useState, useReducer, useEffect } from 'react'; +import { + Typography, + Avatar, + Stack, + Box, + IconButton, + Dialog, + DialogContent, + DialogActions, + Button, + Tabs, + Tab, +} from '@mui/material'; +import { LoadingButton } from '@mui/lab'; +import { + LocalPoliceOutlined, + Close, + InfoRounded, + RuleRounded, + GroupsRounded, +} from '@mui/icons-material'; +import { useRouter } from 'next/router'; + +import { useRoles } from 'store/roles'; +import { Details } from './tabs/Details'; +import { Permissions } from './tabs/Permissions'; +import { Users } from './tabs/Users'; +import { GranularRole } from 'store/types'; +import { useZestyStore } from 'store'; +import { ErrorMsg } from 'components/accounts/ui'; + +type FieldErrors = { + detailsTab: { + roleName: string; + roleDescription: string; + }; + permissionsTab: string[]; + usersTab: string[]; +}; +export type RoleDetails = { + name: string; + description: string; + systemRoleZUID: string; +}; + +type EditCustomRoleDialogProps = { + ZUID: string; + onClose: () => void; +}; +export const EditCustomRoleDialog = ({ + ZUID, + onClose, +}: EditCustomRoleDialogProps) => { + const router = useRouter(); + const { ZestyAPI } = useZestyStore((state: any) => state); + const { + getRoles, + customRoles, + baseRoles, + updateGranularRole, + updateRole, + createGranularRole, + deleteGranularRole, + usersWithRoles, + updateUserRole, + getUsersWithRoles, + } = useRoles((state) => state); + const [activeTab, setActiveTab] = useState< + 'details' | 'permissions' | 'users' + >('details'); + const [isSaving, setIsSaving] = useState(false); + const [fieldErrors, updateFieldErrors] = useReducer( + ( + state: FieldErrors, + action: { + tab: keyof FieldErrors; + data: Partial; + }, + ) => { + return { + ...state, + [action.tab]: { + ...state[action.tab], + ...action.data, + }, + }; + }, + { + detailsTab: { + roleName: null, + roleDescription: null, + }, + permissionsTab: [], + usersTab: [], + }, + ); + + const roleUsers = useMemo(() => { + if (!usersWithRoles?.length) return []; + + return usersWithRoles?.filter((user) => user.role?.ZUID === ZUID); + }, [usersWithRoles, customRoles]); + const [userEmails, setUserEmails] = useState( + roleUsers?.map((user) => user.email) || [], + ); + + const { zuid: instanceZUID } = router.query; + + const roleData = customRoles?.find((role) => role.ZUID === ZUID); + const [granularRoles, setGranularRoles] = useState[]>( + [], + ); + const [resourceZUIDsToDelete, setResourceZUIDsToDelete] = useState( + [], + ); + + useEffect(() => { + setUserEmails(roleUsers?.map((user) => user.email) || []); + }, [roleUsers]); + + const [detailsData, updateDetailsData] = useReducer( + (state: RoleDetails, data: Partial) => { + return { + ...state, + ...data, + }; + }, + { + name: roleData?.name || '', + description: roleData?.description || '', + systemRoleZUID: roleData?.systemRoleZUID || '31-71cfc74-4dm13', + }, + ); + + useEffect(() => { + if (!ZUID) return; + + getPermissions(ZUID); + }, [ZUID]); + + const getPermissions = async (ZUID: string) => { + const res = await ZestyAPI.getAllGranularRoles(ZUID); + + if (res.error) { + ErrorMsg({ text: res.error }); + setGranularRoles([]); + } else { + setGranularRoles(res.data); + } + }; + + const saveGranularRoleUpdates = async () => { + const granularRolesClone: GranularRole[] = JSON.parse( + JSON.stringify(granularRoles || []), + ); + const payload = granularRolesClone?.map((role) => ({ + resourceZUID: role.resourceZUID, + create: role.create, + read: role.read, + update: role.update, + delete: role.delete, + publish: role.publish, + name: '', + })); + + if (!!roleData?.granularRoleZUID) { + // If a granularRoleZUID is already attached to the role, we can just + // do an update to add the new granular roles + if (!!payload?.length) { + return updateGranularRole({ + roleZUID: ZUID, + granularRoles: payload, + }); + } + } else { + // If the role doesn't have any granularRoleZUID attached, we need to create a + // granular role first + const granularRoleInitiator = payload?.[0]; + + if (granularRoleInitiator) { + return createGranularRole({ + roleZUID: ZUID, + data: granularRoleInitiator, + }).then(() => { + // If there are any other granular roles aside from the one we used to + // initiate a new granular role zuid, we then use the update endpoint to + // add those in as well + if (payload?.length > 1) { + return updateGranularRole({ + roleZUID: ZUID, + granularRoles: payload, + }); + } + }); + } + } + }; + + const saveUsersUpdate = () => { + const baseRoleZUID = baseRoles?.find( + (role) => role.systemRoleZUID === roleData?.systemRoleZUID, + )?.ZUID; + const alreadyExistingUsers: string[] = roleUsers?.reduce( + (prev, curr) => [...prev, curr.email], + [], + ); + const usersToRemove = alreadyExistingUsers?.filter( + (email) => !userEmails?.includes(email), + ); + const usersToAdd = userEmails?.filter( + (email) => !alreadyExistingUsers.includes(email), + ); + + // TODO: Do something with emails that are not yet instance members + const users = usersWithRoles?.reduce( + (prev, curr) => { + if (usersToAdd?.includes(curr.email)) { + return { + ...prev, + toAdd: [ + ...prev.toAdd, + { + userZUID: curr.ZUID, + oldRoleZUID: curr.role?.ZUID, + newRoleZUID: ZUID, + }, + ], + }; + } + + if (usersToRemove?.includes(curr.email)) { + return { + ...prev, + toRemove: [ + ...prev.toRemove, + { + userZUID: curr.ZUID, + oldRoleZUID: curr.role?.ZUID, + newRoleZUID: baseRoleZUID, + }, + ], + }; + } + + return prev; + }, + { + toAdd: [], + toRemove: [], + }, + ); + + return Promise.all([ + updateUserRole(users.toAdd), + updateUserRole(users.toRemove), + ]); + }; + + const saveDetailsUpdate = () => { + if (!detailsData?.name?.trim()) { + updateFieldErrors({ + tab: 'detailsTab', + data: { + roleName: 'Role name is required', + }, + }); + } else { + return updateRole({ + roleZUID: ZUID, + name: detailsData.name?.replace(/[^\w\s\n]/g, ''), + description: detailsData.description?.replace(/[^\w\s\n]/g, ''), + systemRoleZUID: detailsData.systemRoleZUID, + }); + } + }; + + const handleSave = () => { + setIsSaving(true); + + Promise.all([ + // Update role details + saveDetailsUpdate(), + // Delete a granular role if there's any to delete + ...(!!resourceZUIDsToDelete && [ + deleteGranularRole({ + roleZUID: ZUID, + resourceZUIDs: resourceZUIDsToDelete, + }), + ]), + // Perform all granular role updates + saveGranularRoleUpdates(), + // Save all user updates + saveUsersUpdate(), + ]) + .then((responses) => console.log(responses)) + .catch(() => ErrorMsg({ title: 'Failed to update role' })) + .finally(() => { + getUsersWithRoles(String(instanceZUID)); + getRoles(String(instanceZUID)); + getPermissions(ZUID); + setIsSaving(false); + setResourceZUIDsToDelete([]); + + // Navigate to the tab if that tab has errors + if ( + !!fieldErrors?.detailsTab?.roleName || + !!fieldErrors?.detailsTab?.roleDescription + ) { + setActiveTab('details'); + } else if (!!fieldErrors?.permissionsTab?.length) { + setActiveTab('permissions'); + } else if (!!fieldErrors?.usersTab?.length) { + setActiveTab('users'); + } + }); + }; + + return ( + onClose?.()} + PaperProps={{ + sx: { + maxWidth: 960, + width: 960, + minHeight: 800, + }, + }} + > + + + + + + + + + Edit {roleData?.name} + + + Edit your custom role that can have granular permissions applied + to it + + + + onClose?.()}> + + + + setActiveTab(value)} + sx={{ + position: 'relative', + top: '2px', + px: 2.5, + }} + > + } + iconPosition="start" + /> + } + iconPosition="start" + /> + } + iconPosition="start" + /> + + + + {activeTab === 'details' && ( +
{ + updateDetailsData(data); + + if (data?.name?.length) { + updateFieldErrors({ + tab: 'detailsTab', + data: { + roleName: null, + }, + }); + } + }} + errors={fieldErrors.detailsTab} + /> + )} + {activeTab === 'permissions' && ( + { + setGranularRoles((prev) => [...prev, newRoleData]); + }} + onDeleteGranularRole={(resourceZUID) => { + setResourceZUIDsToDelete((prevState) => { + try { + const resourceZUIDsToDeleteClone = JSON.parse( + JSON.stringify(prevState), + ); + resourceZUIDsToDeleteClone.push(resourceZUID); + + return Array.from(new Set(resourceZUIDsToDeleteClone)); + } catch (err) { + console.error(err); + } + }); + setGranularRoles( + (prevState) => + prevState?.filter( + (role) => role.resourceZUID !== resourceZUID, + ), + ); + }} + onUpdateGranularRole={(updatedRoleData) => { + setGranularRoles((prevState) => { + try { + const granularRolesClone: GranularRole[] = JSON.parse( + JSON.stringify(prevState), + ); + const index = granularRolesClone?.findIndex( + (role) => + role.resourceZUID === updatedRoleData?.resourceZUID, + ); + + if (index === -1) return prevState; + + granularRolesClone[index] = { + ...granularRolesClone[index], + ...updatedRoleData, + }; + + return granularRolesClone; + } catch (err) { + console.error(err); + } + }); + }} + /> + )} + {activeTab === 'users' && ( + setUserEmails(emails)} + /> + )} + + + + + Save + + +
+ ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Details.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Details.tsx new file mode 100644 index 000000000..b84923763 --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Details.tsx @@ -0,0 +1,161 @@ +import { useMemo } from 'react'; +import { + Typography, + Stack, + Box, + TextField, + Autocomplete, + InputLabel, + Tooltip, +} from '@mui/material'; +import { Close, InfoRounded, Check } from '@mui/icons-material'; + +import { useZestyStore } from 'store'; +import { + BASE_ROLE_PERMISSIONS, + PRODUCT_DETAILS, +} from '../../CreateCustomRoleDialog'; +import { RoleDetails } from '../index'; +import { useRoles } from 'store/roles'; + +type DetailsProps = { + data: RoleDetails; + onUpdateData: (data: Partial) => void; + errors: { + roleName: string; + roleDescription: string; + }; +}; +export const Details = ({ data, onUpdateData, errors }: DetailsProps) => { + const { instance } = useZestyStore((state) => state); + const { baseRoles } = useRoles((state) => state); + + const baseRoleOptions = useMemo(() => { + if (!baseRoles?.length) return []; + + return baseRoles?.map((role) => ({ + label: role.name, + value: role.systemRoleZUID, + })); + }, [baseRoles]); + + return ( + + + + Role Name * + + + + + onUpdateData({ name: evt.target.value })} + placeholder="e.g. Lawyer" + fullWidth + error={!!errors?.roleName} + helperText={errors?.roleName} + /> + + + + Role Description + + + + + onUpdateData({ description: evt.target.value })} + placeholder="What is this role going to be used for" + multiline + fullWidth + rows={4} + error={!!errors?.roleDescription} + helperText={errors?.roleDescription} + /> + + + + Base Role + + + + + role.value === data?.systemRoleZUID, + )} + onChange={(_, value) => onUpdateData({ systemRoleZUID: value.value })} + options={baseRoleOptions} + renderInput={(params) => } + /> + + + + {instance?.name} Base Permissions + + + {Object.entries( + BASE_ROLE_PERMISSIONS[data?.systemRoleZUID]?.actions || {}, + )?.map(([name, permission]) => ( + + + {name} + + {!!permission ? ( + + ) : ( + + )} + + ))} + + + + + + Has access to: + + + {Object.entries( + BASE_ROLE_PERMISSIONS[data?.systemRoleZUID]?.products || {}, + )?.map(([product, hasAccess]) => { + if (hasAccess && !!PRODUCT_DETAILS[product]) { + return ( + + {PRODUCT_DETAILS[product]?.icon} + + {PRODUCT_DETAILS[product]?.name} + + + ); + } + })} + + + + + {data?.systemRoleZUID === '31-71cfc74-0wn3r' + ? 'Can delete users' + : 'Cannot delete other users'} + + + + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/AddRule.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/AddRule.tsx new file mode 100644 index 000000000..ee09668e2 --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/AddRule.tsx @@ -0,0 +1,194 @@ +import { Box, Stack, Typography, Checkbox, Button } from '@mui/material'; +import { Check } from '@mui/icons-material'; +import dynamic from 'next/dynamic'; +import { useMemo, useReducer } from 'react'; + +import { NewGranularRole } from './index'; +import { ResourceSelector } from './ResourceSelector'; +import { GranularRole } from 'store/types'; + +const DataGrid = dynamic(() => + import('@mui/x-data-grid').then((e) => e.DataGrid), +); + +type AddRuleProps = { + onAddRuleClick: (data: NewGranularRole) => void; + onCancel: () => void; + granularRoles: Partial[]; +}; +export const AddRule = ({ + onAddRuleClick, + onCancel, + granularRoles, +}: AddRuleProps) => { + const [ruleData, updateRuleData] = useReducer( + (state: NewGranularRole, data: Partial) => { + return { + ...state, + ...data, + }; + }, + { + resourceZUID: '', + create: false, + read: false, + update: false, + delete: false, + publish: false, + }, + ); + + const resourcesToFilter = granularRoles?.map((role) => role.resourceZUID); + + const COLUMNS = useMemo( + () => [ + { + field: 'resourceZUID', + headerName: 'Resource Name', + width: 380, + sortable: false, + renderCell: () => ( + + updateRuleData({ + resourceZUID: zuid, + }) + } + resourcesToFilter={resourcesToFilter} + /> + ), + }, + { + field: 'create', + headerName: 'Create', + sortable: false, + renderCell: () => ( + updateRuleData({ create: evt.target.checked })} + /> + ), + }, + { + field: 'read', + headerName: 'Read', + sortable: false, + renderCell: () => ( + updateRuleData({ read: evt.target.checked })} + /> + ), + }, + { + field: 'update', + headerName: 'Update', + sortable: false, + renderCell: () => ( + updateRuleData({ update: evt.target.checked })} + /> + ), + }, + { + field: 'delete', + headerName: 'Delete', + sortable: false, + renderCell: () => ( + updateRuleData({ delete: evt.target.checked })} + /> + ), + }, + { + field: 'publish', + headerName: 'Publish', + sortable: false, + renderCell: () => ( + updateRuleData({ publish: evt.target.checked })} + /> + ), + }, + ], + [], + ); + const ROWS = useMemo( + () => [ + { + id: 1, + resourceZUID: '', + create: false, + read: false, + update: false, + delete: false, + publish: false, + }, + ], + [], + ); + + return ( + + + Add Rule + + + Assign specific permissions (create, read, update, delete, publish) for + the items of any model + + + + + + + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/Loading.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/Loading.tsx new file mode 100644 index 000000000..0387d77f3 --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/Loading.tsx @@ -0,0 +1,44 @@ +import { Box, Stack, Typography, Skeleton } from '@mui/material'; + +export const Loading = () => { + return ( + + + + Resource Name + + + Create + + + Read + + + Update + + + Delete + + + Publish + + + + + + + + + + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/NoFilterMatches.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/NoFilterMatches.tsx new file mode 100644 index 000000000..55a3bf40a --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/NoFilterMatches.tsx @@ -0,0 +1,60 @@ +import { Typography, Stack, Box } from '@mui/material'; +import { NoSearchResults } from 'components/accounts/ui/NoSearchResults'; + +type NoFilterMatchesProps = { + keyword: string; + onSearchAgain: () => void; +}; +export const NoFilterMatches = ({ + keyword, + onSearchAgain, +}: NoFilterMatchesProps) => { + return ( + + + + Resource Name + + + Create + + + Read + + + Update + + + Delete + + + Publish + + + + + + + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/NoRules.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/NoRules.tsx new file mode 100644 index 000000000..0d445c708 --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/NoRules.tsx @@ -0,0 +1,88 @@ +import { Stack, Typography, Box, Button } from '@mui/material'; +import { AddRounded } from '@mui/icons-material'; + +import dataTable from '../../../../../../../public/assets/images/data_table.svg'; + +type NoRulesProps = { + onAddRulesClick: () => void; +}; +export const NoRules = ({ onAddRulesClick }: NoRulesProps) => { + return ( + + + + Resource Name + + + Create + + + Read + + + Update + + + Delete + + + Publish + + + + + + + Add Rules + + + Assign specific rules (create, read, update, delete, publish) for any + resource + + + + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/ResourceSelector.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/ResourceSelector.tsx new file mode 100644 index 000000000..a32a071d6 --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/ResourceSelector.tsx @@ -0,0 +1,138 @@ +import { useMemo, forwardRef, useState } from 'react'; +import { + TextField, + Autocomplete, + ListItem, + ListItemIcon, + ListItemText, + InputAdornment, +} from '@mui/material'; +import { EditRounded } from '@mui/icons-material'; +import { Database } from '@zesty-io/material'; + +import { useInstance } from 'store/instance'; +import { VirtualizedList } from 'components/accounts/ui/VirtualizedList'; +import { ContentItem } from 'store/types'; + +const VirtualizedListComp = (defaultprops: any, ref) => { + return ; +}; + +const getLangCode = (content: ContentItem) => { + if (!content || !Object.keys(content)?.length) { + return ''; + } + + const { languages } = useInstance.getState(); + + return languages?.find((lang) => lang.ID === content?.meta?.langID)?.code; +}; + +type ResourceSelectorProps = { + onChange: (zuid: string) => void; + initialValue?: string; + resourcesToFilter: string[]; +}; +export const ResourceSelector = ({ + onChange, + initialValue, + resourcesToFilter, +}: ResourceSelectorProps) => { + const { instanceModels, instanceContentItems } = useInstance( + (state) => state, + ); + + const options = useMemo(() => { + if (!instanceModels?.length && !instanceContentItems?.length) return []; + + const models = instanceModels?.map((model) => ({ + label: model.label, + value: model.ZUID, + type: 'model', + sortText: model.label, + })); + const items = instanceContentItems?.map((item) => { + const label = item?.web?.metaTitle || 'Missing Meta Title'; + + return { + label: getLangCode(item) ? `(${getLangCode(item)}) ${label}` : label, + value: item?.meta?.ZUID, + type: 'item', + sortText: label, + }; + }); + + return [...models, ...items].sort( + (a, b) => a?.sortText?.localeCompare(b?.sortText), + ); + }, [instanceModels, instanceContentItems]); + + const filteredOptions = useMemo(() => { + return options?.filter( + (option) => !resourcesToFilter.includes(option.value), + ); + }, [options, resourcesToFilter]); + + const [value, setValue] = useState( + options?.find((option) => option.value === initialValue), + ); + + return ( + ( + + {value?.value?.startsWith('6-') && } + {value?.value?.startsWith('7-') && } + + ), + }} + placeholder="Select Resource" + /> + )} + renderOption={(props, option) => { + return ( + + + {option.type === 'model' ? : } + + + {option.label} + + + ); + }} + onChange={(_, value) => { + onChange(value?.value || ''); + setValue(value); + }} + ListboxComponent={forwardRef(VirtualizedListComp)} + onKeyDown={(evt) => { + evt.stopPropagation(); + }} + /> + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/Table.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/Table.tsx new file mode 100644 index 000000000..2981ba554 --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/Table.tsx @@ -0,0 +1,217 @@ +import { useMemo } from 'react'; +import dynamic from 'next/dynamic'; +import { Checkbox, Typography, IconButton, Tooltip } from '@mui/material'; +import { Database } from '@zesty-io/material'; +import { EditRounded, InfoRounded, DeleteRounded } from '@mui/icons-material'; +import { GridRenderCellParams, GridValueGetterParams } from '@mui/x-data-grid'; + +import { GranularRoleWithResourceName, UpdateGranularRole } from './index'; +import { useInstance } from 'store/instance'; + +const DataGrid = dynamic(() => + import('@mui/x-data-grid').then((e) => e.DataGrid), +); + +type TableProps = { + granularRoles: Partial[]; + onDataChange: (roleData: UpdateGranularRole) => void; + onDelete: (resourceZUID: string) => void; +}; +export const Table = ({ + granularRoles, + onDataChange, + onDelete, +}: TableProps) => { + const { instanceModels, instanceContentItems } = useInstance( + (state) => state, + ); + + const COLUMNS = useMemo( + () => [ + { + field: 'resourceName', + headerName: 'Resource Name', + width: 300, + sortable: false, + renderCell: (params: GridValueGetterParams) => ( + <> + {params.row.id?.startsWith('6-') ? ( + + ) : ( + + )} + + {params.value} + + + ), + }, + { + field: 'create', + headerName: 'Create', + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + onDataChange({ + resourceZUID: params.row?.resourceZUID, + create: evt.target.checked, + }) + } + /> + ), + }, + { + field: 'read', + headerName: 'Read', + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + onDataChange({ + resourceZUID: params.row?.resourceZUID, + read: evt.target.checked, + }) + } + /> + ), + }, + { + field: 'update', + headerName: 'Update', + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + onDataChange({ + resourceZUID: params.row?.resourceZUID, + update: evt.target.checked, + }) + } + /> + ), + }, + { + field: 'delete', + headerName: 'Delete', + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + onDataChange({ + resourceZUID: params.row?.resourceZUID, + delete: evt.target.checked, + }) + } + /> + ), + }, + { + field: 'publish', + headerName: 'Publish', + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + onDataChange({ + resourceZUID: params.row?.resourceZUID, + publish: evt.target.checked, + }) + } + /> + ), + }, + { + field: 'actions', + headerName: '', + sortable: false, + renderCell: (params: GridRenderCellParams) => { + const title = `Name: ${params.row?.resourceName} \n ${ + params.value?.startsWith('6-') + ? 'Model' + : !!params.value?.startsWith('7-') + ? 'Item' + : '' + } ZUID: ${params.value}`; + + return ( + <> + + + + onDelete(params.value)} + > + + + + ); + }, + }, + ], + [instanceModels, instanceContentItems], + ); + const rows = granularRoles?.map((role) => { + return { + id: role.resourceZUID, + resourceName: role.resourceName, + create: role.create, + read: role.read, + update: role.update, + delete: role.delete, + publish: role.publish, + actions: role.resourceZUID, + }; + }); + + return ( + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/index.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/index.tsx new file mode 100644 index 000000000..bc083ba8f --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Permissions/index.tsx @@ -0,0 +1,164 @@ +import { useRef, useState, useDeferredValue, useMemo, useEffect } from 'react'; +import { + Box, + Stack, + Typography, + TextField, + Button, + InputAdornment, +} from '@mui/material'; +import { Search, AddRounded } from '@mui/icons-material'; + +import { NoRules } from './NoRules'; +import { GranularRole } from 'store/types'; +import { AddRule } from './AddRule'; +import { Table } from './Table'; +import { useInstance } from 'store/instance'; +import { NoFilterMatches } from './NoFilterMatches'; + +export type GranularRoleWithResourceName = GranularRole & { + resourceName: string; +}; +export type UpdateGranularRole = Pick & + Partial>; +export type NewGranularRole = Pick< + GranularRole, + 'resourceZUID' | 'create' | 'read' | 'update' | 'delete' | 'publish' +>; +type PermissionsProps = { + granularRoles: Partial[]; + onAddNewGranularRole: (roleData: NewGranularRole) => void; + onUpdateGranularRole: (roleData: UpdateGranularRole) => void; + onDeleteGranularRole: (resourceZUID: string) => void; +}; +export const Permissions = ({ + granularRoles, + onAddNewGranularRole, + onUpdateGranularRole, + onDeleteGranularRole, +}: PermissionsProps) => { + const { instanceModels, instanceContentItems, languages } = useInstance( + (state) => state, + ); + const [filterKeyword, setFilterKeyword] = useState(''); + const [showAddRule, setShowAddRule] = useState(false); + const deferredFilterKeyword = useDeferredValue(filterKeyword); + const searchFieldRef = useRef(null); + const addGranularRoleRef = useRef(null); + + const resolveResourceZUID = (zuid: string) => { + if (zuid?.startsWith('6-')) { + return ( + instanceModels?.find((model) => model.ZUID === zuid)?.label || zuid + ); + } else if (zuid?.startsWith('7-')) { + const contentItem = instanceContentItems?.find( + (item) => item.meta.ZUID === zuid, + ); + const name = contentItem?.web?.metaTitle || zuid; + const langCode = languages?.find( + (lang) => lang.ID === contentItem?.meta?.langID, + )?.code; + + return langCode ? `(${langCode}) ${name}` : name; + } else { + return zuid; + } + }; + + const granularRolesWithResourceNames = useMemo(() => { + return granularRoles?.map((role) => ({ + ...role, + resourceName: resolveResourceZUID(role.resourceZUID), + })); + }, [granularRoles]); + + const filteredGranularRoles = useMemo(() => { + if (!deferredFilterKeyword) return granularRolesWithResourceNames; + + return granularRolesWithResourceNames?.filter( + (role) => + role.resourceName + ?.toLowerCase() + ?.includes(deferredFilterKeyword.toLowerCase()), + ); + }, [granularRolesWithResourceNames, deferredFilterKeyword]); + + useEffect(() => { + if (showAddRule) { + setTimeout(() => { + addGranularRoleRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 300); + } + }, [showAddRule, addGranularRoleRef]); + + return ( + + + + + Resource Permissions + + + Grant users access only to resources you specify + + + + setFilterKeyword(evt.target.value)} + size="small" + placeholder="Filter Resources" + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + {!granularRoles?.length && !showAddRule && !deferredFilterKeyword && ( + setShowAddRule(true)} /> + )} + {!!filteredGranularRoles?.length && ( + onUpdateGranularRole(roleData)} + onDelete={onDeleteGranularRole} + /> + )} + {!filteredGranularRoles?.length && !!deferredFilterKeyword && ( + { + setFilterKeyword(''); + searchFieldRef.current?.querySelector('input')?.focus(); + }} + /> + )} + {showAddRule && ( + + setShowAddRule(false)} + onAddRuleClick={(newRoleData) => { + onAddNewGranularRole(newRoleData); + setShowAddRule(false); + }} + granularRoles={granularRoles} + /> + + )} + + ); +}; diff --git a/src/components/accounts/roles/EditCustomRoleDialog/tabs/Users.tsx b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Users.tsx new file mode 100644 index 000000000..0a2ba0b1b --- /dev/null +++ b/src/components/accounts/roles/EditCustomRoleDialog/tabs/Users.tsx @@ -0,0 +1,189 @@ +import { useState, useRef, useMemo } from 'react'; +import { + Chip, + Box, + Stack, + InputLabel, + Tooltip, + TextField, + Autocomplete, + Typography, +} from '@mui/material'; +import { InfoRounded } from '@mui/icons-material'; +import { useRoles } from 'store/roles'; + +const emailAddressRegexp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + +type UsersProps = { + userEmails: string[]; + onUpdateUserEmails: (emails: string[]) => void; +}; +export const Users = ({ userEmails, onUpdateUserEmails }: UsersProps) => { + const { usersWithRoles } = useRoles((state) => state); + const [inputValue, setInputValue] = useState(''); + const [emailError, setEmailError] = useState(false); + const emailChipsRef = useRef([]); + const autocompleteRef = useRef(null); + + const nonInstanceMembers = useMemo(() => { + if (!usersWithRoles?.length || !userEmails?.length) return []; + const instanceUserEmails = usersWithRoles?.map((user) => user.email); + + return userEmails?.filter((email) => !instanceUserEmails?.includes(email)); + }, [userEmails, usersWithRoles]); + + return ( + + + Users + + + + + ( + { + setEmailError(false); + if ( + event.key === 'Enter' || + event.key === ',' || + event.key === ' ' + ) { + if (inputValue && !inputValue.match(/^\s+$/)) { + if (inputValue.trim().match(emailAddressRegexp)) { + event.preventDefault(); + onUpdateUserEmails( + Array.from( + new Set([ + ...userEmails, + inputValue?.toLowerCase()?.trim(), + ]), + ), + ); + setInputValue(''); + } else { + setEmailError(true); + } + } else { + setEmailError(false); + } + } + + if (event.key === 'Backspace' && !inputValue) { + event.stopPropagation(); + + // HACK: Needed to prevent the default behavior of autocomplete which autodeletes the right most tag on backspace + setTimeout(() => { + emailChipsRef.current?.[ + emailChipsRef.current?.filter((ref) => !!ref)?.length - 1 + ]?.focus({ visible: true }); + }, 100); + } + }} + onChange={(event) => { + if (event.target.value?.split('').pop() === ',') return; + + // Handle pasted value if it contains comma or space separated emails + const pastedValue = event.target?.value.replaceAll(',', ' '); + + if (pastedValue.includes(' ')) { + event.preventDefault(); + const validEmails: string[] = []; + const invalidEmails: string[] = []; + + pastedValue.split(' ').forEach((email) => { + if (email) { + if (email.match(emailAddressRegexp)) { + validEmails.push(email); + } else { + invalidEmails.push(email); + } + } + }); + + onUpdateUserEmails( + Array.from(new Set([...userEmails, ...validEmails])), + ); + + if (invalidEmails?.length) { + setEmailError(true); + setInputValue(invalidEmails.join(', ')); + } + } else { + setInputValue(event.target.value); + } + }} + value={inputValue} + /> + )} + renderTags={(tagValue, getTagProps) => + userEmails.map((email, index) => ( + (emailChipsRef.current[index] = el)} + size="small" + color="default" + clickable={false} + sx={{ + backgroundColor: 'common.white', + borderColor: 'grey.300', + borderWidth: 1, + borderStyle: 'solid', + }} + label={email} + onDelete={(evt) => { + if (evt.type === 'click') { + onUpdateUserEmails(userEmails.filter((_, i) => i !== index)); + } + }} + onKeyDown={(event) => { + if (event.key === 'Backspace') { + // HACK: Needed to override the default behavior of autocomplete where it automatically selects the next tag after deleting a diff tag via backspace + setTimeout(() => { + onUpdateUserEmails( + userEmails.filter((_, i) => i !== index), + ); + autocompleteRef.current?.querySelector('input')?.focus(); + }, 150); + } + }} + /> + )) + } + /> + {!!nonInstanceMembers?.length && ( + + These users are not part of this instance:{' '} + {nonInstanceMembers?.join(', ')}. To assign them a custom role, first + invite them with a system role. Once added, you can return to assign + them a custom role. + + )} + + ); +}; diff --git a/src/components/accounts/roles/NoCustomRoles.tsx b/src/components/accounts/roles/NoCustomRoles.tsx new file mode 100644 index 000000000..bef4d03e9 --- /dev/null +++ b/src/components/accounts/roles/NoCustomRoles.tsx @@ -0,0 +1,55 @@ +import { Box, Stack, Typography, Button } from '@mui/material'; +import { Add } from '@mui/icons-material'; + +import addUser from '../../../../public/assets/images/add_user.svg'; + +type NoCustomRolesProps = { + onCreateCustomRoleClick: () => void; +}; +export const NoCustomRoles = ({ + onCreateCustomRoleClick, +}: NoCustomRolesProps) => { + return ( + + + Custom Roles + + + + + + + Create your first Custom Role + + + Custom roles allow you to tailor permissions to fit specific needs. + Click the button below to define a role name, set permissions, and + assign users. + + + + + + ); +}; diff --git a/src/components/accounts/ui/NoSearchResults.tsx b/src/components/accounts/ui/NoSearchResults.tsx new file mode 100644 index 000000000..56777302b --- /dev/null +++ b/src/components/accounts/ui/NoSearchResults.tsx @@ -0,0 +1,56 @@ +import { Stack, Typography, Button, Box } from '@mui/material'; +import { SearchRounded } from '@mui/icons-material'; + +import noSearchResults from '../../../../public/assets/images/no_search_results.svg'; + +type NoSearchResultsProps = { + onSearchAgain: () => void; + keyword: string; +}; +export const NoSearchResults = ({ + onSearchAgain, + keyword, +}: NoSearchResultsProps) => { + return ( + + + + + Your filter{' '} + + "{keyword}" + {' '} + could not find any results + + + Try adjusting your search. We suggest check all words are spelled + correctly or try using different keywords. + + + + + ); +}; diff --git a/src/components/accounts/ui/VirtualizedList.tsx b/src/components/accounts/ui/VirtualizedList.tsx new file mode 100644 index 000000000..c2d81ee98 --- /dev/null +++ b/src/components/accounts/ui/VirtualizedList.tsx @@ -0,0 +1,58 @@ +import { + useRef, + useEffect, + createContext, + forwardRef, + useContext, +} from 'react'; +import { VariableSizeList } from 'react-window'; + +const Row = ({ data, index, style }) => { + const elem = data[index]; + return ( +
+ <>{elem} +
+ ); +}; + +const useResetCache = (data: any) => { + const ref = useRef(null); + useEffect(() => { + if (ref.current !== null) { + ref.current.resetAfterIndex(0, true); + } + }, [data]); + return ref; +}; + +const OuterElementContext = createContext({}); +const OuterElementType = forwardRef((props, ref) => { + const outerProps = useContext(OuterElementContext); + return
; +}); +export const VirtualizedList = forwardRef((props: any, ref: any) => { + const itemCount = props.children.length; + const gridRef = useResetCache(itemCount); + const outerProps = { ...props }; + delete outerProps.children; + return ( +
+ + props.rowheight} + overscanCount={5} + itemData={{ ...props.children }} + > + {Row} + + +
+ ); +}); diff --git a/src/components/accounts/ui/dialogs/index.js b/src/components/accounts/ui/dialogs/index.js index 4fbf5bd8d..41df76df5 100644 --- a/src/components/accounts/ui/dialogs/index.js +++ b/src/components/accounts/ui/dialogs/index.js @@ -17,6 +17,9 @@ export const SuccessMsg = ({ title, timer: 2500, confirmButtonColor: light.primary.main, + customClass: { + container: 'swal-zindex-override', + }, }).then(() => action()); }; @@ -36,6 +39,9 @@ export const ErrorMsg = ({ confirmButtonColor: light.zesty.zestyRose, timer, timerProgressBar, + customClass: { + container: 'swal-zindex-override', + }, }); }; @@ -85,6 +91,9 @@ export const DeleteMsg = ({ confirmButtonText: 'Yes', confirmButtonColor: '#3085d6', cancelButtonColor: '#d33', + customClass: { + container: 'swal-zindex-override', + }, }).then((result) => { if (result.isConfirmed) { action(); diff --git a/src/components/accounts/ui/header/index.js b/src/components/accounts/ui/header/index.js index a9edf6533..9ff68ffb6 100644 --- a/src/components/accounts/ui/header/index.js +++ b/src/components/accounts/ui/header/index.js @@ -1,39 +1,43 @@ import React from 'react'; -import { Grid, Stack, Typography } from '@mui/material'; +import { Grid, Stack, Typography, ThemeProvider } from '@mui/material'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { theme } from '@zesty-io/material'; const Index = ({ title, description, info, children }) => { return ( - - - - - - {title} - - + + + + + + + {title} + + + + + + {description} + + - - - {description} - + + {children} - - {children} - - - + + ); }; export const AccountsHeader = React.memo(Index); diff --git a/src/components/globals/NoPermission.jsx b/src/components/globals/NoPermission.jsx new file mode 100644 index 000000000..a64fb533c --- /dev/null +++ b/src/components/globals/NoPermission.jsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { + Stack, + Box, + Typography, + Avatar, + List, + ListItem, + ListItemText, + ListItemAvatar, +} from '@mui/material'; + +import { hashMD5 } from 'utils/Md5Hash'; +import shield from '../../../public/assets/images/shield.svg'; + +export const NoPermission = ({ users }) => { + const ownersAndAdmins = useMemo(() => { + if (!users && !users?.length) return []; + + const owners = users + .filter((user) => user.role?.name?.toLowerCase() === 'owner') + .sort((a, b) => a.firstName.localeCompare(b.firstName)); + const admins = users + .filter((user) => user.role?.name?.toLowerCase() === 'admin') + .sort((a, b) => a.firstName.localeCompare(b.firstName)); + + return [...owners, ...admins]; + }, [users]); + + return ( + + + + You need permission to view and edit Roles & Permissions + + + Contact the instance owner or administrators listed below to upgrade + your role to Admin or Owner for this capability. + + + {ownersAndAdmins?.map((user) => ( + + + + + + + ))} + + + + + ); +}; diff --git a/src/mui.d.ts b/src/mui.d.ts new file mode 100644 index 000000000..9af9d5043 --- /dev/null +++ b/src/mui.d.ts @@ -0,0 +1,27 @@ +import { Color } from '@mui/material'; + +declare module '@mui/material/Typography' { + export interface TypographyPropsVariantOverrides { + body3: true; + } +} + +declare module '@mui/material/styles' { + export interface Palette { + red: Color; + deepPurple: Color; + deepOrange: Color; + pink: Color; + blue: Color; + green: Color; + purple: Color; + yellow: Color; + } +} + +declare module '@mui/material/IconButton' { + interface IconButtonPropsSizeOverrides { + xsmall: true; + xxsmall: true; + } +} diff --git a/src/pages/instances/[zuid]/roles.tsx b/src/pages/instances/[zuid]/roles.tsx new file mode 100644 index 000000000..729c25f6c --- /dev/null +++ b/src/pages/instances/[zuid]/roles.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useRouter } from 'next/router'; + +import { useZestyStore } from 'store'; +import { useRoles } from 'store/roles'; +import { useInstance } from 'store/instance'; +import InstanceContainer from 'components/accounts/instances/InstanceContainer'; +import { Roles } from 'views/accounts'; +import { ErrorMsg } from 'components/accounts'; + +export { default as getServerSideProps } from 'lib/accounts/protectedRouteGetServerSideProps'; + +export default function RolesPage() { + const router = useRouter(); + const { userInfo, loading } = useZestyStore((state) => state); + const { usersWithRoles, getRoles, getUsersWithRoles } = useRoles( + (state) => state, + ); + const { getInstanceModels, getInstanceContentItems, getLanguages } = + useInstance((state) => state); + const [isInitializingData, setIsInitializingData] = useState(true); + + const { zuid } = router.query; + + const hasPermission = useMemo(() => { + if (!userInfo?.ZUID || !usersWithRoles?.length) return false; + + return ['admin', 'owner'].includes( + usersWithRoles + ?.find((user) => user.ZUID === userInfo?.ZUID) + ?.role?.name?.toLowerCase(), + ); + }, [userInfo, usersWithRoles]); + + useEffect(() => { + if (router.isReady) { + const instanceZUID = String(zuid); + + Promise.all([ + getUsersWithRoles(instanceZUID), + getRoles(instanceZUID), + getInstanceModels(), + getInstanceContentItems(), + getLanguages('all'), + ]) + .catch(() => ErrorMsg({ title: 'Failed to fetch page data' })) + .finally(() => setIsInitializingData(false)); + } + }, [router.isReady]); + + return ( + + + + ); +} diff --git a/src/store/instance.ts b/src/store/instance.ts new file mode 100644 index 000000000..606a6294f --- /dev/null +++ b/src/store/instance.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { ContentItem, ContentModel, Language } from './types'; +import { getZestyAPI } from 'store'; + +const ZestyAPI = getZestyAPI(); + +type InstanceState = { + instanceModels: ContentModel[]; + instanceContentItems: ContentItem[]; + languages: Language[]; +}; +type InstanceAction = { + getInstanceModels: () => Promise; + getInstanceContentItems: () => Promise; + getLanguages: (type: 'all' | 'active') => Promise; +}; + +export const useInstance = create((set) => ({ + instanceModels: [], + getInstanceModels: async () => { + const response = await ZestyAPI.getModels(); + + if (response.error) { + console.error('getInstanceModels error: ', response.error); + throw new Error(response.error); + } else { + set({ + instanceModels: response.data, + }); + } + }, + + instanceContentItems: [], + getInstanceContentItems: async () => { + const response = await ZestyAPI.searchItems(); + + if (response.error) { + console.error('getInstanceContentItems error: ', response.error); + throw new Error(response.error); + } else { + set({ + instanceContentItems: response.data, + }); + } + }, + + languages: [], + getLanguages: async (type) => { + const response = await ZestyAPI.getLocales(type); + + if (response.error) { + console.error('getLanguages error: ', response.error); + throw new Error(response.error); + } else { + set({ languages: response.data }); + } + }, +})); diff --git a/src/store/roles.ts b/src/store/roles.ts new file mode 100644 index 000000000..25c31e5a6 --- /dev/null +++ b/src/store/roles.ts @@ -0,0 +1,244 @@ +import { create } from 'zustand'; + +import { UserRole, Role, GranularRole, RoleWithSort } from './types'; +import { getZestyAPI } from 'store'; +import { RoleDetails } from 'components/accounts/roles/CreateCustomRoleDialog'; +import { NewGranularRole } from 'components/accounts/roles/EditCustomRoleDialog/tabs/Permissions'; + +const BASE_ROLE_SORT_ORDER = [ + '31-71cfc74-0wn3r', + '31-71cfc74-4dm13', + '31-71cfc74-4cc4dm13', + '31-71cfc74-d3v3l0p3r', + '31-71cfc74-d3vc0n', + '31-71cfc74-s30', + '31-71cfc74-p0bl1shr', + '31-71cfc74-c0ntr1b0t0r', +] as const; + +const ZestyAPI = getZestyAPI(); + +type RolesState = { + usersWithRoles: UserRole[]; + baseRoles: RoleWithSort[]; + customRoles: Role[]; +}; +type RolesAction = { + getUsersWithRoles: (instanceZUID: string) => Promise; + updateUserRole: ( + data: { userZUID: string; oldRoleZUID: string; newRoleZUID: string }[], + ) => Promise; + getRoles: (instanceZUID: string) => Promise; + createRole: (data: RoleDetails & { instanceZUID: string }) => Promise; + updateRole: ({ + roleZUID, + name, + description, + systemRoleZUID, + }: { + roleZUID: string; + name: string; + description: string; + systemRoleZUID: string; + }) => Promise; + deleteRole: (data: { + roleZUIDToDelete: string; + roleZUIDToTransferUsers: string; + }) => Promise; + createGranularRole: ({ + roleZUID, + data, + }: { + roleZUID: string; + data: NewGranularRole & { name: string }; + }) => Promise; + updateGranularRole: ({ + roleZUID, + granularRoles, + }: { + roleZUID: string; + granularRoles: Partial[]; + }) => Promise; + deleteGranularRole: ({ + roleZUID, + resourceZUIDs, + }: { + roleZUID: string; + resourceZUIDs: string[]; + }) => Promise; +}; + +export const useRoles = create((set) => ({ + usersWithRoles: [], + getUsersWithRoles: async (instanceZUID) => { + const response = await ZestyAPI.getInstanceUsersWithRoles(instanceZUID); + + if (response.error) { + console.error('getUsersWithRoles error: ', response.error); + throw new Error(response.error); + } else { + set({ usersWithRoles: response.data }); + return response.data; + } + }, + updateUserRole: async (data) => { + if (!data?.length) return; + + Promise.all([ + data?.forEach(({ userZUID, oldRoleZUID, newRoleZUID }) => + ZestyAPI.updateUserRole(userZUID, oldRoleZUID, newRoleZUID), + ), + ]) + .then((response) => response) + .catch((error) => { + console.error('updateUserRole error: ', error); + throw new Error(error); + }); + }, + + baseRoles: [], + customRoles: [], + getRoles: async (instanceZUID) => { + const response = await ZestyAPI.getInstanceRoles(instanceZUID); + + if (response.error) { + console.error('getRoles error: ', response.error); + throw new Error(response.error); + } else { + const _baseRoles: RoleWithSort[] = []; + const _customRoles: Role[] = []; + + // Separate base roles from custom roles + response.data?.forEach((role: Role) => { + if (role.static) { + _baseRoles.push({ + ...role, + sort: BASE_ROLE_SORT_ORDER.findIndex( + (systemRoleZUID) => systemRoleZUID === role.systemRoleZUID, + ), + }); + } else { + _customRoles.push(role); + } + }); + + set({ + baseRoles: _baseRoles.sort((a, b) => a.sort - b.sort), + customRoles: _customRoles, + }); + } + }, + createRole: async ({ name, description, systemRoleZUID, instanceZUID }) => { + if (!name && !systemRoleZUID) return; + + const res = await ZestyAPI.createRole( + name, + instanceZUID, + systemRoleZUID, + description, + ); + + if (res.error) { + console.error('Failed to create role: ', res.error); + throw new Error(res.error); + } else { + return res.data; + } + }, + updateRole: async ({ roleZUID, name, description, systemRoleZUID }) => { + if (!roleZUID || !name) return; + + const res = await ZestyAPI.updateRole(roleZUID, { + name, + description, + systemRoleZUID, + }); + + if (res.error) { + console.error('Failed to update role: ', res.error); + throw new Error(res.error); + } else { + return res.data; + } + }, + deleteRole: async ({ roleZUIDToDelete, roleZUIDToTransferUsers }) => { + if (!roleZUIDToDelete || !roleZUIDToTransferUsers) return; + + // Transfer the existing users to a new role + const transferResponse = await ZestyAPI.bulkReassignUsersRole({ + oldRoleZUID: roleZUIDToDelete, + newRoleZUID: roleZUIDToTransferUsers, + }); + + if (transferResponse.error) { + console.error('Failed to reassign users role: ', transferResponse.error); + throw new Error(transferResponse.error); + } else { + // Once users have been reassigned, delete the role + const deleteRoleResponse = await ZestyAPI.deleteRole(roleZUIDToDelete); + + if (deleteRoleResponse.error) { + console.error( + `Failed to delete role ${roleZUIDToDelete}: `, + transferResponse.error, + ); + throw new Error(transferResponse.error); + } else { + return deleteRoleResponse.data; + } + } + }, + + createGranularRole: async ({ roleZUID, data }) => { + if (!roleZUID || !data || !Object.keys(data)?.length) return; + + const res = await ZestyAPI.createGranularRole( + roleZUID, + data.resourceZUID, + data.create, + data.read, + data.update, + data.delete, + data.publish, + ); + + if (res.error) { + console.error('Failed to update role: ', res.error); + throw new Error(res.error); + } else { + return res.data; + } + }, + updateGranularRole: async ({ roleZUID, granularRoles }) => { + if (!roleZUID || !granularRoles) return; + + const res = await ZestyAPI.batchUpdateGranularRoles( + roleZUID, + granularRoles, + ); + + if (res.error) { + console.error('Failed to update granular role: ', res.error); + throw new Error(res.error); + } else { + return res.data; + } + }, + deleteGranularRole: async ({ roleZUID, resourceZUIDs }) => { + if (!roleZUID || !resourceZUIDs || !resourceZUIDs?.length) return; + + Promise.all([ + resourceZUIDs.forEach((zuid) => + ZestyAPI.deleteGranularRole(roleZUID, zuid), + ), + ]) + .then((res) => { + console.log('delete response', res); + return res; + }) + .catch((err) => { + console.error('Failed to delete granular role: ', err); + throw new Error(err); + }); + }, +})); diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 000000000..b0e45f4a6 --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,134 @@ +export type UserRole = { + ID: number; + ZUID: string; + authSource: string | null; + authyEnabled?: boolean; + authyPhoneCountryCode: string | null; + authyPhoneNumber: string | null; + authyUserID: string | null; + createdAt: string; + email: string; + firstName: string; + lastLogin: string; + lastName: string; + prefs: string | null; + role: Role; + signupInfo: string | null; + staff: boolean; + unverifiedEmails: string | null; + updatedAt: string; + verifiedEmails: string | null; + websiteCreator: boolean; +}; + +export type Role = { + ZUID: string; + createdAt: string; + createdByUserZUID: string; + entityZUID: string; + expiry: string | null; + granularRoleZUID: string | null; + granularRoles: GranularRole[] | null; + name: string; + static: boolean; + systemRole: SystemRole; + systemRoleZUID: string; + updatedAt: string; + description?: string; +}; + +export type RoleWithSort = Role & { sort?: number }; + +export type SystemRole = { + ZUID: string; + create: boolean; + createdAt: string; + delete: boolean; + grant: boolean; + name: string; + publish: boolean; + read: boolean; + super: boolean; + update: boolean; + updatedAt: string; +}; + +export type GranularRole = SystemRole & { resourceZUID: string }; + +export type ContentModel = { + ZUID: string; + masterZUID: string; + parentZUID: string; + description: string; + label: string; + metaTitle?: any; + metaDescription?: any; + metaKeywords?: any; + type: ModelType; + name: string; + sort: number; + listed: boolean; + createdByUserZUID: string; + updatedByUserZUID: string; + createdAt: string; + updatedAt: string; + module?: number; + plugin?: number; +}; + +export type ModelType = 'pageset' | 'templateset' | 'dataset'; + +export type ContentItem = { + web: Web; + meta: Meta; + siblings: [{ [key: number]: { value: string; id: number } }] | []; + data: Data; + publishAt?: any; +}; + +export type Web = { + version: number; + versionZUID: string; + metaDescription: string; + metaTitle: string; + metaLinkText: string; + metaKeywords?: any; + parentZUID?: any; + pathPart: string; + path: string; + sitemapPriority: number; + canonicalTagMode: number; + canonicalQueryParamWhitelist?: any; + canonicalTagCustomValue?: any; + createdByUserZUID: string; + createdAt: string; + updatedAt: string; +}; + +export type Meta = { + ZUID: string; + zid: number; + masterZUID: string; + contentModelZUID: string; + sort: number; + listed: boolean; + version: number; + langID: number; + createdAt: string; + updatedAt: string; + createdByUserZUID: string; +}; + +export type Data = { + [key: string]: number | string | null | undefined; +}; + +export type Language = { + ID: number; + code: string; + name: string; + default: boolean; + active: boolean; + createdAt: string; + updatedAt: string; +}; diff --git a/src/views/accounts/instances/Roles.tsx b/src/views/accounts/instances/Roles.tsx new file mode 100644 index 000000000..e55fa3609 --- /dev/null +++ b/src/views/accounts/instances/Roles.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState, useRef, useDeferredValue } from 'react'; +import { + Button, + TextField, + Stack, + InputAdornment, + ThemeProvider, + CircularProgress, +} from '@mui/material'; +import { Search, AddRounded } from '@mui/icons-material'; +import { theme } from '@zesty-io/material'; + +import { useRoles } from 'store/roles'; +import { AccountsHeader } from 'components/accounts'; +import { NoPermission } from 'components/globals/NoPermission'; +import { BaseRoles } from 'components/accounts/roles/BaseRoles'; +import { NoCustomRoles } from 'components/accounts/roles/NoCustomRoles'; +import { CustomRoles } from 'components/accounts/roles/CustomRoles'; +import { CreateCustomRoleDialog } from 'components/accounts/roles/CreateCustomRoleDialog'; +import { NoSearchResults } from 'components/accounts/ui/NoSearchResults'; + +type RolesProps = { + isLoading: boolean; + hasPermission: boolean; +}; +export const Roles = ({ isLoading, hasPermission }: RolesProps) => { + const { usersWithRoles, customRoles, baseRoles } = useRoles((state) => state); + const customRolesRef = useRef(null); + const searchFieldRef = useRef(null); + const [isCreateCustomRoleDialogOpen, setIsCreateCustomRoleDialogOpen] = + useState(false); + const [filterKeyword, setFilterKeyword] = useState(''); + const deferredFilterKeyword = useDeferredValue(filterKeyword); + + const filteredRoles = useMemo(() => { + const keyword = deferredFilterKeyword?.toLowerCase(); + + if (!keyword) { + return { + baseRoles, + customRoles, + }; + } + + return { + baseRoles: baseRoles?.filter((role) => + role.name.toLowerCase().includes(keyword), + ), + customRoles: customRoles?.filter((role) => + role.name.toLowerCase().includes(keyword), + ), + }; + }, [baseRoles, customRoles, deferredFilterKeyword]); + + if (isLoading) { + return ( + + + {/* @ts-expect-error untyped component */} + + + + + + + ); + } + + if (!hasPermission) { + return ( + + + {/* @ts-expect-error untyped component */} + + + + + + + ); + } + + return ( + + + + + setFilterKeyword(evt.target.value)} + ref={searchFieldRef} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + {!filteredRoles?.customRoles?.length && + !filteredRoles?.baseRoles?.length && + deferredFilterKeyword ? ( + { + setFilterKeyword(''); + searchFieldRef.current?.querySelector('input')?.focus(); + }} + /> + ) : ( + <> + {filteredRoles?.customRoles?.length || + (!filteredRoles?.customRoles?.length && + !!deferredFilterKeyword) ? ( + + ) : ( + + setIsCreateCustomRoleDialogOpen(true) + } + /> + )} + + + )} + + + {isCreateCustomRoleDialogOpen && ( + setIsCreateCustomRoleDialogOpen(false)} + onRoleCreated={(ZUID) => + customRolesRef.current?.updateZUIDToEdit?.(ZUID) + } + /> + )} + + ); +}; diff --git a/src/views/accounts/instances/index.js b/src/views/accounts/instances/index.js index 06f20b328..00845c59b 100644 --- a/src/views/accounts/instances/index.js +++ b/src/views/accounts/instances/index.js @@ -5,3 +5,4 @@ export * from './Apis'; export * from './Webhooks'; export * from './Overview'; export * from './Usage'; +export * from './Roles'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..01c2da4b5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "baseUrl": "src" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src"], + "exclude": ["node_modules"] +}