diff --git a/package-lock.json b/package-lock.json index 7b80cea..311ea19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wlan-cloud-owprov-ui", - "version": "2.11.0(37)", + "version": "2.11.0(56)", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "wlan-cloud-owprov-ui", - "version": "2.11.0(37)", + "version": "2.11.0(56)", "license": "ISC", "dependencies": { "@chakra-ui/anatomy": "^2.1.1", @@ -30,6 +30,7 @@ "buffer": "^6.0.3", "chakra-react-select": "^4.6.0", "chart.js": "^4.4.0", + "country-state-city": "^3.2.0", "cronstrue": "2.26.0", "currency-codes": "^2.1.0", "dagre": "^0.8.5", @@ -132,11 +133,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", - "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dependencies": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" @@ -182,12 +184,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", - "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.21.5", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -312,34 +314,34 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", - "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", - "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.20.7", - "@babel/types": "^7.21.0" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -468,29 +470,29 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", - "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } @@ -534,12 +536,12 @@ } }, "node_modules/@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -547,9 +549,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.21.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.8.tgz", - "integrity": "sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -1717,33 +1719,33 @@ } }, "node_modules/@babel/template": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", - "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", - "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.21.4", - "@babel/generator": "^7.21.5", - "@babel/helper-environment-visitor": "^7.21.5", - "@babel/helper-function-name": "^7.21.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.21.5", - "@babel/types": "^7.21.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -1752,12 +1754,12 @@ } }, "node_modules/@babel/types": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz", - "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dependencies": { - "@babel/helper-string-parser": "^7.21.5", - "@babel/helper-validator-identifier": "^7.19.1", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -5591,7 +5593,7 @@ "node_modules/chalk/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "engines": { "node": ">=0.8.0" } @@ -5701,7 +5703,7 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/colorette": { "version": "2.0.20", @@ -5819,6 +5821,11 @@ "node": ">=10" } }, + "node_modules/country-state-city": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz", + "integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA==" + }, "node_modules/cronstrue": { "version": "2.26.0", "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.26.0.tgz", @@ -7664,7 +7671,7 @@ "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "engines": { "node": ">=4" } @@ -9302,9 +9309,9 @@ } }, "node_modules/postcss": { - "version": "8.4.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", - "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -11938,11 +11945,12 @@ } }, "@babel/code-frame": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", - "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "requires": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" } }, "@babel/compat-data": { @@ -11975,12 +11983,12 @@ } }, "@babel/generator": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", - "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "requires": { - "@babel/types": "^7.21.5", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -12077,28 +12085,28 @@ } }, "@babel/helper-environment-visitor": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", - "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true }, "@babel/helper-function-name": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", - "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "requires": { - "@babel/template": "^7.20.7", - "@babel/types": "^7.21.0" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" } }, "@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "requires": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" } }, "@babel/helper-member-expression-to-functions": { @@ -12194,23 +12202,23 @@ } }, "@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "requires": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" } }, "@babel/helper-string-parser": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", - "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==" + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==" }, "@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==" + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" }, "@babel/helper-validator-option": { "version": "7.21.0", @@ -12242,19 +12250,19 @@ } }, "@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "requires": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" } }, "@babel/parser": { - "version": "7.21.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.8.tgz", - "integrity": "sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true }, "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { @@ -13035,41 +13043,41 @@ } }, "@babel/template": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", - "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "requires": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" } }, "@babel/traverse": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", - "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "requires": { - "@babel/code-frame": "^7.21.4", - "@babel/generator": "^7.21.5", - "@babel/helper-environment-visitor": "^7.21.5", - "@babel/helper-function-name": "^7.21.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.21.5", - "@babel/types": "^7.21.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz", - "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "requires": { - "@babel/helper-string-parser": "^7.21.5", - "@babel/helper-validator-identifier": "^7.19.1", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, @@ -15844,7 +15852,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" } } }, @@ -15934,7 +15942,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "colorette": { "version": "2.0.20", @@ -16026,6 +16034,11 @@ "yaml": "^1.10.0" } }, + "country-state-city": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz", + "integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA==" + }, "cronstrue": { "version": "2.26.0", "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.26.0.tgz", @@ -17413,7 +17426,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "has-property-descriptors": { "version": "1.0.0", @@ -18576,9 +18589,9 @@ "dev": true }, "postcss": { - "version": "8.4.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", - "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "requires": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", diff --git a/package.json b/package.json index cefbaba..52ee907 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wlan-cloud-owprov-ui", - "version": "2.11.0(37)", + "version": "2.11.0(56)", "description": "", "main": "index.tsx", "scripts": { @@ -35,6 +35,7 @@ "buffer": "^6.0.3", "chakra-react-select": "^4.6.0", "chart.js": "^4.4.0", + "country-state-city": "^3.2.0", "cronstrue": "2.26.0", "currency-codes": "^2.1.0", "dagre": "^0.8.5", diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 4d33546..e73970a 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -906,6 +906,11 @@ "one": "Benachrichtigung", "other": "Benachrichtigungen" }, + "openroaming": { + "pool_strategy": "Pool-Strategie", + "radius_endpoint_one": "Radiusendpunkt", + "radius_endpoint_other": "Radiusendpunkte" + }, "operator": { "delete_explanation": "Möchten Sie diesen Operator wirklich löschen? Dieser Vorgang ist nicht umkehrbar", "delete_operator": "Betreiber löschen", @@ -971,6 +976,27 @@ "title": "Beschränkungen", "tty": "TTY-Zugriff" }, + "roaming": { + "account_created": "Neues Konto erstellt!", + "account_deleted": "Konto gelöscht!", + "account_one": "Konto", + "account_other": "Konten", + "certificate_deleted": "Zertifikat gelöscht!", + "certificate_one": "Zertifikat", + "certificate_other": "Zertifikate", + "city": "Stadt", + "common_name": "Gemeinsamen Namen", + "country": "Land", + "global_reach": "Globale Reichweite", + "global_reach_account_id": "Konto-ID", + "invalid_certificate": "Ungültiges Zertifikat", + "invalid_key": "Ungültiger privater Schlüssel", + "location_details_title": "Ort", + "organization": "Organisation", + "private_key": "Privat Schlüssel", + "province": "Provinz", + "state": "Zustand" + }, "rrm": { "algorithm": "Algorithmus", "algorithm_other": "Algorithmen", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0649731..4b8e360 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -906,6 +906,11 @@ "one": "Notification", "other": "Notifications" }, + "openroaming": { + "pool_strategy": "Pool Strategy", + "radius_endpoint_one": "Radius Endpoint", + "radius_endpoint_other": "Radius Endpoints" + }, "operator": { "delete_explanation": "Are you sure you want to delete this operator? This operation is not reversible", "delete_operator": "Delete Operator", @@ -971,6 +976,27 @@ "title": "Restrictions", "tty": "TTY Access" }, + "roaming": { + "account_created": "New account created!", + "account_deleted": "Deleted account!", + "account_one": "Account", + "account_other": "Accounts", + "certificate_deleted": "Deleted certificate!", + "certificate_one": "Certificate", + "certificate_other": "Certificates", + "city": "City", + "common_name": "Common Name", + "country": "Country", + "global_reach": "Global Reach", + "global_reach_account_id": " Account ID", + "invalid_certificate": "Invalid certificate", + "invalid_key": "Invalid private key", + "location_details_title": "Location", + "organization": "Organization", + "private_key": "Private Key", + "province": "Province", + "state": "State" + }, "rrm": { "algorithm": "Algorithm", "algorithm_other": "Algorithms", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index e28c6cc..1b5be45 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -906,6 +906,11 @@ "one": "Notificación", "other": "Notificaciones" }, + "openroaming": { + "pool_strategy": "Estrategia de piscina", + "radius_endpoint_one": "Punto final del radio", + "radius_endpoint_other": "Puntos finales de radio" + }, "operator": { "delete_explanation": "¿Está seguro de que desea eliminar este operador? Esta operación no es reversible.", "delete_operator": "Eliminar operador", @@ -971,6 +976,27 @@ "title": "Las restricciones", "tty": "Acceso TTY" }, + "roaming": { + "account_created": "¡Nueva cuenta creada!", + "account_deleted": "¡Cuenta eliminada!", + "account_one": "Cuenta", + "account_other": "Cuentas", + "certificate_deleted": "Certificado eliminado!", + "certificate_one": "Certificado", + "certificate_other": "Certificados", + "city": "ciudad", + "common_name": "Nombre común", + "country": "País", + "global_reach": "Alcance global", + "global_reach_account_id": "ID de cuenta ", + "invalid_certificate": "Certificado inválido", + "invalid_key": "Clave privada no válida", + "location_details_title": "Ubicación", + "organization": "Organización", + "private_key": "Llave privada", + "province": "Provincia", + "state": "Estado" + }, "rrm": { "algorithm": "Algoritmo", "algorithm_other": "Algoritmos", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 23a1a4f..0eda5c8 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -906,6 +906,11 @@ "one": "Notification", "other": "Les notifications" }, + "openroaming": { + "pool_strategy": "Stratégie de pool", + "radius_endpoint_one": "Point final de rayon", + "radius_endpoint_other": "Points de terminaison du rayon" + }, "operator": { "delete_explanation": "Voulez-vous vraiment supprimer cet opérateur ? Cette opération n'est pas réversible", "delete_operator": "Supprimer l'opérateur", @@ -971,6 +976,27 @@ "title": "Restrictions", "tty": "Accès ATS" }, + "roaming": { + "account_created": "Nouveau compte créé !", + "account_deleted": "Compte supprimé !", + "account_one": "Compte", + "account_other": "Comptes", + "certificate_deleted": "Certificat supprimé !", + "certificate_one": "Certificat", + "certificate_other": "Certificats", + "city": "Ville", + "common_name": "Nom commun", + "country": "Pays", + "global_reach": "Portée mondiale", + "global_reach_account_id": "ID de compte ", + "invalid_certificate": "certificat invalide", + "invalid_key": "Clé privée invalide", + "location_details_title": "Emplacement", + "organization": "Organisation", + "private_key": "Clé privée", + "province": "province", + "state": "Etat" + }, "rrm": { "algorithm": "Algorithme", "algorithm_other": "Algorithmes", diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 388fb23..d372c42 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -906,6 +906,11 @@ "one": "Notificação", "other": "Notificações" }, + "openroaming": { + "pool_strategy": "Estratégia de pool", + "radius_endpoint_one": "Ponto final do raio", + "radius_endpoint_other": "Pontos finais de raio" + }, "operator": { "delete_explanation": "Tem certeza de que deseja excluir este operador? Esta operação não é reversível", "delete_operator": "Excluir operador", @@ -971,6 +976,27 @@ "title": "RESTRIÇÕES", "tty": "Acesso TTY" }, + "roaming": { + "account_created": "Nova conta criada!", + "account_deleted": "Conta excluída!", + "account_one": "Conta", + "account_other": "Contas", + "certificate_deleted": "Certificado excluído!", + "certificate_one": "Certificado", + "certificate_other": "Certificados", + "city": "Cidade", + "common_name": "Nome comum", + "country": "País", + "global_reach": "Alcance global", + "global_reach_account_id": "ID da conta", + "invalid_certificate": "Certificado inválido", + "invalid_key": "Chave privada inválida", + "location_details_title": "Localização", + "organization": "Organização", + "private_key": "Chave privada", + "province": "província", + "state": "Estado" + }, "rrm": { "algorithm": "Algoritmo", "algorithm_other": "Algoritmos", diff --git a/src/components/DataGrid/index.tsx b/src/components/DataGrid/index.tsx index d356eee..8b4aab1 100644 --- a/src/components/DataGrid/index.tsx +++ b/src/components/DataGrid/index.tsx @@ -40,13 +40,15 @@ export type DataGridOptions = { onRowClick?: (row: TValue) => (() => void) | undefined; refetch?: () => void; showAsCard?: boolean; + hideTablePreferences?: boolean; }; export type DataGridProps = { + innerTableKey?: string | number; controller: UseDataGridReturn; columns: DataGridColumn[]; header: { - title: string; + title: string | React.ReactNode; objectListed: string; leftContent?: React.ReactNode; addButton?: React.ReactNode; @@ -58,6 +60,7 @@ export type DataGridProps = { }; export const DataGrid = ({ + innerTableKey, controller, columns, header, @@ -149,6 +152,20 @@ export const DataGrid = ({ ...tableOptions, }); + // If this is a manual DataTable, with a page index that is higher than 0 and higher than the max possible page, we send to index 0 + React.useEffect(() => { + if ( + options.isManual && + !isLoading && + data && + pagination.pageIndex > 0 && + options.count !== undefined && + Math.ceil(options.count / pagination.pageSize) - 1 < pagination.pageIndex + ) { + controller.onPaginationChange({ pageIndex: 0, pageSize: pagination.pageSize }); + } + }, [options.count, isLoading, pagination, data]); + if (isLoading && !options.showAsCard && data.length === 0) { return (
@@ -160,25 +177,29 @@ export const DataGrid = ({ return options.showAsCard ? ( - - {header.title} - + {typeof header.title === 'string' ? ( + + {header.title} + + ) : ( + header.title + )} {header.leftContent} {header.otherButtons} {header.addButton} - { + {options.hideTablePreferences ? null : ( // @ts-ignore controller={controller} columns={columns} /> - } + )} {options.refetch ? : null} - +
{table.getHeaderGroups().map((headerGroup) => ( key={headerGroup.id} headerGroup={headerGroup} /> @@ -224,7 +245,7 @@ export const DataGrid = ({ -
+
{table.getHeaderGroups().map((headerGroup) => ( key={headerGroup.id} headerGroup={headerGroup} /> diff --git a/src/components/FormFields/StringField/StringInput.tsx b/src/components/FormFields/StringField/StringInput.tsx index 9f468bf..1c874d9 100644 --- a/src/components/FormFields/StringField/StringInput.tsx +++ b/src/components/FormFields/StringField/StringInput.tsx @@ -23,6 +23,7 @@ interface StringInputProps extends FieldInputProps) => void; explanation?: string; + placeholder?: string; } const StringInput: React.FC = ({ @@ -39,6 +40,7 @@ const StringInput: React.FC = ({ isDisabled, definitionKey, explanation, + placeholder, h, ...props }) => { @@ -97,6 +99,7 @@ const StringInput: React.FC = ({ autoComplete="off" border="2px solid" _disabled={{ opacity: 0.8, cursor: 'not-allowed' }} + placeholder={placeholder} /> {hideButton && ( diff --git a/src/components/FormFields/StringField/index.tsx b/src/components/FormFields/StringField/index.tsx index ae679fb..35edbdb 100644 --- a/src/components/FormFields/StringField/index.tsx +++ b/src/components/FormFields/StringField/index.tsx @@ -7,6 +7,7 @@ import { FieldProps } from 'models/Form'; interface StringFieldProps extends FieldProps, LayoutProps { hideButton?: boolean; explanation?: string; + placeholder?: string; } const StringField: React.FC = ({ @@ -20,6 +21,7 @@ const StringField: React.FC = ({ emptyIsUndefined = false, definitionKey, explanation, + placeholder, ...props }) => { const { value, error, isError, onChange, onBlur } = useFastField({ name }); @@ -47,6 +49,7 @@ const StringField: React.FC = ({ isDisabled={isDisabled} definitionKey={definitionKey} explanation={explanation} + placeholder={placeholder} {...props} /> ); diff --git a/src/components/Modals/Resources/CreateModal/index.tsx b/src/components/Modals/Resources/CreateModal/index.tsx index 6ed2e4a..23e786c 100644 --- a/src/components/Modals/Resources/CreateModal/index.tsx +++ b/src/components/Modals/Resources/CreateModal/index.tsx @@ -15,6 +15,7 @@ import InterfaceSsidResource from '../Sections/InterfaceSsid'; import InterfaceSsidRadiusResource from '../Sections/InterfaceSsidRadius'; import InterfaceVlanResource from '../Sections/InterfaceVlan'; import InterfaceIpv4Resource from '../Sections/Ipv4'; +import OpenRoamingSSID from '../Sections/OpenRoamingSsid'; import SingleRadioResource from '../Sections/SingleRadio'; import InterfaceTunnelResource from '../Sections/Tunnel'; import CloseButton from 'components/Buttons/CloseButton'; @@ -22,6 +23,7 @@ import CreateButton from 'components/Buttons/CreateButton'; import SaveButton from 'components/Buttons/SaveButton'; import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; import ModalHeader from 'components/Modals/ModalHeader'; +import { useGetRadiusEndpoints } from 'hooks/Network/RadiusEndpoints'; import useFormRef from 'hooks/useFormRef'; interface Props { @@ -30,12 +32,13 @@ interface Props { isVenue?: boolean; } -const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => { +const CreateResourceModal: React.FC = ({ refresh, entityId, isVenue = false }) => { const { t } = useTranslation(); const { isOpen, onOpen, onClose } = useDisclosure(); const [selectedVariable, setSelectedVariable] = useState('interface.ssid'); const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure(); const { form, formRef } = useFormRef(); + const getRadiusEndpoints = useGetRadiusEndpoints(); const closeModal = () => (form.dirty ? openConfirm() : onClose()); @@ -48,7 +51,7 @@ const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => { return ( <> - + @@ -68,16 +71,22 @@ const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => { - {t('resources.variable')} + Configuration Section {selectedVariable === 'interface.captive' && ( @@ -100,6 +109,17 @@ const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => { parent={{ entity: isVenue ? undefined : entityId, venue: isVenue ? entityId : undefined }} /> )} + {selectedVariable === 'interface.ssid.openroaming' && getRadiusEndpoints.data && ( + + )} {selectedVariable === 'interface.tunnel' && ( { parent={{ entity: isVenue ? undefined : entityId, venue: isVenue ? entityId : undefined }} /> )} - {selectedVariable === 'interface.ipv4' && ( - - )} {selectedVariable === 'interface.ssid' && ( { isDisabled={false} /> )} + {selectedVariable === 'interface.ipv4' && ( + + )} diff --git a/src/components/Modals/Resources/EditModal/index.tsx b/src/components/Modals/Resources/EditModal/index.tsx index c1e3b91..5321c7c 100644 --- a/src/components/Modals/Resources/EditModal/index.tsx +++ b/src/components/Modals/Resources/EditModal/index.tsx @@ -15,6 +15,7 @@ import InterfaceSsidResource from '../Sections/InterfaceSsid'; import InterfaceSsidRadiusResource from '../Sections/InterfaceSsidRadius'; import InterfaceVlanResource from '../Sections/InterfaceVlan'; import InterfaceIpv4Resource from '../Sections/Ipv4'; +import OpenRoamingSSID from '../Sections/OpenRoamingSsid'; import SingleRadioResource from '../Sections/SingleRadio'; import InterfaceTunnelResource from '../Sections/Tunnel'; import CloseButton from 'components/Buttons/CloseButton'; @@ -22,6 +23,7 @@ import EditButton from 'components/Buttons/EditButton'; import SaveButton from 'components/Buttons/SaveButton'; import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; import ModalHeader from 'components/Modals/ModalHeader'; +import { useGetRadiusEndpoints } from 'hooks/Network/RadiusEndpoints'; import { useGetResource } from 'hooks/Network/Resources'; import useFormRef from 'hooks/useFormRef'; import { Resource } from 'models/Resource'; @@ -33,7 +35,7 @@ interface Props { refresh: () => void; } -const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => { +const EditResourceModal: React.FC = ({ isOpen, onClose, resource, refresh }) => { const { t } = useTranslation(); const [editing, setEditing] = useBoolean(); const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure(); @@ -46,7 +48,7 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => { id: resource?.id ?? '', enabled: resource?.id !== '' && isOpen, }); - + const getRadiusEndpoints = useGetRadiusEndpoints(); const closeModal = () => (form.dirty ? openConfirm() : onClose()); const closeCancelAndForm = () => { @@ -75,7 +77,9 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => { ); - if (getType() === 'interface.captive') + const resourceType = getType(); + + if (resourceType === 'interface.captive') return ( { /> ); - if (getType() === 'interface.ssid.radius') + if (resourceType === 'interface.ssid.radius') return ( { isDisabled={!editing} /> ); - if (getType() === 'interface.tunnel') + if (resourceType === 'interface.tunnel') return ( { /> ); - if (getType() === 'interface.ipv4') + if (resourceType === 'interface.ssid.openroaming') return ( - ); - if (getType() === 'interface.vlan') + if (resourceType === 'interface.vlan') return ( { /> ); - if (getType() === 'interface.ssid') + if (resourceType === 'interface.ssid') return ( { isDisabled={!editing} /> ); + if (resourceType === 'interface.ipv4') + return ( + + ); - if (getType() === 'radio') + if (resourceType === 'radio') return ( { +const InterfaceSsidResource: React.FC = ({ + isOpen, + onClose, + refresh, + formRef, + resource, + isDisabled = false, + parent, +}) => { const { t } = useTranslation(); const toast = useToast(); const [formKey, setFormKey] = useState(uuid()); diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/Form.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/Form.tsx new file mode 100644 index 0000000..21f83d0 --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/Form.tsx @@ -0,0 +1,101 @@ +/* eslint-disable max-len */ +import React, { useMemo } from 'react'; +import { Box, Heading, SimpleGrid } from '@chakra-ui/react'; +import { getIn, useFormikContext } from 'formik'; +import OpenRoamingEncryption from './OpenRoamingEncryption'; +import MultiSelectField from 'components/FormFields/MultiSelectField'; +import SelectField from 'components/FormFields/SelectField'; +import StringField from 'components/FormFields/StringField'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import AdvancedSettings from 'pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/AdvancedSettings'; +import PassPoint from 'pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/PassPoint'; + +const namePrefix = 'editing'; + +const InterfaceSsidResourceForm = ({ + isDisabled, + radiusEndpoints, +}: { + isDisabled: boolean; + radiusEndpoints: RadiusEndpoint[]; +}) => { + const { values } = useFormikContext<{ + editing?: { + radius?: { + __radiusEndpoint: string; + }; + }; + }>(); + + const foundRadiusEndpoint = React.useMemo( + () => radiusEndpoints.find(({ id }) => id === values?.editing?.radius?.__radiusEndpoint), + [values?.editing?.radius?.__radiusEndpoint], + ); + + const isPasspoint = useMemo( + // @ts-ignore + () => values !== undefined && values['pass-point'] !== undefined && values['pass-point'] !== null, + [getIn(values, `${namePrefix}`)], + ); + + return ( + <> + + OpenRoaming SSID + + + + + + + + + + + + + ); +}; + +export default React.memo(InterfaceSsidResourceForm); diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Encryption.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Encryption.tsx new file mode 100644 index 0000000..7290f8d --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Encryption.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Flex, Heading, SimpleGrid } from '@chakra-ui/react'; +import OpenRoamingRadius from './Radius'; +import SelectField from 'components/FormFields/SelectField'; +import StringField from 'components/FormFields/StringField'; +import ToggleField from 'components/FormFields/ToggleField'; +import { + ENCRYPTION_OPTIONS, + ENCRYPTION_PROTOS_REQUIRE_RADIUS, +} from 'pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants'; + +interface Props { + editing: boolean; + namePrefix: string; + radiusPrefix: string; + onProtoChange: (e: React.ChangeEvent) => void; + needIeee: boolean; + isKeyNeeded: boolean; + isPasspoint?: boolean; +} + +const OpenRoamingEncryptionForm = ({ + editing, + namePrefix, + radiusPrefix, + onProtoChange, + needIeee, + isKeyNeeded, + isPasspoint, +}: Props) => ( + <> + + + Authentication + + + + ENCRYPTION_PROTOS_REQUIRE_RADIUS.includes(value))} + isDisabled={!editing} + isRequired + onChange={onProtoChange} + w="300px" + /> + {isKeyNeeded && ( + + )} + {needIeee && ( + + )} + + + + +); + +export default React.memo(OpenRoamingEncryptionForm); diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Radius/Radius.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Radius/Radius.tsx new file mode 100644 index 0000000..443821c --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Radius/Radius.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Flex, FormControl, FormLabel, Heading, SimpleGrid, Switch } from '@chakra-ui/react'; +import NumberField from 'components/FormFields/NumberField'; +import StringField from 'components/FormFields/StringField'; +import ToggleField from 'components/FormFields/ToggleField'; + +type Props = { + editing: boolean; + namePrefix: string; + onAccountingChange: (e: React.ChangeEvent) => void; + isAccountingEnabled: boolean; + // eslint-disable-next-line react/no-unused-prop-types + isPasspoint?: boolean; +}; +const OpenRoamingRadiusForm = ({ editing, namePrefix, onAccountingChange, isAccountingEnabled }: Props) => ( + <> + +
+ + Radius + +
+
+ + + Enable Accounting + + + + {isAccountingEnabled && ( + + + + + + )} + + + + + +); + +export default React.memo(OpenRoamingRadiusForm); diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Radius/index.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Radius/index.tsx new file mode 100644 index 0000000..b33eeb9 --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/Radius/index.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import RadiusForm from './Radius'; +import useFastField from 'hooks/useFastField'; + +type Props = { editing: boolean; namePrefix: string; isPasspoint?: boolean }; + +const OpenRoamingRadius = ({ editing, namePrefix, isPasspoint }: Props) => { + const { value: accounting, onChange: setAccounting } = useFastField({ name: `${namePrefix}.accounting` }); + + const onEnabledAccountingChange = (e: React.ChangeEvent) => { + if (e.target.checked) { + setAccounting({ + host: '192.168.178.192', + port: 1813, + secret: 'YOUR_SECRET', + }); + } else { + setAccounting(undefined); + } + }; + + const isAccountingEnabled = useMemo(() => accounting !== undefined, [accounting !== undefined]); + + return ( + + ); +}; + +export default React.memo(OpenRoamingRadius); diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/index.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/index.tsx new file mode 100644 index 0000000..46f0c2b --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/OpenRoamingEncryption/index.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useMemo } from 'react'; +import OpenRoamingEncryptionForm from './Encryption'; +import useFastField from 'hooks/useFastField'; +import { + ENCRYPTION_PROTOS_REQUIRE_IEEE, + ENCRYPTION_PROTOS_REQUIRE_KEY, + NO_MULTI_PROTOS, +} from 'pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants'; + +const OpenRoamingEncryption = ({ + editing, + ssidName, + namePrefix, + radiusPrefix, + isPasspoint, +}: { + editing: boolean; + namePrefix: string; + radiusPrefix: string; + ssidName: string; + isPasspoint?: boolean; +}) => { + const { value: encryptionValue, onChange: onEncryptionChange } = useFastField({ name: namePrefix }); + const { value: radiusValue, onChange: onRadiusChange } = useFastField({ name: radiusPrefix }); + const { onChange: onMultiPskChange } = useFastField({ name: `${ssidName}.multi-psk` }); + + const onProtoChange = useCallback( + (e: React.ChangeEvent) => { + const newEncryption: { proto: string; key?: string; ieee80211w?: string } = { + proto: e.target.value, + }; + if (e.target.value === 'none') { + onEncryptionChange({ proto: 'none' }); + onRadiusChange(undefined); + } else { + if (NO_MULTI_PROTOS.includes(e.target.value)) onMultiPskChange(undefined); + if (ENCRYPTION_PROTOS_REQUIRE_KEY.includes(e.target.value)) newEncryption.key = 'YOUR_SECRET'; + if (ENCRYPTION_PROTOS_REQUIRE_IEEE.includes(e.target.value)) newEncryption.ieee80211w = 'required'; + onEncryptionChange(newEncryption); + } + }, + [isPasspoint], + ); + + const { isKeyNeeded, needIeee } = useMemo( + () => ({ + isKeyNeeded: ENCRYPTION_PROTOS_REQUIRE_KEY.includes(encryptionValue?.proto ?? ''), + needIeee: ENCRYPTION_PROTOS_REQUIRE_IEEE.includes(encryptionValue?.proto ?? ''), + }), + [encryptionValue?.proto, radiusValue !== undefined], + ); + + return ( + + ); +}; + +export default React.memo(OpenRoamingEncryption); diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/RadiusEndpointSelector.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/RadiusEndpointSelector.tsx new file mode 100644 index 0000000..0588581 --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/RadiusEndpointSelector.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import SelectField from 'components/FormFields/SelectField'; +import { useGetRadiusEndpoints } from 'hooks/Network/RadiusEndpoints'; +import useFastField from 'hooks/useFastField'; + +const CONSORTIUMS = { + orion: ['F4F5E8F5F4'], + globalreach: ['5A03BA0000'], + generic: [], + radsec: [], +} as const; + +type Props = { + name: string; + isDisabled?: boolean; +}; + +const RadiusEndpointSelector = ({ name, isDisabled }: Props) => { + const getEndpoints = useGetRadiusEndpoints(); + const field = useFastField({ name }); + const consortiumField = useFastField({ name: 'editing.pass-point.roaming-consortium' }); + + const options = + getEndpoints.data?.map((endpoint) => ({ + value: endpoint.id, + label: endpoint.name, + })) ?? []; + + React.useEffect(() => { + const found = getEndpoints.data?.find(({ id }) => id === field.value); + + if (found) { + const newValue = CONSORTIUMS[found.Type]; + + consortiumField.onChange(newValue); + } + }, [field.value]); + + return ( + + ); +}; + +export default RadiusEndpointSelector; diff --git a/src/components/Modals/Resources/Sections/OpenRoamingSsid/index.tsx b/src/components/Modals/Resources/Sections/OpenRoamingSsid/index.tsx new file mode 100644 index 0000000..a0682a3 --- /dev/null +++ b/src/components/Modals/Resources/Sections/OpenRoamingSsid/index.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useState } from 'react'; +import { Heading, Tab, TabList, TabPanel, TabPanels, Tabs, useToast } from '@chakra-ui/react'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import { object, string } from 'yup'; +import InterfaceSsidForm from './Form'; +import RadiusEndpointSelector from './RadiusEndpointSelector'; +import NotesTable from 'components/CustomFields/NotesTable'; +import StringField from 'components/FormFields/StringField'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import { useCreateResource, useUpdateResource } from 'hooks/Network/Resources'; +import { AxiosError } from 'models/Axios'; +import { Note } from 'models/Note'; +import { Resource } from 'models/Resource'; +import { INTERFACE_SSID_SCHEMA } from 'pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants'; + +const CONSORTIUMS = { + orion: ['F4F5E8F5F4'], + globalreach: ['5A03BA0000'], + generic: [], + radsec: [], +} as const; + +const DEFAULT_VALUE = (defaultEndpoint?: RadiusEndpoint) => ({ + 'bss-mode': 'ap', + 'dtim-period': 2, + encryption: { + ieee80211w: 'disabled', + proto: 'wpa-mixed', + }, + radius: { + __radiusEndpoint: defaultEndpoint?.id ?? '', + 'chargeable-user-id': true, + accounting: { + host: 'example.com', + port: '1813', + secret: 'Secret', + }, + }, + 'pass-point': { + 'venue-group': 1, + 'venue-type': 1, + 'auth-type': { + type: 'terms-and-conditions', + }, + 'anqp-domain': 8888, + 'access-network-type': 0, + internet: true, + 'wan-metrics': { + type: 'up', + downlink: 20000, + uplink: 20000, + }, + 'roaming-consortium': CONSORTIUMS[defaultEndpoint?.Type ?? 'generic'], + 'domain-name': ['main.example.com'], + 'friendly-name': ['eng:ExampleWifi', 'fra:ExempleWifi'], + 'venue-name': ['eng:Example Inc 1', 'fra:Exemple Inc 1'], + 'venue-url': ['http://www.example.com/info-fra', 'http://www.example.com/info-eng'], + }, + 'fils-discovery-interval': 20, + 'hidden-ssid': false, + 'isolate-clients': false, + 'maximum-clients': 64, + name: 'OpenRoaming', + services: ['radius-gw-proxy', 'wifi-steering'], + 'tip-information-element': true, + 'wifi-bands': ['2G', '5G'], +}); + +export const EDIT_SCHEMA = (t: (str: string) => string) => + object().shape({ + _unused_name: string().required(t('form.required')).default(''), + _unused_description: string().default(''), + editing: INTERFACE_SSID_SCHEMA(t), + }); + +interface Props { + isOpen: boolean; + onClose: () => void; + refresh: () => void; + formRef: React.Ref>> | undefined; + resource?: Resource; + isDisabled?: boolean; + parent?: { + entity?: string; + venue?: string; + subscriber?: string; + }; + radiusEndpoints: RadiusEndpoint[]; +} + +const OpenRoamingSSID = ({ + isOpen, + onClose, + refresh, + formRef, + resource, + isDisabled = false, + parent, + radiusEndpoints, +}: Props) => { + const { t } = useTranslation(); + const toast = useToast(); + const [formKey, setFormKey] = useState(uuid()); + + const create = useCreateResource(); + const update = useUpdateResource(resource?.id ?? ''); + + useEffect(() => { + setFormKey(uuid()); + }, [isOpen]); + + return ( + { + const after = (success: boolean) => { + if (success) { + setSubmitting(false); + resetForm(); + refresh(); + onClose(); + } else { + setSubmitting(false); + } + }; + + return resource + ? update.mutateAsync( + { + variables: [ + { + type: 'json', + weight: 0, + prefix: 'interface.ssid.openroaming', + value: { + // @ts-ignore + ...formData.editing, + _unused_name: undefined, + _unused_description: undefined, + _unused_notes: undefined, + entity: undefined, + }, + }, + ], + name: formData._unused_name, + description: formData._unused_description, + // @ts-ignore + notes: formData._unused_notes.filter((note: Note) => note.isNew), + }, + { + onSuccess: async () => { + toast({ + id: 'resource-update-success', + title: t('common.success'), + description: t('crud.success_update_obj', { + obj: t('resources.configuration_resource'), + }), + status: 'success', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + after(true); + }, + // @ts-ignore + onError: (e: AxiosError) => { + toast({ + id: uuid(), + title: t('common.error'), + description: t('crud.error_update_obj', { + obj: t('resources.configuration_resource'), + e: e?.response?.data?.ErrorDescription, + }), + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + after(false); + }, + }, + ) + : create.mutateAsync( + { + variables: [ + { + type: 'json', + weight: 0, + prefix: 'interface.ssid.openroaming', + value: { + // @ts-ignore + ...formData.editing, + _unused_name: undefined, + _unused_description: undefined, + _unused_notes: undefined, + }, + }, + ], + ...parent, + name: formData._unused_name, + description: formData._unused_description, + // @ts-ignore + notes: formData._unused_notes.filter((note: Note) => note.isNew), + }, + { + onSuccess: async () => { + toast({ + id: 'user-creation-success', + title: t('common.success'), + description: t('crud.success_create_obj', { + obj: t('resources.configuration_resource'), + }), + status: 'success', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + after(true); + }, + // @ts-ignore + onError: (e: AxiosError) => { + toast({ + id: uuid(), + title: t('common.error'), + description: t('crud.error_create_obj', { + obj: t('resources.configuration_resource'), + e: e?.response?.data?.ErrorDescription, + }), + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + after(false); + }, + }, + ); + }} + > + + + {t('common.main')} + {t('common.notes')} + + + + + Resource Details + + + + + + + + + + + + + ); +}; + +export default OpenRoamingSSID; diff --git a/src/components/Tables/ConfigurationTable/CreateConfigurationModal/Form.jsx b/src/components/Tables/ConfigurationTable/CreateConfigurationModal/Form.jsx index 4f0c66a..d6fa49d 100644 --- a/src/components/Tables/ConfigurationTable/CreateConfigurationModal/Form.jsx +++ b/src/components/Tables/ConfigurationTable/CreateConfigurationModal/Form.jsx @@ -10,6 +10,7 @@ import MultiSelectField from 'components/FormFields/MultiSelectField'; import SelectWithSearchField from 'components/FormFields/SelectWithSearchField'; import StringField from 'components/FormFields/StringField'; import { CreateConfigurationSchema } from 'constants/formSchemas'; +import { ConfigurationProvider } from 'contexts/ConfigurationProvider'; import { useGetEntities } from 'hooks/Network/Entity'; import { useGetVenues } from 'hooks/Network/Venues'; @@ -170,7 +171,9 @@ const CreateConfigurationForm = ({ - + + + )} diff --git a/src/constants/formTests.ts b/src/constants/formTests.ts index 54a03ba..819dffd 100644 --- a/src/constants/formTests.ts +++ b/src/constants/formTests.ts @@ -69,16 +69,23 @@ export const testLength = ({ val, min, max }: { val?: string; min: number; max: return false; }; -export const testPemCertificate = (val?: string) => { +export const testPemCertificate = (val?: string, nonStrict?: boolean) => { if (val) { + if (nonStrict) { + return val.includes('---BEGIN') && val.includes('---END'); + } return val.includes('---BEGIN CERTIFICATE---') && val.includes('---END CERTIFICATE---'); } return false; }; -export const testPemPrivateKey = (val?: string) => { +export const testPemPrivateKey = (val?: string, nonStrict?: boolean) => { if (val) { + if (nonStrict) { + return val.includes('---BEGIN') && val.includes('---END'); + } + return val.includes('---BEGIN PRIVATE KEY---') && val.includes('---END PRIVATE KEY---'); } @@ -225,7 +232,6 @@ export const isValidPortRanges = (first: string, second: string) => { return false; }; - export type TestSelectPortsProps = { ports: string[]; vlan?: number }[]; export const testSelectPorts = (obj: TestSelectPortsProps) => { @@ -262,4 +268,9 @@ export const testSelectPorts = (obj: TestSelectPortsProps) => { return true; }; -export const testObjectName = (str?: string) => (str ? str.length <= 50 : false); +export const testObjectName = (str?: string) => (str ? str.length <= 30 : false); + +export const isValidEmailAddress = (email: string) => + email.match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ); diff --git a/src/contexts/ConfigurationProvider/index.tsx b/src/contexts/ConfigurationProvider/index.tsx new file mode 100644 index 0000000..149c4d2 --- /dev/null +++ b/src/contexts/ConfigurationProvider/index.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from 'react'; +import { useGetConfiguration } from 'hooks/Network/Configurations'; +import { useGetEntity } from 'hooks/Network/Entity'; +import { useGetResources } from 'hooks/Network/Resources'; +import { useGetVenue } from 'hooks/Network/Venues'; +import { Resource } from 'models/Resource'; + +const ConfigurationContext = React.createContext<{ + configurationId: string; + availableResources?: Resource[]; +}>({ + configurationId: '', +}); + +export const ConfigurationProvider = ({ + children, + configurationId, + entityId, +}: { + children: React.ReactElement; + configurationId?: string; + entityId?: string; +}) => { + const getConfig = useGetConfiguration({ id: configurationId, onSuccess: () => {} }); + const venueId = () => { + const split = entityId?.split(':'); + if (split?.[0] && split?.[1] && split[0] === 'ven') { + return split[1]; + } + return getConfig.data?.venue; + }; + const finalEntityId = () => { + const split = entityId?.split(':'); + if (split?.[0] && split?.[1] && split[0] === 'ent') { + return split[1]; + } + return getConfig.data?.entity; + }; + + const getVenue = useGetVenue({ id: venueId() }); + const getEntity = useGetEntity({ id: finalEntityId() }); + const getResources = useGetResources({ + pageInfo: null, + select: getVenue.data?.variables ?? getEntity.data?.variables, + }); + + const value = useMemo( + () => ({ + configurationId, + availableResources: getResources.data, + }), + [getResources.data], + ); + + return {children}; +}; + +export const useConfigurationContext = () => React.useContext(ConfigurationContext); diff --git a/src/hooks/Network/Configurations.ts b/src/hooks/Network/Configurations.ts index be82b0c..7f18cd2 100644 --- a/src/hooks/Network/Configurations.ts +++ b/src/hooks/Network/Configurations.ts @@ -1,8 +1,8 @@ import { useToast } from '@chakra-ui/react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; import { useTranslation } from 'react-i18next'; import useDefaultPage from 'hooks/useDefaultPage'; +import { AxiosError } from 'models/Axios'; import { axiosProv } from 'utils/axiosInstances'; export const useGetConfigurations = () => { @@ -92,7 +92,7 @@ export const useGetAvailableConfigurations = ({ tagId }: { tagId: string }) => { ); }; -export const useGetConfiguration = ({ id = null, onSuccess = () => {} }) => { +export const useGetConfiguration = ({ id, onSuccess = () => {} }: { id?: string | null; onSuccess?: () => void }) => { const { t } = useTranslation(); const toast = useToast(); const goToDefaultPage = useDefaultPage(); @@ -101,7 +101,9 @@ export const useGetConfiguration = ({ id = null, onSuccess = () => {} }) => { ['get-configuration', id], () => axiosProv.get(`configuration/${id}?withExtendedInfo=true`).then(({ data }) => data), { - enabled: id !== null && id !== '', + enabled: id !== undefined && id !== null && id !== '', + keepPreviousData: true, + staleTime: 10 * 1000, onSuccess, onError: (e: AxiosError) => { if (!toast.isActive('configuration-fetching-error')) diff --git a/src/hooks/Network/GlobalReach.ts b/src/hooks/Network/GlobalReach.ts new file mode 100644 index 0000000..55e3c52 --- /dev/null +++ b/src/hooks/Network/GlobalReach.ts @@ -0,0 +1,251 @@ +/* eslint-disable no-await-in-loop */ +import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Note } from 'models/Note'; +import { axiosProv } from 'utils/axiosInstances'; + +export type GlobalReachAccount = { + id: string; + created: number; + modified: number; + name: string; + description: string; + notes: Note[]; + privateKey: string; + country: string; + province: string; + city: string; + organization: string; + commonName: string; + CSR: string; + GlobalReachAcctId: string; +}; + +const ACCOUNT_QUERY_KEY_PREFIX = 'globalReach'; +const ACCOUNT_PATH = '/openroaming/globalreach/account'; +const ACCOUNTS_PATH = '/openroaming/globalreach/accounts'; + +const getGlobalReachAccounts = async (limit: number, offset: number) => + axiosProv.get(`${ACCOUNTS_PATH}?limit=${limit}&offset=${offset}`).then(({ data }) => data as GlobalReachAccount[]); + +const getAllGlobalReachAccounts = async () => { + const batchSize = 500; + let offset = 0; + let accounts: GlobalReachAccount[] = []; + let lastBatchSize = 0; + + do { + const batch = await getGlobalReachAccounts(batchSize, offset); + lastBatchSize = batch.length; + accounts = accounts.concat(batch); + offset += batchSize; + } while (lastBatchSize === batchSize); + + return accounts; +}; + +export const useGetGlobalReachAccounts = () => + useQuery({ + queryKey: [ACCOUNT_QUERY_KEY_PREFIX, 'all'], + queryFn: getAllGlobalReachAccounts, + staleTime: 1000 * 60, + keepPreviousData: true, + }); + +const getGlobalReachAccount = async (context: QueryFunctionContext<[string, { id: string }]>) => + axiosProv.get(`${ACCOUNT_PATH}/${context.queryKey[1].id}`).then(({ data }) => data as GlobalReachAccount); + +export const useGetGlobalReachAccount = (id: string) => + useQuery({ + queryKey: [ACCOUNT_QUERY_KEY_PREFIX, { id }], + queryFn: getGlobalReachAccount, + staleTime: 1000 * 60, + keepPreviousData: true, + }); + +export type CreateGlobalReachAccountRequest = { + name: string; + description?: string; + notes?: Note[]; + privateKey: string; + country: string; + province: string; + city: string; + organization: string; + commonName: string; + GlobalReachAcctId: string; +}; +const createGlobalReachAccount = async (req: CreateGlobalReachAccountRequest) => + axiosProv.post(`${ACCOUNT_PATH}/0`, req).then(({ data }) => data as GlobalReachAccount); + +export const useCreateGlobalReachAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createGlobalReachAccount, + onSuccess: () => { + queryClient.invalidateQueries([ACCOUNT_QUERY_KEY_PREFIX]); + }, + }); +}; + +export type UpdateGlobalReachAccountRequest = { + id: string; + name?: string; + description?: string; + notes?: Note[]; + privateKey?: string; + country?: string; + province?: string; + city?: string; + organization?: string; + commonName?: string; +}; + +const updateGlobalReachAccount = async (req: UpdateGlobalReachAccountRequest) => + axiosProv.put(`${ACCOUNT_PATH}/${req.id}`, req).then(({ data }) => data as GlobalReachAccount); + +export const useUpdateGlobalReachAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateGlobalReachAccount, + onSuccess: () => { + queryClient.invalidateQueries([ACCOUNT_QUERY_KEY_PREFIX]); + }, + }); +}; + +const deleteGlobalReachAccount = async (id: string) => axiosProv.delete(`${ACCOUNT_PATH}/${id}`); + +export const useDeleteGlobalReachAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteGlobalReachAccount, + onSuccess: () => { + queryClient.invalidateQueries([ACCOUNT_QUERY_KEY_PREFIX]); + }, + }); +}; + +export type GlobalReachCertificate = { + id: string; + name: string; + accountId: string; + csr: string; + certificate: string; + certificateChain: string; + certificateId: string; + expiresAt: number; + created: number; +}; + +const CERTIFICATE_QUERY_KEY_PREFIX = 'globalReachCertificate'; +const CERTIFICATE_PATH = '/openroaming/globalreach/certificate'; +const CERTIFICATES_PATH = '/openroaming/globalreach/certificates'; + +const getGlobalReachCertificatesBatch = async (accountId: string, limit: number, offset: number) => + axiosProv + .get(`${CERTIFICATES_PATH}/${accountId}?limit=${limit}&offset=${offset}`) + .then(({ data }) => data as GlobalReachCertificate[]); + +const getGlobalReachCertificates = async (context: QueryFunctionContext<[string, { accountId: string }]>) => { + const { accountId } = context.queryKey[1]; + const batchSize = 500; + let offset = 0; + let certificates: GlobalReachCertificate[] = []; + let lastBatchSize = 0; + + do { + const batch = await getGlobalReachCertificatesBatch(accountId, batchSize, offset); + lastBatchSize = batch.length; + certificates = certificates.concat(batch); + offset += batchSize; + } while (lastBatchSize === batchSize); + + return certificates; +}; + +export const useGetGlobalReachCertificates = (accountId: string) => + useQuery({ + queryKey: [CERTIFICATE_QUERY_KEY_PREFIX, { accountId }], + queryFn: getGlobalReachCertificates, + staleTime: 1000 * 60, + keepPreviousData: true, + }); + +const getSelectedGlobalReachCertificates = async (context: QueryFunctionContext<[string, { certIds: string[] }]>) => + axiosProv + .get(`${CERTIFICATES_PATH}/*?select=${context.queryKey[1].certIds.join(',')}`) + .then(({ data }) => data as GlobalReachCertificate[]); + +export const useGetSelectedGlobalReachCertificates = ({ certIds }: { certIds: string[] }) => + useQuery({ + queryKey: [CERTIFICATE_QUERY_KEY_PREFIX, { certIds }], + queryFn: getSelectedGlobalReachCertificates, + staleTime: 1000 * 60, + enabled: certIds.length > 0, + keepPreviousData: true, + }); + +const getGlobalReachCertificate = async (context: QueryFunctionContext<[string, { accountId: string; id: string }]>) => + axiosProv + .get(`${CERTIFICATE_PATH}/${context.queryKey[1].accountId}/${context.queryKey[1].id}`) + .then(({ data }) => data as GlobalReachCertificate); + +export const useGetGlobalReachCertificate = (accountId: string, id: string) => + useQuery({ + queryKey: [CERTIFICATE_QUERY_KEY_PREFIX, { accountId, id }], + queryFn: getGlobalReachCertificate, + staleTime: 1000 * 60, + keepPreviousData: true, + }); + +export type CreateGlobalReachCertificateRequest = { + accountId: string; + name: string; +}; + +const createGlobalReachCertificate = async (req: CreateGlobalReachCertificateRequest) => + axiosProv.post(`${CERTIFICATE_PATH}/${req.accountId}/0`, req).then(({ data }) => data as GlobalReachCertificate); + +export const useCreateGlobalReachCertificate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createGlobalReachCertificate, + onSuccess: () => { + queryClient.invalidateQueries([CERTIFICATE_QUERY_KEY_PREFIX]); + }, + }); +}; + +const renewGlobalReachCertificate = async ({ accountId, id }: { accountId: string; id: string }) => + axiosProv + .put(`${CERTIFICATE_PATH}/${accountId}/${id}?updateCertificate=true`, {}) + .then(({ data }) => data as GlobalReachCertificate); + +export const useRenewGlobalReachCertificate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: renewGlobalReachCertificate, + onSuccess: () => { + queryClient.invalidateQueries([CERTIFICATE_QUERY_KEY_PREFIX]); + }, + }); +}; + +const deleteGlobalReachCertificate = async ({ accountId, id }: { accountId: string; id: string }) => + axiosProv.delete(`${CERTIFICATE_PATH}/${accountId}/${id}`); + +export const useDeleteGlobalReachCertificate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteGlobalReachCertificate, + onSuccess: () => { + queryClient.invalidateQueries([CERTIFICATE_QUERY_KEY_PREFIX]); + }, + }); +}; diff --git a/src/hooks/Network/GoogleOrion.ts b/src/hooks/Network/GoogleOrion.ts new file mode 100644 index 0000000..e20dc88 --- /dev/null +++ b/src/hooks/Network/GoogleOrion.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-await-in-loop */ +import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Note } from 'models/Note'; +import { axiosProv } from 'utils/axiosInstances'; + +export type GoogleOrionAccount = { + name: string; + description: string; + notes: Note[]; + created: number; + modified: number; + id: string; + privateKey: string; + certificate: string; + cacerts: string[]; +}; + +const ACCOUNT_QUERY_KEY_PREFIX = 'googleOrion'; +const ACCOUNT_PATH = '/openroaming/orion/account'; +const ACCOUNTS_PATH = '/openroaming/orion/accounts'; + +const getGoogleOrionAccounts = async (limit: number, offset: number) => + axiosProv.get(`${ACCOUNTS_PATH}?limit=${limit}&offset=${offset}`).then(({ data }) => data as GoogleOrionAccount[]); + +const getAllGoogleOrionAccounts = async () => { + const batchSize = 500; + let offset = 0; + let accounts: GoogleOrionAccount[] = []; + let lastBatchSize = 0; + + do { + const batch = await getGoogleOrionAccounts(batchSize, offset); + lastBatchSize = batch.length; + accounts = accounts.concat(batch); + offset += batchSize; + } while (lastBatchSize === batchSize); + + return accounts; +}; + +export const useGetGoogleOrionAccounts = () => + useQuery({ + queryKey: [ACCOUNT_QUERY_KEY_PREFIX, 'all'], + queryFn: getAllGoogleOrionAccounts, + staleTime: 1000 * 60, + keepPreviousData: true, + }); + +const getGoogleOrionAccount = async (context: QueryFunctionContext<[string, { id: string }]>) => + axiosProv.get(`${ACCOUNT_PATH}/${context.queryKey[1].id}`).then(({ data }) => data as GoogleOrionAccount); + +export const useGetGoogleOrionAccount = (id: string) => + useQuery({ + queryKey: [ACCOUNT_QUERY_KEY_PREFIX, { id }], + queryFn: getGoogleOrionAccount, + staleTime: 1000 * 60, + }); + +export type CreateGoogleOrionAccountRequest = { + name: string; + description?: string; + notes?: Note[]; + privateKey: string; + certificate: string; + cacerts: string[]; +}; + +const createGoogleOrionAccount = async (request: CreateGoogleOrionAccountRequest) => + axiosProv.post(`${ACCOUNT_PATH}/0`, request).then(({ data }) => data as GoogleOrionAccount); + +export const useCreateGoogleOrionAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createGoogleOrionAccount, + onSuccess: () => { + queryClient.invalidateQueries([ACCOUNT_QUERY_KEY_PREFIX]); + }, + }); +}; + +export type UpdateGoogleOrionAccountRequest = { + id: string; + name?: string; + description?: string; + notes?: Note[]; +}; + +const updateGoogleOrionAccount = async (request: UpdateGoogleOrionAccountRequest) => + axiosProv + .put(`${ACCOUNT_PATH}/${request.id}`, { + name: request.name, + description: request.description, + notes: request.notes, + }) + .then(({ data }) => data as GoogleOrionAccount); + +export const useUpdateGoogleOrionAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateGoogleOrionAccount, + onSuccess: () => { + queryClient.invalidateQueries([ACCOUNT_QUERY_KEY_PREFIX]); + }, + }); +}; + +export const useDeleteGoogleOrionAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => axiosProv.delete(`${ACCOUNT_PATH}/${id}`), + onSuccess: () => { + queryClient.invalidateQueries([ACCOUNT_QUERY_KEY_PREFIX]); + }, + }); +}; diff --git a/src/hooks/Network/RadiusEndpoints.ts b/src/hooks/Network/RadiusEndpoints.ts new file mode 100644 index 0000000..f8f106d --- /dev/null +++ b/src/hooks/Network/RadiusEndpoints.ts @@ -0,0 +1,200 @@ +/* eslint-disable no-await-in-loop */ +import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AtLeast } from 'models/General'; +import { Note } from 'models/Note'; +import { axiosProv } from 'utils/axiosInstances'; + +export type RadiusServer = { + Hostname: string; + IP: string; + Port: number; + Secret: number; +}; + +export type RadiusEndpointServer = { + Authentication: RadiusServer[]; + Accounting: RadiusServer[]; + CoA: RadiusServer[]; + AccountingInterval: number; +}; + +export type RadsecServer = { + /** + * It should be the ID of a google orion account OR the certificate ID of the global reach account + * If not empty, only Weight needs to be populated + * If empty, all fields need to be populated + */ + UseOpenRoamingAccount: string; + Weight: number; + Hostname: string; + IP: string; + Port: string; + /** Default: radsec */ + Secret: string; + Certificate: string; + PrivateKey: string; + CaCerts: string[]; + /** Default: false */ + AllowSelfSigned: boolean; +}; + +export const RADIUS_ENDPOINT_TYPES = ['generic', 'radsec', 'globalreach', 'orion'] as const; +export type RadiusEndpointType = (typeof RADIUS_ENDPOINT_TYPES)[number]; + +export const RADIUS_ENDPOINT_POOL_STRATEGIES = ['round_robin', 'weighted', 'random'] as const; +export type RadiusEndpointPoolStrategy = (typeof RADIUS_ENDPOINT_POOL_STRATEGIES)[number]; + +export type RadiusEndpoint = { + id: string; + name: string; + description: string; + notes: Note[]; + created: number; + modified: number; + Type: RadiusEndpointType; + PoolStrategy: RadiusEndpointPoolStrategy; + /** + * If Type is radius, we need at least one entry + * Else, it should be empty + */ + RadiusServers: RadiusEndpointServer[]; + /** + * If Type is radsec, orion or globalreach, we need at least one entry + * Else, it should be empty + */ + RadsecServers: RadsecServer[]; + /** Default: true */ + UseGWProxy: boolean; + /** + * An IP address that should be between 0.0.1.1 and 0.0.2.254 + */ + Index: string; + /** + * The ids of all configurations using this endpoint + */ + UsedBy: string[]; + NasIdentifier: string; + AccountingInterval: number; +}; + +const SINGLE_PATH = '/RADIUSEndPoint'; +const COLLECTION_PATH = '/RADIUSEndPoints'; + +const QUERY_KEY = 'radius-endpoints'; + +const getEndpoints = (limit: number, offset: number) => + axiosProv.get(`${COLLECTION_PATH}?limit=${limit}&offset=${offset}`).then((res) => res.data as RadiusEndpoint[]); + +const getAllEndpoints = async () => { + const limit = 100; + let offset = 0; + const endpoints: RadiusEndpoint[] = []; + let lastResponse = [] as RadiusEndpoint[]; + + do { + lastResponse = await getEndpoints(limit, offset); + endpoints.push(...lastResponse); + offset += limit; + } while (lastResponse.length === limit); + + return endpoints; +}; + +export const useGetRadiusEndpoints = () => + useQuery({ + queryKey: [QUERY_KEY, 'all'], + queryFn: getAllEndpoints, + keepPreviousData: true, + staleTime: 60 * 1000, + }); + +const getEndpoint = ( + context: QueryFunctionContext< + [ + string, + { + id?: string; + }, + ] + >, +) => axiosProv.get(`${SINGLE_PATH}/${context.queryKey[1].id}`).then((res) => res.data as RadiusEndpoint); + +export const useGetRadiusEndpoint = ({ id }: { id?: string }) => + useQuery({ + queryKey: [QUERY_KEY, { id }], + queryFn: getEndpoint, + keepPreviousData: true, + staleTime: 60 * 1000, + enabled: !!id, + }); + +const deleteEndpoint = async (id: string) => axiosProv.delete(`${SINGLE_PATH}/${id}`); + +export const useDeleteRadiusEndpoint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteEndpoint, + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY]); + }, + }); +}; + +const updateEndpoint = async (endpoint: AtLeast) => + axiosProv.put(`${SINGLE_PATH}/${endpoint.id}`, endpoint); + +export const useUpdateRadiusEndpoint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateEndpoint, + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY]); + }, + }); +}; + +const createEndpoint = async (endpoint: Omit) => + axiosProv.post(`${SINGLE_PATH}/0`, endpoint); + +export const useCreateRadiusEndpoint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createEndpoint, + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY]); + }, + }); +}; + +const getLastTimeUpdatedOnGateway = async () => + axiosProv.get(`${COLLECTION_PATH}?currentStatus=true`).then( + (res) => + res.data as { + lastUpdate: number; + lastConfigurationChange: number; + }, + ); + +export const useGetRadiusEndpointLastGwUpdate = () => + useQuery({ + queryKey: [QUERY_KEY, { lastUpdatedOnly: true }], + queryFn: getLastTimeUpdatedOnGateway, + keepPreviousData: true, + staleTime: 60 * 1000, + }); + +const updateEndpointOnGateway = async () => axiosProv.put(`${COLLECTION_PATH}?updateEndpoints=true`); + +export const useUpdateRadiusEndpointsOnGateway = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateEndpointOnGateway, + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY]); + }, + }); +}; diff --git a/src/hooks/Network/Resources.ts b/src/hooks/Network/Resources.ts index 4910182..609b404 100644 --- a/src/hooks/Network/Resources.ts +++ b/src/hooks/Network/Resources.ts @@ -2,8 +2,8 @@ import { useToast } from '@chakra-ui/react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AxiosError } from 'models/Axios'; +import { Resource } from 'models/Resource'; import { PageInfo } from 'models/Table'; -import { VariableBlock } from 'models/VariableBlock'; import { axiosProv } from 'utils/axiosInstances'; export const useGetResourcesCount = () => { @@ -78,7 +78,7 @@ export const useGetResources = ({ select, count, }: { - pageInfo?: PageInfo; + pageInfo?: PageInfo | null; select?: string[]; count?: number; }) => { @@ -92,7 +92,7 @@ export const useGetResources = ({ select.length > 0 ? axiosProv .get(`variable?withExtendedInfo=true&select=${select}`) - .then(({ data }: { data: { variableBlocks: VariableBlock[] } }) => data.variableBlocks) + .then(({ data }: { data: { variableBlocks: Resource[] } }) => data.variableBlocks) : [], { onError: (e: AxiosError) => { @@ -123,7 +123,7 @@ export const useGetResources = ({ (pageInfo?.limit ?? 10) * (pageInfo?.index ?? 1) }`, ) - .then(({ data }) => data.variableBlocks), + .then(({ data }) => data.variableBlocks as Resource[]), { keepPreviousData: true, enabled: pageInfo !== null, diff --git a/src/hooks/useFormModal.ts b/src/hooks/useFormModal.ts index 6e72ca4..11d1307 100644 --- a/src/hooks/useFormModal.ts +++ b/src/hooks/useFormModal.ts @@ -1,24 +1,32 @@ import { useMemo } from 'react'; import { useDisclosure } from '@chakra-ui/react'; -interface Props { +type UseFormModalProps = { isDirty?: boolean; onModalClose?: () => void; -} -const useFormModal = ({ isDirty, onModalClose }: Props) => { + onCloseSideEffect?: () => void; +}; + +const useFormModal = ({ isDirty, onModalClose, onCloseSideEffect }: UseFormModalProps) => { const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isConfirmOpen, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure(); const closeModal = () => { if (isDirty) openConfirm(); else if (onModalClose) onModalClose(); - else onClose(); + else { + onClose(); + if (onCloseSideEffect) onCloseSideEffect(); + } }; const closeCancelAndForm = () => { closeConfirm(); if (onModalClose) onModalClose(); - else onClose(); + else { + onClose(); + if (onCloseSideEffect) onCloseSideEffect(); + } }; const toReturn = useMemo( diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..fcc1f30 --- /dev/null +++ b/src/hooks/useNotification.ts @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { useToast } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import { isApiError } from 'models/Axios'; + +export type SuccessNotificationProps = { + description: string; + id?: string; +}; + +export type ApiErrorNotificationProps = { + e: unknown; + fallbackMessage?: string; + id?: string; +}; + +export const useNotification = () => { + const { t } = useTranslation(); + const toast = useToast(); + + const successToast = ({ description, id }: SuccessNotificationProps) => { + toast({ + id: id ?? uuid(), + title: t('common.success'), + description, + status: 'success', + duration: 3000, + isClosable: true, + position: 'top-right', + }); + }; + + const apiErrorToast = ({ e, id, fallbackMessage }: ApiErrorNotificationProps) => { + if (isApiError(e)) { + toast({ + id: id ?? uuid(), + title: t('common.error'), + description: e.response?.data.ErrorDescription, + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + } else { + toast({ + id: id ?? uuid(), + title: t('common.error'), + description: fallbackMessage, + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + } + }; + + const errorToast = ({ description, id }: SuccessNotificationProps) => { + toast({ + id: id ?? uuid(), + title: t('common.error'), + description, + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + }; + + return React.useMemo( + () => ({ + successToast, + errorToast, + apiErrorToast, + }), + [t], + ); +}; + +export type UseNotificationReturn = ReturnType; diff --git a/src/layout/Sidebar/NavLinkButton.tsx b/src/layout/Sidebar/NavLinkButton.tsx index 4f7995a..4138497 100644 --- a/src/layout/Sidebar/NavLinkButton.tsx +++ b/src/layout/Sidebar/NavLinkButton.tsx @@ -40,13 +40,14 @@ export const NavLinkButton = ({ isActive, route, toggleSidebar }: Props) => { _hover={{ bg: hoverBg, }} + whiteSpace="normal" > {route.icon(false)} - - {t(route.name)} + + {route.label ?? t(route.name)} @@ -64,13 +65,14 @@ export const NavLinkButton = ({ isActive, route, toggleSidebar }: Props) => { _hover={{ bg: hoverBg, }} + whiteSpace="normal" > {route.icon(false)} - - {t(route.name)} + + {route.label ?? t(route.name)} diff --git a/src/layout/Sidebar/NestedNavButton/SubNavigationButton.tsx b/src/layout/Sidebar/NestedNavButton/SubNavigationButton.tsx index 78dc72f..1af8122 100644 --- a/src/layout/Sidebar/NestedNavButton/SubNavigationButton.tsx +++ b/src/layout/Sidebar/NestedNavButton/SubNavigationButton.tsx @@ -29,8 +29,9 @@ const SubNavigationButton = ({ isActive, route }: Props) => { bg: hoverBg, }} border="none" + textAlign="left" > - {t(route.name)} + {route.label ?? t(route.name)} ); diff --git a/src/layout/Sidebar/index.tsx b/src/layout/Sidebar/index.tsx index f9e125f..2b3de1b 100644 --- a/src/layout/Sidebar/index.tsx +++ b/src/layout/Sidebar/index.tsx @@ -89,7 +89,7 @@ export const Sidebar = ({ routes, isOpen, toggle, logo, version, topNav, childre ), - [user?.userRole, location, topNav], + [user?.userRole, location, topNav, routes], ); return ( diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 03c5766..7c69864 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -57,7 +57,9 @@ const Layout = () => { name = location.pathname.split('/')[location.pathname.split('/').length - 1] ?? ''; } - if (name.includes('RAW-')) name.replace('RAW-', ''); + if (name.includes('RAW-')) { + return name.replace('RAW-', ''); + } return t(name); }, [t, location.pathname]); diff --git a/src/models/Axios.ts b/src/models/Axios.ts index 5e0567f..e7a19d6 100644 --- a/src/models/Axios.ts +++ b/src/models/Axios.ts @@ -1,3 +1,5 @@ -import { AxiosError as Err } from 'axios'; +import { AxiosError as Err, isAxiosError } from 'axios'; export type AxiosError = Err<{ ErrorDescription: string; ErrorCode: number }>; +export const isApiError = (e: unknown): e is AxiosError => + isAxiosError(e) && (e as AxiosError).response?.data?.ErrorDescription !== undefined; diff --git a/src/models/Routes.ts b/src/models/Routes.ts index b6b12e5..884ec93 100644 --- a/src/models/Routes.ts +++ b/src/models/Routes.ts @@ -7,7 +7,8 @@ export type SubRoute = { authorized: string[]; path: string; name: RouteName; - component: React.ReactElement | LazyExoticComponent>; + label?: string; + component: typeof React.Component | React.LazyExoticComponent<() => JSX.Element | null>; navName?: RouteName; hidden?: boolean; icon?: undefined; @@ -21,6 +22,7 @@ export type RouteGroup = { id: string; authorized: string[]; name: RouteName; + label?: string; icon: (active: boolean) => React.ReactElement; children: SubRoute[]; hidden?: boolean; @@ -36,15 +38,16 @@ export type SingleRoute = { authorized: string[]; path: string; name: RouteName; + label?: string; navName?: RouteName; icon: (active: boolean) => React.ReactElement; navButton?: ( isActive: boolean, toggleSidebar: () => void, route: Route, - ) => React.ReactElement | LazyExoticComponent>; + ) => typeof React.Component | LazyExoticComponent>; isEntity?: boolean; - component: React.ReactElement | LazyExoticComponent>; + component: typeof React.Component | React.LazyExoticComponent<() => JSX.Element | null>; hidden?: boolean; isCustom?: boolean; children?: undefined; diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/Encryption.tsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/Encryption.tsx index 41421a4..002a13d 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/Encryption.tsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/Encryption.tsx @@ -16,6 +16,7 @@ interface Props { isUsingRadius: boolean; isPasspoint?: boolean; canUseRadius: boolean; + acceptedEncryptionProtos?: string[]; } const EncryptionForm = ({ @@ -28,6 +29,7 @@ const EncryptionForm = ({ isUsingRadius, isPasspoint, canUseRadius, + acceptedEncryptionProtos, }: Props) => ( <> @@ -40,7 +42,11 @@ const EncryptionForm = ({ name={`${namePrefix}.proto`} label="protocol" definitionKey="interface.ssid.encryption.proto" - options={ENCRYPTION_OPTIONS} + options={ + acceptedEncryptionProtos + ? ENCRYPTION_OPTIONS.filter(({ value }) => acceptedEncryptionProtos.includes(value)) + : ENCRYPTION_OPTIONS + } isDisabled={!editing} isRequired onChange={onProtoChange} diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/index.tsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/index.tsx index 0a5658b..e5f22a7 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/index.tsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/Encryption/index.tsx @@ -17,12 +17,14 @@ const Encryption = ({ namePrefix, radiusPrefix, isPasspoint, + acceptedEncryptionProtos, }: { editing: boolean; namePrefix: string; radiusPrefix: string; ssidName: string; isPasspoint?: boolean; + acceptedEncryptionProtos?: string[]; }) => { const { t } = useTranslation(); const { value: encryptionValue, onChange: onEncryptionChange } = useFastField({ name: namePrefix }); @@ -43,12 +45,6 @@ const Encryption = ({ if (ENCRYPTION_PROTOS_REQUIRE_IEEE.includes(e.target.value)) newEncryption.ieee80211w = 'required'; onEncryptionChange(newEncryption); if (ENCRYPTION_PROTOS_REQUIRE_RADIUS.includes(e.target.value)) { - /* - if (isPasspoint) { - onRadiusChange(DEFAULT_PASSPOINT_RADIUS); - } else { - onRadiusChange(INTERFACE_SSID_RADIUS_SCHEMA(t, true).cast()); - } */ onRadiusChange(INTERFACE_SSID_RADIUS_SCHEMA(t, true).cast()); } else { onRadiusChange(undefined); @@ -80,6 +76,7 @@ const Encryption = ({ isUsingRadius={isUsingRadius} isPasspoint={isPasspoint} canUseRadius={canUseRadius} + acceptedEncryptionProtos={acceptedEncryptionProtos} /> ); }; diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedEncryption.jsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedEncryption.jsx index 90d1088..585d7e4 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedEncryption.jsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedEncryption.jsx @@ -12,9 +12,10 @@ import StringField from 'components/FormFields/StringField'; const propTypes = { data: PropTypes.instanceOf(Object).isRequired, + isUsingRadiusEndpoint: PropTypes.bool, }; -const LockedEncryption = ({ data }) => { +const LockedEncryption = ({ data, isUsingRadiusEndpoint }) => { if (!data) return null; return ( @@ -73,7 +74,7 @@ const LockedEncryption = ({ data }) => { Radius - + - - - Accounting - - {data?.radius?.accounting && ( - - - - - + <> + + + Accounting + + + + + + + + )} - - - Accounting - - {data?.radius?.['dynamic-authorization'] && ( - - - - - + <> + + + + + + + )} diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedPasspoint.jsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedPasspoint.jsx index dc6c58a..ea5a62d 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedPasspoint.jsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedPasspoint.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Flex, Heading, Image, NumberInputField, SimpleGrid } from '@chakra-ui/react'; +import { Flex, Heading, Image, SimpleGrid } from '@chakra-ui/react'; import PropTypes from 'prop-types'; import { INTERFACE_PASSPOINT_ICONS_SCHEMA } from '../../interfacesConstants'; import DisplayNumberField from 'components/DisplayFields/DisplayNumberField'; @@ -35,7 +35,7 @@ const LockedPasspoint = ({ data }) => { () => ( <> - + diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedSsid.jsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedSsid.jsx index fcff023..e59f111 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedSsid.jsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/LockedSsid.jsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { SimpleGrid, useToast } from '@chakra-ui/react'; +import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, SimpleGrid, useToast } from '@chakra-ui/react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import LockedAdvanced from './LockedAdvanced'; @@ -8,7 +8,9 @@ import LockedPasspoint from './LockedPasspoint'; import DisplayMultiSelectField from 'components/DisplayFields/DisplayMultiSelectField'; import DisplaySelectField from 'components/DisplayFields/DisplaySelectField'; import DisplayStringField from 'components/DisplayFields/DisplayStringField'; +import { useGetRadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; import { useGetResource } from 'hooks/Network/Resources'; +import useRadiusEndpointAccountModal from 'pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/useEditModal'; const propTypes = { variableBlockId: PropTypes.string.isRequired, @@ -17,6 +19,7 @@ const propTypes = { const LockedSsid = ({ variableBlockId }) => { const { t } = useTranslation(); const toast = useToast(); + const modal = useRadiusEndpointAccountModal({ hideEdit: true }); const { data: resource } = useGetResource({ t, toast, @@ -31,10 +34,26 @@ const LockedSsid = ({ variableBlockId }) => { return null; }, [resource]); + const getEndpoint = useGetRadiusEndpoint({ + id: data?.radius?.__radiusEndpoint, + }); + if (!data) return null; return ( <> + {modal.modal} + {getEndpoint.data ? ( + modal.openModal(getEndpoint.data)} cursor="pointer" w="max-content"> + + + Custom radius endpoint: {getEndpoint.data.name} + + Click here to view details + + + + ) : null} { isRequired /> - + diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/PassPoint/Form.tsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/PassPoint/Form.tsx index fbc5095..4e6e0ee 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/PassPoint/Form.tsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/PassPoint/Form.tsx @@ -14,16 +14,10 @@ interface Props { namePrefix: string; isEnabled: boolean; onToggle: (event: React.ChangeEvent) => void; + lockConsortium?: boolean; } -const PassPointForm = ( - { - isDisabled, - namePrefix, - isEnabled, - onToggle - }: Props -) => { +const PassPointForm: React.FC = ({ isDisabled, namePrefix, isEnabled, onToggle, lockConsortium }) => { const name = React.useCallback((suffix: string) => `${namePrefix}.${suffix}`, []); const fieldProps = (suffix: string) => ({ @@ -157,7 +151,12 @@ const PassPointForm = ( - + { +const PassPointConfig = ({ isDisabled, namePrefix, radiusPrefix, lockConsortium }: Props) => { const { t } = useTranslation(); const { value, onChange } = useFastField({ name: namePrefix }); const { value: radius } = useFastField({ name: radiusPrefix }); @@ -36,7 +37,15 @@ const PassPointConfig = ({ isDisabled, namePrefix, radiusPrefix }: Props) => { [onChange, radius], ); - return ; + return ( + + ); }; export default React.memo(PassPointConfig); diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SingleSsid.jsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SingleSsid.jsx index 711e5d1..0621171 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SingleSsid.jsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SingleSsid.jsx @@ -3,14 +3,13 @@ import { Box, Flex, Heading, SimpleGrid, Spacer } from '@chakra-ui/react'; import { getIn, useFormikContext } from 'formik'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { INTERFACE_SSID_SCHEMA } from '../../interfacesConstants'; import AdvancedSettings from './AdvancedSettings'; import Encryption from './Encryption'; import LockedSsid from './LockedSsid'; import PassPoint from './PassPoint'; +import SsidResourcePicker from './SsidResourcePicker'; import DeleteButton from 'components/Buttons/DeleteButton'; import CardBody from 'components/Card/CardBody'; -import ConfigurationResourcePicker from 'components/CustomFields/ConfigurationResourcePicker'; import MultiSelectField from 'components/FormFields/MultiSelectField'; import SelectField from 'components/FormFields/SelectField'; import StringField from 'components/FormFields/StringField'; @@ -42,74 +41,71 @@ const SingleSsid = ({ editing, index, namePrefix, remove }) => { #{index} - + - {isUsingCustomRadius ? ( - <> - - - - - - - - + {isUsingCustomRadius ? ( + <> + + + + + + - - - - ) : ( - - )} + + + + + + ) : ( + + )} + ); diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SsidResourcePicker.tsx b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SsidResourcePicker.tsx new file mode 100644 index 0000000..af6f970 --- /dev/null +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/SingleInterface/SsidList/SsidResourcePicker.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Select } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { INTERFACE_SSID_SCHEMA } from '../../interfacesConstants'; +import { useConfigurationContext } from 'contexts/ConfigurationProvider'; +import useFastField from 'hooks/useFastField'; + +type Props = { + name: string; + isDisabled: boolean; +}; + +const SsidResourcePicker = ({ name, isDisabled }: Props) => { + const { t } = useTranslation(); + const context = useConfigurationContext(); + const field = useFastField<{ __variableBlock?: string[] } | undefined>({ name }); + + const availableResources = React.useMemo(() => { + if (context.availableResources) + return context.availableResources + .filter( + (resource) => + resource.variables[0]?.prefix === 'interface.ssid.openroaming' || + resource.variables[0]?.prefix === 'interface.ssid', + ) + .map((resource) => ({ value: resource.id, label: resource.name })); + return []; + }, [context.availableResources?.length]); + + const selectValue = React.useMemo(() => { + if (!field.value || !field.value.__variableBlock) return ''; + return field.value.__variableBlock[0]; + }, [field.value?.__variableBlock]); + + const onChange = React.useCallback((e: React.ChangeEvent) => { + if (e.target.value === '') field.onChange(INTERFACE_SSID_SCHEMA(t, true).cast()); + else { + const newObj = {} as { __variableBlock: string[] }; + newObj.__variableBlock = [e.target.value]; + field.onChange(newObj); + } + }, []); + + return ( + + ); +}; + +export default SsidResourcePicker; diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants.js b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants.js index 6614b7c..b814b53 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants.js +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/InterfaceSection/interfacesConstants.js @@ -763,7 +763,7 @@ export const SINGLE_INTERFACE_SCHEMA = ( .default({ addressing: 'dynamic' }) : INTERFACE_IPV4_SCHEMA(t, useDefault), tunnel: INTERFACE_TUNNEL_SCHEMA(t, useDefault).default(undefined), - ssids: array().of(INTERFACE_SSID_SCHEMA(t, useDefault)).default([]), + ssids: array().of(INTERFACE_SSID_SCHEMA(t, useDefault)).default(undefined), 'hostapd-bss-raw': array().of(string()).default(undefined), }); diff --git a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/UnitSection/unitConstants.js b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/UnitSection/unitConstants.js index 3fc8314..e014c2a 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/UnitSection/unitConstants.js +++ b/src/pages/ConfigurationPage/ConfigurationCard/ConfigurationSectionsCard/UnitSection/unitConstants.js @@ -1,5 +1,5 @@ import { bool, object, number, string } from 'yup'; -import { testAlphanumWithDash } from 'constants/formTests'; +import { testFqdnHostname } from 'constants/formTests'; export const DEFAULT_UNIT = { name: 'Unit', @@ -28,7 +28,7 @@ export const UNIT_SCHEMA = (t) => name: string().required(t('form.required')).default(''), location: string().required(t('form.required')).default(''), hostname: string() - .test('test-hostname-network', t('form.invalid_hostname'), testAlphanumWithDash) + .test('test-hostname-network', t('form.invalid_fqdn_host'), (v) => v === undefined || testFqdnHostname(v)) .default(undefined), timezone: string().default(undefined), 'leds-active': bool().default(true), diff --git a/src/pages/ConfigurationPage/ConfigurationCard/index.jsx b/src/pages/ConfigurationPage/ConfigurationCard/index.jsx index 016f018..175a7f7 100644 --- a/src/pages/ConfigurationPage/ConfigurationCard/index.jsx +++ b/src/pages/ConfigurationPage/ConfigurationCard/index.jsx @@ -18,13 +18,14 @@ import CardBody from 'components/Card/CardBody'; import CardHeader from 'components/Card/CardHeader'; import LoadingOverlay from 'components/LoadingOverlay'; import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; +import { ConfigurationProvider } from 'contexts/ConfigurationProvider'; import { useGetConfiguration, useUpdateConfiguration } from 'hooks/Network/Configurations'; const propTypes = { id: PropTypes.string.isRequired, }; -const tryParse = (value) => { +const try_parse = (value) => { try { return JSON.parse(value); } catch (e) { @@ -77,7 +78,7 @@ const ConfigurationCard = ({ id }) => { if (conf !== 'third-party') deviceConfig.__selected_subcategories = undefined; const config = { ...sections.data[conf].data, configuration: {} }; if (conf === 'interfaces') config.configuration = { interfaces: deviceConfig }; - else if (conf === 'third-party') config.configuration = { 'third-party': tryParse(deviceConfig) }; + else if (conf === 'third-party') config.configuration = { 'third-party': try_parse(deviceConfig) }; else config.configuration[conf] = deviceConfig; return config; }), @@ -140,8 +141,8 @@ const ConfigurationCard = ({ id }) => { return ( <> - - + + {configuration?.name} @@ -182,7 +183,9 @@ const ConfigurationCard = ({ id }) => { )} - + + + ; +const actionCell = (row: GlobalReachAccount, open: (acc: GlobalReachAccount) => void) => ( + +); +const countryCell = (row: GlobalReachAccount) => { + const found = COUNTRY_LIST.find((c) => c.value === row.country); + + return found?.label ?? row.country; +}; +const provinceCell = (row: GlobalReachAccount) => { + const found = State.getStateByCodeAndCountry(row.province, row.country); + + return found?.name ?? row.province; +}; + +const GlobalReachAccountTable = () => { + const { t } = useTranslation(); + const detailsModal = useGlobalAccountModal(); + const tableController = useDataGrid({ + tableSettingsId: 'provisioning.global_reach_roaming.table', + defaultSortBy: [{ id: 'name', desc: false }], + defaultOrder: [ + 'name', + 'modified', + 'country', + 'province', + 'city', + 'organization', + 'commonName', + 'description', + 'actions', + ], + }); + const getAccounts = useGetGlobalReachAccounts(); + + const columns: DataGridColumn[] = React.useMemo( + () => [ + { + id: 'name', + header: t('common.name'), + accessorKey: 'name', + meta: { + customMaxWidth: '200px', + customWidth: 'calc(15vh)', + customMinWidth: '150px', + anchored: true, + alwaysShow: true, + }, + }, + { + id: 'modified', + header: t('common.modified'), + + accessorKey: 'modified', + cell: (cell) => dateCell(cell.row.original.modified), + meta: { + customMinWidth: '150px', + customWidth: '150px', + }, + }, + { + id: 'country', + header: t('roaming.country'), + accessorKey: 'country', + cell: (cell) => countryCell(cell.row.original), + meta: { + customMinWidth: '100px', + customWidth: '100px', + }, + }, + { + id: 'province', + header: t('roaming.province'), + accessorKey: 'province', + cell: (cell) => provinceCell(cell.row.original), + meta: { + customMinWidth: '100px', + customWidth: '100px', + }, + }, + { + id: 'city', + header: t('roaming.city'), + accessorKey: 'city', + meta: { + customMinWidth: '100px', + customWidth: '100px', + }, + }, + { + id: 'organization', + header: t('roaming.organization'), + accessorKey: 'organization', + meta: { + customMinWidth: '100px', + customWidth: '100px', + }, + }, + { + id: 'commonName', + header: t('roaming.common_name'), + accessorKey: 'commonName', + meta: { + customMinWidth: '100px', + customWidth: '100px', + }, + }, + { + id: 'description', + header: t('common.description'), + accessorKey: 'description', + enableSorting: false, + }, + { + id: 'actions', + header: '', + accessorKey: 'id', + cell: (cell) => actionCell(cell.row.original, detailsModal.openModal), + enableSorting: false, + meta: { + customWidth: '80px', + alwaysShow: true, + columnSelectorOptions: { + label: t('common.actions'), + }, + }, + }, + ], + [t], + ); + + return ( + <> + {detailsModal.modal} + + controller={tableController} + header={{ + title: `${t('roaming.account', { count: getAccounts.data?.length ?? 0 })} ${ + getAccounts.data?.length ? `(${getAccounts.data.length})` : '' + }`, + objectListed: t('roaming.account_other'), + addButton: , + }} + columns={columns} + data={getAccounts.data ?? []} + isLoading={getAccounts.isFetching} + options={{ + count: getAccounts.data?.length ?? 0, + onRowClick: (device) => () => detailsModal.openModal(device), + refetch: getAccounts.refetch, + minimumHeight: '200px', + showAsCard: true, + }} + /> + + ); +}; + +export default GlobalReachAccountTable; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/ActionCell.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/ActionCell.tsx new file mode 100644 index 0000000..46f8c14 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/ActionCell.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { + Box, + Button, + Center, + HStack, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + Tooltip, +} from '@chakra-ui/react'; +import { MagnifyingGlass, Trash } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { GlobalReachAccount, useDeleteGlobalReachAccount } from 'hooks/Network/GlobalReach'; +import { useNotification } from 'hooks/useNotification'; + +type Props = { + account: GlobalReachAccount; + openDetailsModal: (account: GlobalReachAccount) => void; +}; + +const ActionCell = ({ account, openDetailsModal }: Props) => { + const { t } = useTranslation(); + const deleteAccount = useDeleteGlobalReachAccount(); + const { successToast, apiErrorToast } = useNotification(); + + const onDelete = (onClose: () => void) => async () => { + await deleteAccount.mutateAsync(account.id, { + onSuccess: () => { + successToast({ + description: t('roaming.account_deleted'), + }); + onClose(); + }, + onError: (error) => { + apiErrorToast({ e: error }); + }, + }); + }; + + return ( + + + {({ onClose, isOpen }) => ( + <> + + + + } size="sm" /> + + + + + + + + {t('crud.delete')} {account.name} + + {t('crud.delete_confirm', { obj: t('roaming.account_one') })} + +
+ + +
+
+
+ + )} +
+ + openDetailsModal(account)} + size="sm" + icon={} + colorScheme="blue" + /> + +
+ ); +}; + +export default ActionCell; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/CreateGlobalReachAccountModal.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/CreateGlobalReachAccountModal.tsx new file mode 100644 index 0000000..a79e8e1 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/CreateGlobalReachAccountModal.tsx @@ -0,0 +1,185 @@ +import * as React from 'react'; +import { Box, Flex, Heading } from '@chakra-ui/react'; +import { Formik, FormikHelpers } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as Yup from 'yup'; +import PrivateKeyField from './PrivateKeyField'; +import StatePicker from './StatePicker'; +import CreateButton from 'components/Buttons/CreateButton'; +import SaveButton from 'components/Buttons/SaveButton'; +import SelectField from 'components/FormFields/SelectField'; +import StringField from 'components/FormFields/StringField'; +import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; +import { Modal } from 'components/Modals/Modal'; +import COUNTRY_LIST from 'constants/countryList'; +import { testPemPrivateKey } from 'constants/formTests'; +import { CreateGlobalReachAccountRequest, useCreateGlobalReachAccount } from 'hooks/Network/GlobalReach'; +import useFormModal from 'hooks/useFormModal'; +import useFormRef from 'hooks/useFormRef'; +import { useNotification } from 'hooks/useNotification'; + +const CreateGlobalReachAccountModal = () => { + const { t } = useTranslation(); + const [formKey, setFormKey] = React.useState(0); + const { form, formRef } = useFormRef(); + const modal = useFormModal({ + isDirty: form.dirty, + onCloseSideEffect: () => { + setFormKey((k) => k + 1); + }, + }); + const { successToast, apiErrorToast } = useNotification(); + const create = useCreateGlobalReachAccount(); + + const onSubmit = async ( + data: CreateGlobalReachAccountRequest, + helpers: FormikHelpers, + ) => { + helpers.setSubmitting(true); + + await create.mutateAsync(data, { + onSuccess: () => { + successToast({ + description: t('roaming.account_created'), + }); + modal.closeCancelAndForm(); + helpers.resetForm(); + helpers.setSubmitting(false); + }, + onError: (error) => { + apiErrorToast({ e: error }); + helpers.setSubmitting(false); + }, + }); + }; + + const FormSchema = React.useMemo( + () => + Yup.object().shape({ + name: Yup.string().required(t('form.required')), + description: Yup.string(), + notes: Yup.array().of(Yup.string()), + privateKey: Yup.string() + .test('test-key', t('roaming.invalid_key'), (v) => testPemPrivateKey(v)) + .required(t('form.required')), + country: Yup.string().required(t('form.required')), + province: Yup.string().required(t('form.required')), + city: Yup.string().required(t('form.required')), + organization: Yup.string().required(t('form.required')), + commonName: Yup.string().required(t('form.required')), + GlobalReachAcctId: Yup.string().required(t('form.required')), + }), + [t], + ); + + const isFieldDisabled = create.isLoading; + + return ( + <> + + } + > + + + + + {t('roaming.account_one')} {t('common.details')} + + + + + + + {' '} + + + {t('roaming.global_reach')} + + + + + + + + + {t('roaming.location_details_title')} + {' '} + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CreateGlobalReachAccountModal; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/ActionCell.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/ActionCell.tsx new file mode 100644 index 0000000..3a604d9 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/ActionCell.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { + Box, + Button, + Center, + HStack, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + Tooltip, +} from '@chakra-ui/react'; +import { Recycle, Trash } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { + GlobalReachCertificate, + useDeleteGlobalReachCertificate, + useRenewGlobalReachCertificate, +} from 'hooks/Network/GlobalReach'; +import { useNotification } from 'hooks/useNotification'; + +type Props = { + certificate: GlobalReachCertificate; +}; + +const GlobalReachCertActionCell = ({ certificate }: Props) => { + const { t } = useTranslation(); + const renewCertificate = useRenewGlobalReachCertificate(); + const deleteCertificate = useDeleteGlobalReachCertificate(); + const { successToast, apiErrorToast } = useNotification(); + + const onDelete = (onClose: () => void) => async () => { + await deleteCertificate.mutateAsync( + { id: certificate.id, accountId: certificate.accountId }, + { + onSuccess: () => { + successToast({ + description: t('roaming.certificate_deleted'), + }); + onClose(); + }, + onError: (error) => { + apiErrorToast({ e: error }); + }, + }, + ); + }; + + const onRenew = async () => { + await renewCertificate.mutateAsync( + { id: certificate.id, accountId: certificate.accountId }, + { + onSuccess: () => { + successToast({ + description: 'Recreated certificate!', + }); + }, + onError: (error) => { + apiErrorToast({ e: error }); + }, + }, + ); + }; + + return ( + + + {({ onClose, isOpen }) => ( + <> + + + + } size="sm" /> + + + + + + + + {t('crud.delete')} {certificate.name} + + {t('crud.delete_confirm', { obj: t('roaming.certificate_one') })} + +
+ + +
+
+
+ + )} +
+ + } + size="sm" + isLoading={renewCertificate.isLoading} + onClick={onRenew} + /> + +
+ ); +}; + +export default GlobalReachCertActionCell; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/AddButton.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/AddButton.tsx new file mode 100644 index 0000000..25beec9 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/AddButton.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { + Box, + Button, + Center, + Heading, + Input, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + useDisclosure, +} from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import CreateButton from 'components/Buttons/CreateButton'; +import { GlobalReachAccount, useCreateGlobalReachCertificate } from 'hooks/Network/GlobalReach'; +import { useNotification } from 'hooks/useNotification'; + +type Props = { + account: GlobalReachAccount; +}; + +const CreateGlobalReachCertificateButton = ({ account }: Props) => { + const { t } = useTranslation(); + const popoverProps = useDisclosure(); + const create = useCreateGlobalReachCertificate(); + const [name, setName] = React.useState(''); + const { successToast, apiErrorToast } = useNotification(); + + const handleCreate = () => { + create.mutate( + { + accountId: account.id, + name, + }, + { + onSuccess: () => { + successToast({ + description: 'Certificate created successfully', + }); + popoverProps.onClose(); + setName(''); + }, + onError: (e) => { + apiErrorToast({ e }); + }, + }, + ); + }; + + React.useEffect(() => { + setName(''); + }, [popoverProps.isOpen]); + + return ( + + + + + + + + + + + {t('crud.create')} {t('certificates.certificate')} + + + What should be this certificate's name? + setName(e.target.value)} /> + + +
+ + +
+
+
+
+ ); +}; + +export default CreateGlobalReachCertificateButton; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CertificatesTable.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CertificatesTable.tsx new file mode 100644 index 0000000..f8004bd --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CertificatesTable.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import GlobalReachCertActionCell from './ActionCell'; +import CreateGlobalReachCertificateButton from './AddButton'; +import CopyCell from './CopyCell'; +import { DataGrid } from 'components/DataGrid'; +import { DataGridColumn, useDataGrid } from 'components/DataGrid/useDataGrid'; +import FormattedDate from 'components/FormattedDate'; +import { GlobalReachAccount, GlobalReachCertificate, useGetGlobalReachCertificates } from 'hooks/Network/GlobalReach'; + +const dateCell = (date: number) => ; +const copyCell = (value: string) => ; +const actionCell = (row: GlobalReachCertificate) => ; + +type Props = { + account: GlobalReachAccount; + isSubTable?: boolean; +}; + +const CertificatesTable = ({ account, isSubTable }: Props) => { + const { t } = useTranslation(); + const getCertificates = useGetGlobalReachCertificates(account.id); + const tableController = useDataGrid({ + tableSettingsId: 'provisioning.global_reach_roaming_certs.table', + defaultSortBy: [{ id: 'name', desc: false }], + defaultOrder: [ + 'name', + 'created', + 'expiresAt', + 'csr', + 'certificate', + 'certificateChain', + 'certificateId', + 'actions', + ], + }); + + const columns: DataGridColumn[] = React.useMemo( + () => [ + { + id: 'name', + header: t('common.name'), + accessorKey: 'name', + meta: { + customMaxWidth: '200px', + customWidth: 'calc(15vh)', + customMinWidth: '150px', + anchored: true, + alwaysShow: true, + }, + }, + { + id: 'created', + header: t('common.created'), + + accessorKey: 'created', + cell: (cell) => dateCell(cell.row.original.created), + meta: { + customMinWidth: '150px', + customWidth: '150px', + }, + }, + { + id: 'expires', + header: 'Expires', + + accessorKey: 'created', + cell: (cell) => dateCell(cell.row.original.expiresAt), + meta: { + customMinWidth: '150px', + customWidth: '150px', + }, + }, + { + id: 'csr', + header: 'CSR', + accessorKey: 'csr', + cell: (cell) => copyCell(cell.row.original.csr), + meta: { + customMaxWidth: '50px', + customWidth: '50px', + customMinWidth: '50px', + }, + }, + { + id: 'certificate', + header: 'Cert', + accessorKey: 'certificate', + cell: (cell) => copyCell(cell.row.original.certificate), + meta: { + customMaxWidth: '60px', + customWidth: '60px', + customMinWidth: '60px', + }, + }, + { + id: 'certificateChain', + header: 'Cert Chain', + accessorKey: 'certificateChain', + cell: (cell) => copyCell(cell.row.original.certificateChain), + meta: { + customMaxWidth: '120px', + customWidth: '120px', + customMinWidth: '120px', + }, + }, + { + id: 'actions', + header: '', + accessorKey: 'id', + cell: (cell) => actionCell(cell.row.original), + enableSorting: false, + meta: { + customWidth: '10px', + alwaysShow: true, + columnSelectorOptions: { + label: t('common.actions'), + }, + }, + }, + ], + [t], + ); + return ( + + controller={tableController} + header={{ + title: isSubTable + ? '' + : `${t('roaming.certificate', { count: getCertificates.data?.length ?? 0 })} ${ + getCertificates.data?.length ? `(${getCertificates.data.length})` : '' + }`, + objectListed: t('roaming.certificate_other'), + addButton: isSubTable ? null : , + }} + columns={isSubTable ? columns.filter(({ id }) => id !== 'actions') : columns} + data={getCertificates.data ?? []} + isLoading={getCertificates.isFetching} + options={{ + count: getCertificates.data?.length ?? 0, + // onRowClick: (device) => () => detailsModal.openModal(device), + refetch: getCertificates.refetch, + minimumHeight: '200px', + }} + /> + ); +}; + +export default CertificatesTable; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell.tsx new file mode 100644 index 0000000..830266b --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { CopyIcon } from '@chakra-ui/icons'; +import { Button, Center, IconButton, Tooltip, useClipboard } from '@chakra-ui/react'; + +type Props = { + value: string; + isCompact?: boolean; +}; + +const CopyCell = ({ value, isCompact }: Props) => { + const copy = useClipboard(value); + + if (isCompact) { + return ( + + } + /> + + ); + } + + return ( +
+ +
+ ); +}; + +export default CopyCell; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/index.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/index.tsx new file mode 100644 index 0000000..d5584e8 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/index.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { + Box, + Button, + Flex, + Heading, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + UseDisclosureReturn, + useClipboard, +} from '@chakra-ui/react'; +import { State } from 'country-state-city'; +import { useTranslation } from 'react-i18next'; +import CertificatesTable from './CertificatesTable'; +import { Modal } from 'components/Modals/Modal'; +import COUNTRY_LIST from 'constants/countryList'; +import { GlobalReachAccount } from 'hooks/Network/GlobalReach'; + +const labelProps = { + mr: 2, +} as const; + +type Props = { + account: GlobalReachAccount; + modalProps: UseDisclosureReturn; +}; + +const DetailsModal = ({ account, modalProps }: Props) => { + const { t } = useTranslation(); + const privateKeyCopy = useClipboard(account.privateKey); + const csrCopy = useClipboard(account.CSR); + + const state = () => { + const found = State.getStateByCodeAndCountry(account.province, account.country); + + return found?.name ?? account.province; + }; + + React.useEffect(() => { + privateKeyCopy.setValue(account.privateKey); + csrCopy.setValue(account.CSR); + }, [account.privateKey]); + + return ( + + + + + {t('common.details')} + {t('certificates.title')} + + + + + {t('roaming.account_one')} {t('common.details')} + + + + {t('common.name')}: + + {account.name} + + + + {t('roaming.common_name')}: + + {account.commonName} + + + + {t('roaming.global_reach')} + + + + {t('roaming.global_reach_account_id')}: + + {account.GlobalReachAcctId} + + + + {t('roaming.private_key')}: + + + + + + CSR: + + + + + {t('roaming.location_details_title')} + + + {account.city}, {state()},{' '} + {COUNTRY_LIST.find((acc) => acc.value === account.country)?.label ?? account.country} + + + + {t('roaming.organization')}: + + {account.organization} + + + + + + + + + + ); +}; + +export default DetailsModal; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/useEditModal.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/useEditModal.tsx new file mode 100644 index 0000000..7cb3f4d --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/DetailsModal/useEditModal.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { useDisclosure } from '@chakra-ui/react'; +import DetailsModal from '.'; +import { GlobalReachAccount } from 'hooks/Network/GlobalReach'; + +const useGlobalAccountModal = () => { + const [account, setAccount] = React.useState(null); + const modalProps = useDisclosure(); + + const openModal = (newAcc: GlobalReachAccount) => { + setAccount(newAcc); + modalProps.onOpen(); + }; + + return React.useMemo( + () => ({ + openModal, + modal: account ? : null, + }), + [account, modalProps], + ); +}; + +export default useGlobalAccountModal; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/PrivateKeyField.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/PrivateKeyField.tsx new file mode 100644 index 0000000..f231f67 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/PrivateKeyField.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import FileInputButton from 'components/Buttons/FileInputButton'; +import useFastField from 'hooks/useFastField'; + +type Props = { + isDisabled?: boolean; +}; + +const PrivateKeyField = ({ isDisabled }: Props) => { + const { t } = useTranslation(); + const [refreshId, setRefreshId] = React.useState(uuid()); + const privateKey = useFastField({ name: 'privateKey' }); + + React.useEffect(() => { + setRefreshId(uuid()); + }, [privateKey.value]); + + return ( + + {t('roaming.private_key')} + + {privateKey.error} + + ); +}; + +export default PrivateKeyField; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/StatePicker.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/StatePicker.tsx new file mode 100644 index 0000000..1ba1810 --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/StatePicker.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { State } from 'country-state-city'; +import { useTranslation } from 'react-i18next'; +import SelectField from 'components/FormFields/SelectField'; +import StringField from 'components/FormFields/StringField'; +import useFastField from 'hooks/useFastField'; + +type Props = { + isDisabled?: boolean; +}; + +const StatePicker = ({ isDisabled }: Props) => { + const { t } = useTranslation(); + const country = useFastField({ name: 'country' }); + const province = useFastField({ name: 'province' }); + const [options, setOptions] = React.useState<{ label: string; value: string }[]>([]); + + React.useEffect(() => { + if (country.value) { + const states = State.getStatesOfCountry(country.value); + setOptions(states.map((s) => ({ label: s.name, value: s.isoCode }))); + if (states[0]) province.onChange(states[0].isoCode); + } + }, [country.value]); + + if (options.length === 0) + return ( + + ); + + return ( + + ); +}; + +export default StatePicker; diff --git a/src/pages/OpenRoamingPage/GlobalReachPage/index.tsx b/src/pages/OpenRoamingPage/GlobalReachPage/index.tsx new file mode 100644 index 0000000..34f480f --- /dev/null +++ b/src/pages/OpenRoamingPage/GlobalReachPage/index.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import GlobalReachAccountTable from './AccountTable'; + +const GlobalReachPage = () => ; + +export default GlobalReachPage; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/AccountTable.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/AccountTable.tsx new file mode 100644 index 0000000..6f766ab --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/AccountTable.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import GoogleOrionAccountActionCell from './ActionCell'; +import CreateGoogleOrionAccountModal from './CreateGoogleOrionAccountModal'; +import useGoogleOrionAccountModal from './DetailsModal/useEditModal'; +import { DataGrid } from 'components/DataGrid'; +import { DataGridColumn, useDataGrid } from 'components/DataGrid/useDataGrid'; +import FormattedDate from 'components/FormattedDate'; +import { GoogleOrionAccount, useGetGoogleOrionAccounts } from 'hooks/Network/GoogleOrion'; + +const dateCell = (date: number) => ; +const actionCell = (row: GoogleOrionAccount, open: (acc: GoogleOrionAccount) => void) => ( + +); + +const GoogleOrionAccountTable = () => { + const { t } = useTranslation(); + const detailsModal = useGoogleOrionAccountModal(); + const tableController = useDataGrid({ + tableSettingsId: 'provisioning.google_orion_roaming.table', + defaultSortBy: [{ id: 'name', desc: false }], + defaultOrder: ['name', 'modified', 'description', 'actions'], + }); + const getAccounts = useGetGoogleOrionAccounts(); + + const columns: DataGridColumn[] = React.useMemo( + () => [ + { + id: 'name', + header: t('common.name'), + accessorKey: 'name', + meta: { + customMaxWidth: '200px', + customWidth: 'calc(15vh)', + customMinWidth: '150px', + anchored: true, + alwaysShow: true, + }, + }, + { + id: 'modified', + header: t('common.modified'), + + accessorKey: 'modified', + cell: (cell) => dateCell(cell.row.original.modified), + meta: { + customMinWidth: '150px', + customWidth: '150px', + }, + }, + { + id: 'description', + header: t('common.description'), + accessorKey: 'description', + enableSorting: false, + }, + { + id: 'actions', + header: '', + accessorKey: 'id', + cell: (cell) => actionCell(cell.row.original, detailsModal.openModal), + enableSorting: false, + meta: { + customWidth: '80px', + alwaysShow: true, + columnSelectorOptions: { + label: t('common.actions'), + }, + }, + }, + ], + [t], + ); + + return ( + <> + {detailsModal.modal} + + controller={tableController} + header={{ + title: `${t('roaming.account', { count: getAccounts.data?.length ?? 0 })} ${ + getAccounts.data?.length ? `(${getAccounts.data.length})` : '' + }`, + objectListed: t('roaming.account_other'), + addButton: , + }} + columns={columns} + data={getAccounts.data ?? []} + isLoading={getAccounts.isFetching} + options={{ + count: getAccounts.data?.length ?? 0, + onRowClick: (device) => () => detailsModal.openModal(device), + refetch: getAccounts.refetch, + minimumHeight: '200px', + showAsCard: true, + }} + /> + + ); +}; + +export default GoogleOrionAccountTable; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/ActionCell.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/ActionCell.tsx new file mode 100644 index 0000000..2e9bc1d --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/ActionCell.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { + Box, + Button, + Center, + HStack, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + Tooltip, +} from '@chakra-ui/react'; +import { MagnifyingGlass, Trash } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { GoogleOrionAccount, useDeleteGoogleOrionAccount } from 'hooks/Network/GoogleOrion'; +import { useNotification } from 'hooks/useNotification'; + +type Props = { + account: GoogleOrionAccount; + openDetailsModal: (account: GoogleOrionAccount) => void; +}; + +const GoogleOrionAccountActionCell = ({ account, openDetailsModal }: Props) => { + const { t } = useTranslation(); + const deleteAccount = useDeleteGoogleOrionAccount(); + const { successToast, apiErrorToast } = useNotification(); + + const onDelete = (onClose: () => void) => async () => { + await deleteAccount.mutateAsync(account.id, { + onSuccess: () => { + successToast({ + description: t('roaming.account_deleted'), + }); + onClose(); + }, + onError: (error) => { + apiErrorToast({ e: error }); + }, + }); + }; + + return ( + + + {({ onClose, isOpen }) => ( + <> + + + + } size="sm" /> + + + + + + + + {t('crud.delete')} {account.name} + + {t('crud.delete_confirm', { obj: t('roaming.account_one') })} + +
+ + +
+
+
+ + )} +
+ + openDetailsModal(account)} + size="sm" + icon={} + colorScheme="blue" + /> + +
+ ); +}; + +export default GoogleOrionAccountActionCell; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/CaCertificateField.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/CaCertificateField.tsx new file mode 100644 index 0000000..6943cdb --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/CaCertificateField.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { InfoIcon } from '@chakra-ui/icons'; +import { + FormControl, + FormErrorMessage, + FormLabel, + IconButton, + ListItem, + Tooltip, + UnorderedList, +} from '@chakra-ui/react'; +import { Trash } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import FileInputButton from 'components/Buttons/FileInputButton'; +import useFastField from 'hooks/useFastField'; + +type Props = { + name?: string; + isDisabled?: boolean; +}; + +const GoogleOrionCaCertificateField = ({ name, isDisabled }: Props) => { + const { t } = useTranslation(); + const [refreshId, setRefreshId] = React.useState(uuid()); + const field = useFastField<{ value: string; filename: string }[]>({ name: name ?? 'temporaryCerts' }); + + const onAdd = (v: string, file?: File) => { + if (!file) return; + + field.onChange([...field.value, { value: v, filename: file.name }]); + setRefreshId(uuid()); + }; + + const onRemove = (index: number) => { + field.onChange(field.value.filter((_: unknown, i: number) => i !== index)); + setRefreshId(uuid()); + }; + + return ( + <> + + + CA Certificates ({field.value?.length}) + {name ? null : ( + + + + )} + + + You need to upload at least one file to CA Certs + + + {field.value.map((v: { filename: string }, i: number) => ( + + {v.filename} + + } + size="sm" + ml={2} + colorScheme="red" + onClick={() => onRemove(i)} + /> + + + ))} + + + ); +}; + +export default GoogleOrionCaCertificateField; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/CertificateField.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/CertificateField.tsx new file mode 100644 index 0000000..778518f --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/CertificateField.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import FileInputButton from 'components/Buttons/FileInputButton'; +import useFastField from 'hooks/useFastField'; + +type Props = { + name?: string; + isDisabled?: boolean; +}; + +const GoogleOrionCertificateField = ({ isDisabled, name }: Props) => { + const { t } = useTranslation(); + const [refreshId, setRefreshId] = React.useState(uuid()); + const certificate = useFastField({ name: name ?? 'certificate' }); + + React.useEffect(() => { + setRefreshId(uuid()); + }, [certificate.value]); + + return ( + + {t('certificates.certificate')} + + {certificate.error} + + ); +}; + +export default GoogleOrionCertificateField; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/CreateGoogleOrionAccountModal.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/CreateGoogleOrionAccountModal.tsx new file mode 100644 index 0000000..fe01576 --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/CreateGoogleOrionAccountModal.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { Box, Flex, Heading } from '@chakra-ui/react'; +import { Formik, FormikHelpers } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as Yup from 'yup'; +import GoogleOrionCaCertificateField from './CaCertificateField'; +import GoogleOrionCertificateField from './CertificateField'; +import GoogleOrionPrivateKeyField from './PrivateKeyField'; +import CreateButton from 'components/Buttons/CreateButton'; +import SaveButton from 'components/Buttons/SaveButton'; +import StringField from 'components/FormFields/StringField'; +import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; +import { Modal } from 'components/Modals/Modal'; +import { testPemCertificate, testPemPrivateKey } from 'constants/formTests'; +import { CreateGoogleOrionAccountRequest, useCreateGoogleOrionAccount } from 'hooks/Network/GoogleOrion'; +import useFormModal from 'hooks/useFormModal'; +import useFormRef from 'hooks/useFormRef'; +import { useNotification } from 'hooks/useNotification'; + +const CreateGoogleOrionAccountModal = () => { + const { t } = useTranslation(); + const [formKey, setFormKey] = React.useState(0); + const { form, formRef } = useFormRef< + CreateGoogleOrionAccountRequest & { temporaryCerts: { value: string; filename: string }[] } + >(); + const modal = useFormModal({ + isDirty: form.dirty, + onCloseSideEffect: () => { + setFormKey((k) => k + 1); + }, + }); + const { successToast, apiErrorToast } = useNotification(); + const create = useCreateGoogleOrionAccount(); + + const onSubmit = async ( + data: CreateGoogleOrionAccountRequest & { temporaryCerts: { value: string; filename: string }[] }, + helpers: FormikHelpers, + ) => { + helpers.setSubmitting(true); + + const finalData = data; + finalData.cacerts = data.temporaryCerts.map((v) => v.value); + + await create.mutateAsync(finalData, { + onSuccess: () => { + successToast({ + description: t('roaming.account_created'), + }); + modal.closeCancelAndForm(); + helpers.resetForm(); + helpers.setSubmitting(false); + }, + onError: (error) => { + apiErrorToast({ e: error }); + helpers.setSubmitting(false); + }, + }); + }; + + const FormSchema = React.useMemo( + () => + Yup.object().shape({ + name: Yup.string().required(t('form.required')), + description: Yup.string(), + notes: Yup.array().of(Yup.string()), + privateKey: Yup.string() + .test('test-key', t('roaming.invalid_key'), (v) => testPemPrivateKey(v, true)) + .required(t('form.required')), + certificate: Yup.string() + .test('test-certificate', t('roaming.invalid_certificate'), (v) => testPemCertificate(v, true)) + .required(t('form.required')), + temporaryCerts: Yup.array().of(Yup.object()).min(1).required(t('form.required')), + }), + [t], + ); + + const isFieldDisabled = create.isLoading; + + return ( + <> + + + } + > + + + + + {t('roaming.account_one')} {t('common.details')} + + + + + + + Google Orion + + + + + + + + + + + + + + + + + ); +}; + +export default CreateGoogleOrionAccountModal; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/DetailsModal/index.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/DetailsModal/index.tsx new file mode 100644 index 0000000..935266d --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/DetailsModal/index.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { Box, Flex, Heading, ListItem, Text, UnorderedList, UseDisclosureReturn, useBoolean } from '@chakra-ui/react'; +import { Formik, FormikHelpers } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import SaveButton from 'components/Buttons/SaveButton'; +import ToggleEditButton from 'components/Buttons/ToggleEditButton'; +import StringField from 'components/FormFields/StringField'; +import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; +import { Modal } from 'components/Modals/Modal'; +import { + GoogleOrionAccount, + UpdateGoogleOrionAccountRequest, + useUpdateGoogleOrionAccount, +} from 'hooks/Network/GoogleOrion'; +import useFormModal from 'hooks/useFormModal'; +import useFormRef from 'hooks/useFormRef'; +import { useNotification } from 'hooks/useNotification'; +import CopyCell from 'pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell'; + +type Props = { + modalProps: UseDisclosureReturn; + account: GoogleOrionAccount; +}; +const GoogleOrionAccountEditModal = ({ modalProps, account }: Props) => { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useBoolean(); + const [formKey, setFormKey] = React.useState(uuid()); + const { form, formRef } = useFormRef(); + const modal = useFormModal({ + isDirty: form.dirty || isEditing, + onCloseSideEffect: () => { + setFormKey(uuid()); + setIsEditing.off(); + modalProps.onClose(); + }, + }); + const { successToast, apiErrorToast } = useNotification(); + const update = useUpdateGoogleOrionAccount(); + + const onSubmit = async ( + data: UpdateGoogleOrionAccountRequest, + helpers: FormikHelpers, + ) => { + helpers.setSubmitting(true); + + await update.mutateAsync(data, { + onSuccess: () => { + successToast({ + description: t('roaming.account_created'), + }); + modal.closeCancelAndForm(); + helpers.resetForm(); + helpers.setSubmitting(false); + }, + onError: (error) => { + apiErrorToast({ e: error }); + helpers.setSubmitting(false); + }, + }); + }; + + const FormSchema = React.useMemo( + () => + Yup.object().shape({ + name: Yup.string().required(t('form.required')), + description: Yup.string(), + notes: Yup.array().of(Yup.string()), + }), + [t], + ); + + const isFieldDisabled = update.isLoading || !isEditing; + + React.useEffect(() => { + if (!modalProps.isOpen) return; + + modal.onOpen(); + }, [modalProps.isOpen]); + + React.useEffect(() => { + setFormKey(uuid()); + }, [isEditing]); + + return ( + <> + + + + + ); +}; + +export default GoogleOrionAccountEditModal; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/DetailsModal/useEditModal.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/DetailsModal/useEditModal.tsx new file mode 100644 index 0000000..cbb5faf --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/DetailsModal/useEditModal.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { useDisclosure } from '@chakra-ui/react'; +import DetailsModal from '.'; +import { GoogleOrionAccount } from 'hooks/Network/GoogleOrion'; + +const useGoogleOrionAccountModal = () => { + const [account, setAccount] = React.useState(); + const modalProps = useDisclosure(); + + const openModal = (newAcc: GoogleOrionAccount) => { + setAccount(newAcc); + modalProps.onOpen(); + }; + + return React.useMemo( + () => ({ + openModal, + modal: account ? : null, + }), + [account, modalProps], + ); +}; + +export default useGoogleOrionAccountModal; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/PrivateKeyField.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/PrivateKeyField.tsx new file mode 100644 index 0000000..19dd575 --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/PrivateKeyField.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import FileInputButton from 'components/Buttons/FileInputButton'; +import useFastField from 'hooks/useFastField'; + +type Props = { + name?: string; + isDisabled?: boolean; +}; + +const GoogleOrionPrivateKeyField = ({ name, isDisabled }: Props) => { + const { t } = useTranslation(); + const [refreshId, setRefreshId] = React.useState(uuid()); + const privateKey = useFastField({ name: name ?? 'privateKey' }); + + React.useEffect(() => { + setRefreshId(uuid()); + }, [privateKey.value]); + + return ( + + {t('roaming.private_key')} + + {privateKey.error} + + ); +}; + +export default GoogleOrionPrivateKeyField; diff --git a/src/pages/OpenRoamingPage/GoogleOrionPage/index.tsx b/src/pages/OpenRoamingPage/GoogleOrionPage/index.tsx new file mode 100644 index 0000000..8557e13 --- /dev/null +++ b/src/pages/OpenRoamingPage/GoogleOrionPage/index.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import GoogleOrionAccountTable from './AccountTable'; + +const GoogleOrionPage = () => ; + +export default GoogleOrionPage; diff --git a/src/pages/OpenRoamingPage/index.tsx b/src/pages/OpenRoamingPage/index.tsx new file mode 100644 index 0000000..b997282 --- /dev/null +++ b/src/pages/OpenRoamingPage/index.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Box, Tab, TabList, TabPanel, TabPanels, Tabs, useColorMode } from '@chakra-ui/react'; +import GlobalReachAccountTable from './GlobalReachPage/AccountTable'; +import GoogleOrionPage from './GoogleOrionPage'; + +const OpenRoamingPage = () => { + const { colorMode } = useColorMode(); + + const isLight = colorMode === 'light'; + + const tabStyle = { + textColor: isLight ? 'var(--chakra-colors-blue-600)' : 'var(--chakra-colors-blue-300)', + fontWeight: 'semibold', + borderWidth: '0px', + marginBottom: '-1px', + borderBottom: '2px solid', + }; + + return ( + + + Global Reach + Google Orion + + + + + + + + + + + + + + + ); +}; + +export default OpenRoamingPage; diff --git a/src/pages/Profile/GeneralInformation.tsx b/src/pages/Profile/GeneralInformation.tsx index 9209ac5..062c8c8 100644 --- a/src/pages/Profile/GeneralInformation.tsx +++ b/src/pages/Profile/GeneralInformation.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ExternalLinkIcon } from '@chakra-ui/icons'; -import { Box, Center, Flex, Heading, Link, Spacer, Spinner, useToast } from '@chakra-ui/react'; +import { Box, Center, Flex, HStack, Heading, Link, Spacer, Spinner } from '@chakra-ui/react'; import { Form, Formik, FormikProps } from 'formik'; import { useTranslation } from 'react-i18next'; import { v4 as uuid } from 'uuid'; @@ -18,11 +18,11 @@ import { useUpdateAccount } from 'hooks/Network/Account'; import useApiRequirements from 'hooks/useApiRequirements'; import useFormModal from 'hooks/useFormModal'; import useFormRef from 'hooks/useFormRef'; +import { useNotification } from 'hooks/useNotification'; const FormSchema = (t: (str: string) => string, { passRegex }: { passRegex: string }) => Yup.object().shape({ - firstName: Yup.string().required(t('form.required')), - lastName: Yup.string().required(t('form.required')), + name: Yup.string().required(t('form.required')), newPassword: Yup.string() .notRequired() .test('password', t('form.invalid_password'), (v) => testRegex(v, passRegex)), @@ -36,7 +36,7 @@ const FormSchema = (t: (str: string) => string, { passRegex }: { passRegex: stri const GeneralInformationProfile = () => { const { t } = useTranslation(); - const toast = useToast(); + const { successToast, apiErrorToast } = useNotification(); const { passwordPattern, passwordPolicyLink } = useApiRequirements(); const { user } = useAuth(); const updateUser = useUpdateAccount({}); @@ -70,13 +70,15 @@ const GeneralInformationProfile = () => { {t('profile.your_profile')} - {!user ? ( @@ -86,8 +88,7 @@ const GeneralInformationProfile = () => { ) : ( key={formKey} @@ -95,12 +96,10 @@ const GeneralInformationProfile = () => { { email: user?.email, description: user?.description ?? '', - firstName: user?.name.split(' ')[0] ?? '', - lastName: user?.name.split(' ')[1] ?? '', + name: user?.name ?? '', } as { description: string; - firstName: string; - lastName: string; + name: string; newPassword?: string; } } @@ -108,35 +107,35 @@ const GeneralInformationProfile = () => { formRef as React.Ref< FormikProps<{ description: string; - firstName: string; - lastName: string; + name: string; newPassword?: string; }> > } validationSchema={FormSchema(t, { passRegex: passwordPattern })} - onSubmit={async ({ description, firstName, lastName, newPassword }, { setSubmitting }) => { + onSubmit={async ({ description, name, newPassword }, { setSubmitting }) => { await updateUser.mutateAsync( { id: user?.id, description, - name: `${firstName} ${lastName}`, + name, currentPassword: newPassword, }, { onSuccess: () => { setSubmitting(false); closeCancelAndForm(); - toast({ + successToast({ id: 'account-update-success', - title: t('common.success'), description: t('crud.success_update_obj', { obj: t('profile.your_profile'), }), - status: 'success', - duration: 5000, - isClosable: true, - position: 'top-right', + }); + }, + onError: (e) => { + apiErrorToast({ + id: 'account-update-error', + e, }); }, }, @@ -145,29 +144,16 @@ const GeneralInformationProfile = () => { > {({ isSubmitting }) => (
- - - + + - { hideButton /> + {t('login.password_policy')} diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/DetailsStep.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/DetailsStep.tsx new file mode 100644 index 0000000..d75a3c3 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/DetailsStep.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { Box, Flex, Heading } from '@chakra-ui/react'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as Yup from 'yup'; +import NumberField from 'components/FormFields/NumberField'; +import SelectField from 'components/FormFields/SelectField'; +import StringField from 'components/FormFields/StringField'; +import ToggleField from 'components/FormFields/ToggleField'; +import { GlobalReachAccount } from 'hooks/Network/GlobalReach'; +import { GoogleOrionAccount } from 'hooks/Network/GoogleOrion'; +import { + RADIUS_ENDPOINT_POOL_STRATEGIES, + RADIUS_ENDPOINT_TYPES, + useGetRadiusEndpoints, +} from 'hooks/Network/RadiusEndpoints'; + +const testString = (v: string | undefined) => { + try { + if (!v) return false; + const split = v.split('.'); + + if (split.length !== 4) { + return false; + } + if (split[0] !== '0' || split[1] !== '0') { + return false; + } + + const num1 = parseInt(split[2] ?? '0', 10); + const num2 = parseInt(split[3] ?? '0', 10); + + if (!num1 || num1 < 1 || num1 > 2) { + return false; + } + if (!num2 || num2 < 1 || num2 > 254) { + return false; + } + + return true; + } catch (e) { + return false; + } +}; + +type Props = { + formRef: React.Ref>> | undefined; + finishStep: (v: Record) => void; + orionAccounts: GoogleOrionAccount[]; + globalReachAccounts: GlobalReachAccount[]; +}; + +const CreateRadiusEndpointDetailsStep = ({ formRef, finishStep, orionAccounts, globalReachAccounts }: Props) => { + const { t } = useTranslation(); + const getAccounts = useGetRadiusEndpoints(); + + const testIndexIsUnused = React.useCallback( + (v: string | undefined) => { + if (!v) return false; + if (!getAccounts.data) return true; + + return !getAccounts.data.find((a) => a.Index === v); + }, + [getAccounts.data], + ); + + const FormSchema = React.useMemo( + () => + Yup.object().shape({ + name: Yup.string() + .min(3, 'Must be between 3 and 32 characters') + .max(32, 'Must be between 3 and 32 characters') + .required(t('form.required')) + .default(''), + description: Yup.string().default(''), + Type: Yup.string() + .oneOf(RADIUS_ENDPOINT_TYPES as unknown as string[]) + .required(t('form.required')) + .default('generic'), + PoolStrategy: Yup.string() + .oneOf(RADIUS_ENDPOINT_POOL_STRATEGIES as unknown as string[]) + .required(t('form.required')) + .default('random'), + UseGWProxy: Yup.boolean().default(true), + Index: Yup.string() + .test('index-value', 'Must be between 0.0.1.1 and 0.0.2.254', testString) + .test('unique-index', 'Index must be unique and not used by other endpoints', testIndexIsUnused) + .required(t('form.required')) + .default('0.0.1.1'), + NasIdentifier: Yup.string().default(''), + AccountingInterval: Yup.number().min(0).max(65535).default(60), + }), + [t, testIndexIsUnused], + ); + + type FormValues = Yup.InferType; + + const initialValues: FormValues = FormSchema.cast({}); + + const typeOptions = React.useMemo(() => { + const options = [ + { value: 'generic', label: 'Generic' }, + { value: 'radsec', label: 'RadSec' }, + ]; + + if (orionAccounts.length > 0) { + options.push({ value: 'orion', label: 'Google Orion' }); + } + + if (globalReachAccounts.length > 0) { + options.push({ value: 'globalreach', label: 'Global Reach' }); + } + + return options; + }, [t, orionAccounts, globalReachAccounts]); + + return ( + | null) => void} + initialValues={initialValues} + validateOnMount + validationSchema={FormSchema} + onSubmit={(values: FormValues) => { + finishStep(values); + }} + > + + + {t('common.details')} + + + + + + + + + Endpoint + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CreateRadiusEndpointDetailsStep; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GlobalReach/GlobalReachAccountField.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GlobalReach/GlobalReachAccountField.tsx new file mode 100644 index 0000000..d7e90ca --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GlobalReach/GlobalReachAccountField.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Box, Radio, RadioGroup, Stack } from '@chakra-ui/react'; +import { GlobalReachCertificate } from 'hooks/Network/GlobalReach'; +import useFastField from 'hooks/useFastField'; + +type Props = { + certificates: GlobalReachCertificate[]; + name: string; + isDisabled?: boolean; +}; + +const GlobalReachAccountField = ({ certificates, name, isDisabled }: Props) => { + const field = useFastField<{ UseOpenRoamingAccount: string; Weight: number }[]>({ name }); + + return ( + + + field.onChange([ + { + UseOpenRoamingAccount: v, + Weight: 0, + }, + ]) + } + > + + {certificates.map((certificate) => ( + + {certificate.name} + + ))} + + + + ); +}; + +export default GlobalReachAccountField; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GlobalReach/GlobalReachStep.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GlobalReach/GlobalReachStep.tsx new file mode 100644 index 0000000..8aa8790 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GlobalReach/GlobalReachStep.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { Box, Center, Heading, Select, Spinner } from '@chakra-ui/react'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as Yup from 'yup'; +import GlobalReachAccountField from './GlobalReachAccountField'; +import { GlobalReachAccount, useGetGlobalReachCertificates } from 'hooks/Network/GlobalReach'; + +type Props = { + formRef: React.Ref>> | undefined; + finishStep: (v: Record) => void; + accounts: GlobalReachAccount[]; +}; + +const CreateRadiusEndpointGlobalReachStep = ({ formRef, finishStep, accounts }: Props) => { + const { t } = useTranslation(); + const [selectedAccount, setSelectedAccount] = React.useState(accounts[0] as GlobalReachAccount); + const getCertificates = useGetGlobalReachCertificates(selectedAccount.id); + + const FormSchema = React.useMemo( + () => + Yup.object() + .shape({ + Accounts: Yup.array() + .of( + Yup.object().shape({ + UseOpenRoamingAccount: Yup.string().required(t('form.required')).default(''), + Weight: Yup.number().required(t('form.required')).min(0).max(500).default(0), + }), + ) + .min(1, 'Must select at least one account') + .required(t('form.required')) + .default([]), + }) + .default([]), + [t], + ); + + type FormValues = Yup.InferType; + + const initialValues: FormValues = FormSchema.cast({}); + + return ( + | null) => void} + initialValues={initialValues} + validateOnMount + validationSchema={FormSchema} + onSubmit={async (values: FormValues) => { + await finishStep({ + RadsecServers: values.Accounts, + }); + }} + > + {({ isSubmitting }) => ( + + + What Global Reach account would like to use? + + + + Please choose one or more certificates to use: + + + {getCertificates.data ? ( + + ) : ( +
+ +
+ )} +
+
+ )} +
+ ); +}; + +export default CreateRadiusEndpointGlobalReachStep; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GoogleOrion/OrionAccountField.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GoogleOrion/OrionAccountField.tsx new file mode 100644 index 0000000..11a4030 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GoogleOrion/OrionAccountField.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Box, Radio, RadioGroup, Stack } from '@chakra-ui/react'; +import { GoogleOrionAccount } from 'hooks/Network/GoogleOrion'; +import useFastField from 'hooks/useFastField'; + +type Props = { + accounts: GoogleOrionAccount[]; + name: string; + isDisabled?: boolean; +}; + +const OrionAccountField = ({ accounts, name, isDisabled }: Props) => { + const field = useFastField<{ UseOpenRoamingAccount: string; Weight: number }[]>({ name }); + + return ( + + + field.onChange([ + { + UseOpenRoamingAccount: v, + Weight: 0, + }, + ]) + } + > + + {accounts.map((account) => ( + + {account.name} {account.description ? ` - ${account.description}` : ''} + + ))} + + + + ); +}; + +export default OrionAccountField; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GoogleOrion/OrionStep.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GoogleOrion/OrionStep.tsx new file mode 100644 index 0000000..7f452b3 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/GoogleOrion/OrionStep.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { Box, Heading } from '@chakra-ui/react'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as Yup from 'yup'; +import OrionAccountField from './OrionAccountField'; +import { GoogleOrionAccount } from 'hooks/Network/GoogleOrion'; + +type Props = { + formRef: React.Ref>> | undefined; + finishStep: (v: Record) => void; + accounts: GoogleOrionAccount[]; +}; + +const CreateRadiusEndpointOrionStep = ({ formRef, finishStep, accounts }: Props) => { + const { t } = useTranslation(); + + const FormSchema = React.useMemo( + () => + Yup.object() + .shape({ + Accounts: Yup.array() + .of( + Yup.object().shape({ + UseOpenRoamingAccount: Yup.string().required(t('form.required')).default(''), + Weight: Yup.number().required(t('form.required')).min(0).max(500).default(0), + }), + ) + .min(1, 'Must select at least one account') + .required(t('form.required')) + .default([]), + }) + .default([]), + [t], + ); + + type FormValues = Yup.InferType; + + const initialValues: FormValues = FormSchema.cast({}); + + return ( + | null) => void} + initialValues={initialValues} + validateOnMount + validationSchema={FormSchema} + onSubmit={async (values: FormValues) => { + await finishStep({ + RadsecServers: values.Accounts, + }); + }} + > + {({ isSubmitting }) => ( + + + Please choose one or more Google Orion accounts you would like to use: + + + + )} + + ); +}; + +export default CreateRadiusEndpointOrionStep; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusEndpointServerForm.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusEndpointServerForm.tsx new file mode 100644 index 0000000..a46ebab --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusEndpointServerForm.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { Box, Button, Center } from '@chakra-ui/react'; +import { Plus } from '@phosphor-icons/react'; +import { Formik } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import RadiusServerForm from './RadiusServerForm'; +import NumberField from 'components/FormFields/NumberField'; +import { testIpv4, testIpv6 } from 'constants/formTests'; +import useFormRef from 'hooks/useFormRef'; + +type Props = { + setValue: (field: string, value: object, shouldValidate?: boolean | undefined) => void; + value: object[]; +}; + +const RadiusEndpointServerForm = ({ setValue, value }: Props) => { + const { t } = useTranslation(); + const { form, formRef } = useFormRef(); + const [key, setFormKey] = React.useState(uuid()); + const onAdd = (v: object) => { + setValue('RadiusServers', [...value, v]); + }; + + const resetForm = () => { + setFormKey(uuid()); + }; + + const RadiusServerSchema = React.useMemo( + () => + Yup.object().shape({ + Hostname: Yup.string() + .required(t('form.required')) + .matches( + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$/, + 'Invalid hostname, please check the format (ex.: example.com)', + ), + IP: Yup.string() + .required(t('form.required')) + .test( + 'test-ipv4-ipv6', + 'Invalid IP address, needs to be either IPv4 or IPv6', + (v) => testIpv4(v) || testIpv6(v), + ), + Port: Yup.number().required(t('form.required')).min(1).max(65535).default(1812), + Secret: Yup.string().required(t('form.required')), + }), + [t], + ); + + const RadiusEndpointServerSchema = React.useMemo( + () => + Yup.object() + .shape({ + Authentication: Yup.array().of(RadiusServerSchema).min(1).required(t('form.required')), + Accounting: Yup.array().of(RadiusServerSchema).min(1).required(t('form.required')), + CoA: Yup.array().of(RadiusServerSchema).min(1).required(t('form.required')), + AccountingInterval: Yup.number().required(t('form.required')).min(10).max(500).default(60), + }) + .default({ + Authentication: [ + { + Hostname: '', + IP: '', + Port: 1812, + Secret: '', + }, + ], + Accounting: [ + { + Hostname: '', + IP: '', + Port: 1813, + Secret: '', + }, + ], + CoA: [ + { + Hostname: '', + IP: '', + Port: 1814, + Secret: '', + }, + ], + AccountingInterval: 60, + }), + [t], + ); + + return ( + { + onAdd(values); + resetForm(); + }} + > + + + + + +
+ +
+
+
+ ); +}; + +export default RadiusEndpointServerForm; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusServerForm.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusServerForm.tsx new file mode 100644 index 0000000..6b107f2 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusServerForm.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { Box, Button, Flex, Heading } from '@chakra-ui/react'; +import DeleteButton from 'components/Buttons/DeleteButton'; +import NumberField from 'components/FormFields/NumberField'; +import StringField from 'components/FormFields/StringField'; +import useFastField from 'hooks/useFastField'; + +type Props = { + namePrefix: string; + label: string; +}; + +const RadiusServerForm = ({ namePrefix, label }: Props) => { + const field = useFastField<{ Hostname: string; IP: string; Port: number; Secret: string }[]>({ name: namePrefix }); + + const name = (postfix: string, index: number) => `${namePrefix}[${index}].${postfix}]`; + + const onAdd = () => { + const newValue = [...field.value]; + newValue.push({ Hostname: '', IP: '', Port: 1815, Secret: '' }); + field.onChange(newValue); + }; + const onRemove = (index: number) => { + const newValue = [...field.value]; + newValue.splice(index, 1); + field.onChange(newValue); + }; + + return ( + <> + + + {label} ({field.value.length}) + + + + + {field.value.map((_: unknown, i: number) => ( + + + + #{i} + + onRemove(i)} isDisabled={field.value.length === 1} /> + + + + + + + + + + + + + + + ))} + + + ); +}; + +export default React.memo(RadiusServerForm); diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusStep.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusStep.tsx new file mode 100644 index 0000000..557dbd8 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radius/RadiusStep.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import RadiusEndpointServerForm from './RadiusEndpointServerForm'; +import DeleteButton from 'components/Buttons/DeleteButton'; + +type Props = { + formRef: React.Ref>> | undefined; + finishStep: (v: Record) => void; +}; + +const CreateRadiusEndpointRadiusStep = ({ formRef, finishStep }: Props) => { + const { t } = useTranslation(); + + const FormSchema = React.useMemo( + () => + Yup.object() + .shape({ + RadiusServers: Yup.array() + .of(Yup.object()) + .min(1, 'Must select at least one account') + .required(t('form.required')) + .default([]), + }) + .default({ + RadiusServers: [], + }), + [t], + ); + + type FormValues = Yup.InferType; + + const initialValues: FormValues = FormSchema.cast({}); + + return ( + | null) => void} + initialValues={initialValues} + validateOnMount + validationSchema={FormSchema} + onSubmit={async (values: FormValues) => { + await finishStep({ + RadiusServers: values.RadiusServers, + }); + }} + > + {({ setFieldValue, values }) => ( + + + Please input the information of one or more generic radius servers: + + + + Servers ({values.RadiusServers.length}) + + + {values.RadiusServers.map((v: { Authentication: { Hostname: string }[] }) => ( + + {v.Authentication[0]?.Hostname} + { + setFieldValue( + 'RadiusServers', + values.RadiusServers.filter( + (s: { Authentication: { Hostname: string }[] }) => + s.Authentication[0]?.Hostname !== v.Authentication[0]?.Hostname, + ), + ); + }} + /> + + ))} + + + )} + + ); +}; + +export default CreateRadiusEndpointRadiusStep; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radsec/RadiusStep.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radsec/RadiusStep.tsx new file mode 100644 index 0000000..2792aa9 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radsec/RadiusStep.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import RadiusEndpointServerForm from './RadsecEndpointServerForm'; +import DeleteButton from 'components/Buttons/DeleteButton'; + +type Props = { + formRef: React.Ref>> | undefined; + finishStep: (v: Record) => void; +}; + +const CreateRadiusEndpointRadsecStep = ({ formRef, finishStep }: Props) => { + const { t } = useTranslation(); + + const FormSchema = React.useMemo( + () => + Yup.object() + .shape({ + RadsecServers: Yup.array() + .of(Yup.object()) + .min(1, 'Must select at least one account') + .required(t('form.required')) + .default([]), + }) + .default({ + RadsecServers: [], + }), + [t], + ); + + type FormValues = Yup.InferType; + + const initialValues: FormValues = FormSchema.cast({}); + + return ( + | null) => void} + initialValues={initialValues} + validateOnMount + validationSchema={FormSchema} + onSubmit={async (values: FormValues) => { + await finishStep({ + RadsecServers: values.RadsecServers, + }); + }} + > + {({ setFieldValue, values }) => ( + + + Please input the information of one or more Radsec Servers: + + + + Radsec Servers ({values.RadsecServers.length}) + + + {values.RadsecServers.map((v: { Hostname: string }) => ( + + {v.Hostname} + { + setFieldValue( + 'RadsecServers', + values.RadsecServers.filter((s: { Hostname: string }) => s.Hostname !== v.Hostname), + ); + }} + /> + + ))} + + + )} + + ); +}; + +export default CreateRadiusEndpointRadsecStep; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radsec/RadsecEndpointServerForm.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radsec/RadsecEndpointServerForm.tsx new file mode 100644 index 0000000..51d4203 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/Radsec/RadsecEndpointServerForm.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { Box, Button, Center, Flex, Heading } from '@chakra-ui/react'; +import { Plus } from '@phosphor-icons/react'; +import { Formik } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import NumberField from 'components/FormFields/NumberField'; +import StringField from 'components/FormFields/StringField'; +import ToggleField from 'components/FormFields/ToggleField'; +import { testPemCertificate, testPemPrivateKey } from 'constants/formTests'; +import useFormRef from 'hooks/useFormRef'; +import GoogleOrionCaCertificateField from 'pages/OpenRoamingPage/GoogleOrionPage/CaCertificateField'; +import GoogleOrionCertificateField from 'pages/OpenRoamingPage/GoogleOrionPage/CertificateField'; +import GoogleOrionPrivateKeyField from 'pages/OpenRoamingPage/GoogleOrionPage/PrivateKeyField'; + +type Props = { + setValue: (field: string, value: object, shouldValidate?: boolean | undefined) => void; + value: object[]; +}; + +const RadsecEndpointServerForm = ({ setValue, value }: Props) => { + const { t } = useTranslation(); + const { form, formRef } = useFormRef(); + const [key, setFormKey] = React.useState(uuid()); + const onAdd = (v: object) => { + setValue('RadsecServers', [...value, v]); + }; + + const resetForm = () => { + setFormKey(uuid()); + }; + + const RadiusEndpointServerSchema = React.useMemo( + () => + Yup.object() + .shape({ + Weight: Yup.number().required(t('form.required')).min(1).max(100).default(1), + Hostname: Yup.string().required(t('form.required')), + IP: Yup.string(), + Port: Yup.number().required(t('form.required')).min(1).max(65535).default(2083), + Secret: Yup.string().required(t('form.required')), + PrivateKey: Yup.string() + .test('test-key', t('roaming.invalid_key'), (v) => testPemPrivateKey(v, true)) + .required(t('form.required')) + .default(''), + Certificate: Yup.string() + .test('test-certificate', t('roaming.invalid_certificate'), (v) => testPemCertificate(v, true)) + .required(t('form.required')) + .default(''), + CaCerts: Yup.array().of(Yup.object()).min(1).required(t('form.required')).default([]), + AllowSelfSigned: Yup.boolean().required(t('form.required')).default(false), + }) + .default({ + Weight: 1, + Hostname: '', + IP: '', + Port: 2083, + Secret: 'radsec', + Certificate: '', + PrivateKey: '', + CaCerts: [], + AllowSelfSigned: false, + }), + [t], + ); + + return ( + { + const cleanedCaCerts = values.CaCerts.map((cert: { value: string }) => cert.value); + onAdd({ + ...values, + CaCerts: cleanedCaCerts, + }); + resetForm(); + }} + > + + + Radsec Server + + + + + + + + + + + + + + + + + + + + + + Certificates + + + + + + + {' '} + + + + +
+ +
+
+
+ ); +}; + +export default RadsecEndpointServerForm; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/index.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/index.tsx new file mode 100644 index 0000000..bc58591 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/CreateModal/index.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { Box, IconButton, Tooltip } from '@chakra-ui/react'; +import { ArrowLeft } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import CreateRadiusEndpointDetailsStep from './DetailsStep'; +import CreateRadiusEndpointGlobalReachStep from './GlobalReach/GlobalReachStep'; +import CreateRadiusEndpointOrionStep from './GoogleOrion/OrionStep'; +import CreateRadiusEndpointRadiusStep from './Radius/RadiusStep'; +import CreateRadiusEndpointRadsecStep from './Radsec/RadiusStep'; +import CreateButton from 'components/Buttons/CreateButton'; +import StepButton from 'components/Buttons/StepButton'; +import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; +import { Modal } from 'components/Modals/Modal'; +import { GlobalReachAccount } from 'hooks/Network/GlobalReach'; +import { GoogleOrionAccount } from 'hooks/Network/GoogleOrion'; +import { RadiusEndpoint, useCreateRadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import useFormModal from 'hooks/useFormModal'; +import useFormRef from 'hooks/useFormRef'; +import { useNotification } from 'hooks/useNotification'; + +const DEFAULT_DATA = { + data: {}, + step: 0, +} as const; + +type Props = { + orionAccounts: GoogleOrionAccount[]; + globalReachAccounts: GlobalReachAccount[]; +}; + +const CreateRadiusEndpointModal = ({ orionAccounts, globalReachAccounts }: Props) => { + const { t } = useTranslation(); + const [data, setData] = React.useState<{ + data: Partial; + step: 0 | 1; + }>(DEFAULT_DATA); + const { form, formRef } = useFormRef(); + const onReset = () => { + setData({ ...DEFAULT_DATA }); + }; + const modal = useFormModal({ + isDirty: form.dirty || Object.keys(data).length > 0, + onCloseSideEffect() { + onReset(); + }, + }); + const create = useCreateRadiusEndpoint(); + const { successToast, apiErrorToast } = useNotification(); + + const handleNextStep = React.useCallback( + async (newData: Partial) => { + if (data.step === 0) { + setData({ + data: { + ...data.data, + ...newData, + }, + step: 1, + }); + } + + if (data.step === 1) { + await create.mutateAsync( + // @ts-ignore + { ...data.data, ...newData }, + { + onSuccess: () => { + successToast({ + description: 'Radius Endpoint created', + }); + modal.closeCancelAndForm(); + }, + onError: (error) => { + apiErrorToast({ e: error }); + }, + }, + ); + } + }, + [data], + ); + + const body = () => { + if (data.step === 0) { + return ( + + ); + } + + const type = data.data.Type ?? 'generic'; + + if (type === 'orion') { + return ; + } + if (type === 'globalreach') { + return ( + + ); + } + if (type === 'generic') { + return ; + } + + return ; + }; + + return ( + <> + + + + } + isDisabled={data.step === 0} + /> + + + + } + > + {body()} + + + + ); +}; + +export default CreateRadiusEndpointModal; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/LastUpdateButton.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/LastUpdateButton.tsx new file mode 100644 index 0000000..735e59f --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/LastUpdateButton.tsx @@ -0,0 +1,146 @@ +/* eslint-disable max-len */ +import * as React from 'react'; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Center, + Heading, + IconButton, + Tag, + TagLeftIcon, + Text, + Tooltip, + useDisclosure, +} from '@chakra-ui/react'; +import { CloudArrowUp, Warning } from '@phosphor-icons/react'; +import RefreshButton from 'components/Buttons/RefreshButton'; +import FormattedDate from 'components/FormattedDate'; +import { Modal } from 'components/Modals/Modal'; +import { useGetRadiusEndpointLastGwUpdate, useUpdateRadiusEndpointsOnGateway } from 'hooks/Network/RadiusEndpoints'; +import { useNotification } from 'hooks/useNotification'; + +const LastRadiusEndpointUpdateButton = () => { + const getLastUpdate = useGetRadiusEndpointLastGwUpdate(); + const triggerUpdate = useUpdateRadiusEndpointsOnGateway(); + const { apiErrorToast, successToast } = useNotification(); + const modalProps = useDisclosure(); + + const lastUpdateInfo = React.useMemo(() => { + if (getLastUpdate.data) { + const lastUpdate = + getLastUpdate.data.lastUpdate === 0 ? 'Never' : ; + const lastConfigurationChange = + getLastUpdate.data.lastConfigurationChange === 0 ? ( + 'Never' + ) : ( + + ); + + const isUpToDate = + getLastUpdate.data.lastUpdate !== 0 && + getLastUpdate.data.lastConfigurationChange === getLastUpdate.data.lastUpdate; + + return { + lastUpdate, + lastConfigurationChange, + isUpToDate, + }; + } + + return { + lastUpdate: '-', + lastConfigurationChange: '-', + isUpToDate: true, + }; + }, [getLastUpdate.data]); + + const onTrigger = async () => { + await triggerUpdate.mutateAsync(undefined, { + onSuccess: () => { + successToast({ + description: 'Initiated update of all Radius Endpoints on the gateway', + }); + modalProps.onClose(); + }, + onError: (e) => { + apiErrorToast({ e }); + }, + }); + }; + + const onOpen = () => { + getLastUpdate.refetch(); + modalProps.onOpen(); + }; + + return ( + <> + + + + + + } /> + + } + > + + {!lastUpdateInfo.isUpToDate ? ( + + + + + The RADIUS configuration of your controller does not match your RADIUS endpoints. This means your + RADIUS configurations on your controller might not work as expected + + + + ) : null} + Last Provisioning change: {lastUpdateInfo.lastConfigurationChange} + Last Controller update: {lastUpdateInfo.lastUpdate} + + + + Warning + + Updating the Controller with the latest RADIUS endpoint values may cause some RADIUS disruption for 1-2 + minutes + + + +
+ + +
+
+
+ + ); +}; + +export default LastRadiusEndpointUpdateButton; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/RadiusEndpointSummary.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/RadiusEndpointSummary.tsx new file mode 100644 index 0000000..cefef36 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/RadiusEndpointSummary.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Box, Flex, Heading } from '@chakra-ui/react'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; + +const prettyTypes = { + orion: 'Google Orion', + globalreach: 'Global Reach', + generic: 'Generic', + radsec: 'RadSec', +} as const; + +type Props = { + endpoint?: RadiusEndpoint; +}; + +const RadiusEndpointSummary = ({ endpoint }: Props) => + !endpoint ? null : ( + + + + Name: + + {endpoint.name} + + + + Description: + + {endpoint.description} + + + + Type: + + {prettyTypes[endpoint.Type]} + + + ); + +export default RadiusEndpointSummary; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/TableActions.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/TableActions.tsx new file mode 100644 index 0000000..70eeb7f --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/TableActions.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { + Box, + Button, + Center, + HStack, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + Tooltip, +} from '@chakra-ui/react'; +import { MagnifyingGlass, Trash } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { RadiusEndpoint, useDeleteRadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import { useNotification } from 'hooks/useNotification'; + +type Props = { + endpoint: RadiusEndpoint; + onEdit: (endpoint: RadiusEndpoint) => void; +}; + +const RadiusEndpointActions = ({ endpoint, onEdit }: Props) => { + const { t } = useTranslation(); + const deleteEndpoint = useDeleteRadiusEndpoint(); + const { successToast, apiErrorToast } = useNotification(); + + const onDelete = (onClose: () => void) => async () => { + await deleteEndpoint.mutateAsync(endpoint.id, { + onSuccess: () => { + successToast({ + description: 'Endpoint deleted', + }); + onClose(); + }, + onError: (error) => { + apiErrorToast({ e: error }); + }, + }); + }; + + return ( + + + {({ onClose, isOpen }) => ( + <> + + + + } size="sm" /> + + + + + + + + {t('crud.delete')} {endpoint.name} + + {t('crud.delete_confirm', { obj: 'radius endpoint' })} + +
+ + +
+
+
+ + )} +
+ + onEdit(endpoint)} + size="sm" + icon={} + colorScheme="blue" + /> + +
+ ); +}; + +export default RadiusEndpointActions; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/EndpointDisplay.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/EndpointDisplay.tsx new file mode 100644 index 0000000..d785a7b --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/EndpointDisplay.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { Box, Flex, Heading, Text } from '@chakra-ui/react'; +import GlobalReachEndpointDetails from './GlobalReachEndpointDetails'; +import GoogleOrionEndpointDetails from './GoogleOrionEndpointDetails'; +import RadiusEndpointDetails from './RadiusEndpointDetails'; +import RadsecEndpointDetails from './RadsecEndpointDetails'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import { uppercaseFirstLetter } from 'utils/stringHelper'; + +const firstColProps = { + w: '160px', + mr: 4, +} as const; +const secondColProps = {} as const; + +const prettyTypes = { + orion: 'Google Orion', + globalreach: 'Global Reach', + generic: 'Generic', + radsec: 'RadSec', +} as const; + +type Props = { + endpoint: RadiusEndpoint; +}; + +const EndpointDisplay = ({ endpoint }: Props) => { + const furtherDetails = () => { + if (endpoint.Type === 'orion') return ; + if (endpoint.Type === 'generic') return ; + if (endpoint.Type === 'radsec') return ; + if (endpoint.Type === 'globalreach') return ; + return null; + }; + + return ( + + + Endpoint + + + + Type: + + {prettyTypes[endpoint.Type]} + + + + IP Index: + + {endpoint.Index} + + + + Pool Strategy: + + {uppercaseFirstLetter(endpoint.PoolStrategy)} + + + + Gateway Proxy: + + {endpoint.UseGWProxy ? 'On' : 'Off'} + + + + NAS Identifier: + + {endpoint.NasIdentifier} + + + + Accounting Interval: + + {endpoint.AccountingInterval}s + + {furtherDetails()} + + ); +}; + +export default EndpointDisplay; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/GlobalReachEndpointDetails.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/GlobalReachEndpointDetails.tsx new file mode 100644 index 0000000..4552a19 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/GlobalReachEndpointDetails.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { Alert, AlertDescription, AlertIcon, Box, Flex, Heading, Text } from '@chakra-ui/react'; +import { v4 as uuid } from 'uuid'; +import FormattedDate from 'components/FormattedDate'; +import { useGetSelectedGlobalReachCertificates } from 'hooks/Network/GlobalReach'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import CopyCell from 'pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell'; + +const copyCell = (value: string) => ; + +type Props = { + endpoint: RadiusEndpoint; +}; + +const GlobalReachEndpointDetails = ({ endpoint }: Props) => { + const getCertificates = useGetSelectedGlobalReachCertificates({ + certIds: endpoint.RadsecServers.map((v) => v.UseOpenRoamingAccount), + }); + + const certificate = getCertificates.data?.[0]; + + return ( + + + Global Reach Certificate + {' '} + {certificate ? ( + + + + Name:{' '} + + {certificate.name} + + + + Created:{' '} + + + + + + Expiry:{' '} + + + + + + Certificate:{' '} + + {copyCell(certificate.certificate)} + + + + Cert. Chain:{' '} + + {copyCell(certificate.certificateChain)} + + + + CSR:{' '} + + {copyCell(certificate.csr)} + + + ) : ( + + Cannot retrieve certificate information for now + + )} + + ); +}; + +export default GlobalReachEndpointDetails; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/GoogleOrionEndpointDetails.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/GoogleOrionEndpointDetails.tsx new file mode 100644 index 0000000..baff498 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/GoogleOrionEndpointDetails.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Box, Flex, Heading, ListItem, Text, UnorderedList } from '@chakra-ui/react'; +import { v4 as uuid } from 'uuid'; +import { useGetGoogleOrionAccounts } from 'hooks/Network/GoogleOrion'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import CopyCell from 'pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell'; + +type Props = { + endpoint: RadiusEndpoint; +}; + +const GoogleOrionEndpointDetails = ({ endpoint }: Props) => { + const getAccounts = useGetGoogleOrionAccounts(); + + const account = getAccounts.data?.find( + ({ id }) => endpoint.RadsecServers.find((server) => server.UseOpenRoamingAccount === id) !== undefined, + ); + + return ( + + + Google Orion Account + + {account ? ( + + + + Certificate:{' '} + + + + + + Private Key:{' '} + + + + CA Certificates ({account.cacerts.length}): + + {account.cacerts.map((v, i) => ( + + + Certificate #{i}: + + + + ))} + + + ) : ( + 'No account found' + )} + + ); +}; + +export default GoogleOrionEndpointDetails; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/RadiusEndpointDetails.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/RadiusEndpointDetails.tsx new file mode 100644 index 0000000..7213591 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/RadiusEndpointDetails.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Flex, + Heading, + Text, +} from '@chakra-ui/react'; +import { v4 as uuid } from 'uuid'; +import { RadiusEndpoint, RadiusServer } from 'hooks/Network/RadiusEndpoints'; + +const SectionDisplay = ({ Hostname, IP, Port, Secret }: RadiusServer) => ( + + + + Hostname: + + {Hostname} + + + + IP: + + {IP} + + + + Port: + + {Port} + + + + Secret: + + {Secret} + + +); + +type Props = { + endpoint: RadiusEndpoint; +}; + +const RadiusEndpointDetails = ({ endpoint }: Props) => ( + + + Servers + + + {endpoint.RadiusServers.map((server) => ( + + + {server.Authentication.map((data) => data.Hostname).join(', ')} + + + + + + Accounting Interval:{' '} + + {server.AccountingInterval}s + + + Authentication + + {server.Authentication.map((data) => ( + + ))} + + Accounting + + {server.Accounting.map((data) => ( + + ))} + + Change of Authorization (CoA) + + {server.CoA.map((data) => ( + + ))} + + + ))} + + +); + +export default RadiusEndpointDetails; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/RadsecEndpointDetails.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/RadsecEndpointDetails.tsx new file mode 100644 index 0000000..b7c440e --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/RadsecEndpointDetails.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Flex, + Heading, + ListItem, + Text, + UnorderedList, +} from '@chakra-ui/react'; +import { v4 as uuid } from 'uuid'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import CopyCell from 'pages/OpenRoamingPage/GlobalReachPage/DetailsModal/CopyCell'; + +type Props = { + endpoint: RadiusEndpoint; +}; + +const RadsecEndpointDetails = ({ endpoint }: Props) => ( + + + RadSec Servers + + + {endpoint.RadsecServers.map((server) => ( + + + {server.Hostname} + + + + + + IP Address:{' '} + + {server.IP} + + + + Port:{' '} + + {server.Port} + + + + Secret:{' '} + + {server.Secret} + + + + Allow Self-Signed:{' '} + + {server.AllowSelfSigned ? 'Yes' : 'No'} + + + + Certificate:{' '} + + + + + + Private Key:{' '} + + + + CA Certificates ({server.CaCerts.length}): + + {server.CaCerts.map((v, i) => ( + + + Certificate #{i}: + + + + ))} + + + + ))} + + +); + +export default RadsecEndpointDetails; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/index.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/index.tsx new file mode 100644 index 0000000..a68852a --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/index.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import { Box, Flex, Heading, UseDisclosureReturn, useBoolean } from '@chakra-ui/react'; +import { Formik, FormikHelpers } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuid } from 'uuid'; +import * as Yup from 'yup'; +import EndpointDisplay from './EndpointDisplay'; +import SaveButton from 'components/Buttons/SaveButton'; +import ToggleEditButton from 'components/Buttons/ToggleEditButton'; +import StringField from 'components/FormFields/StringField'; +import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert'; +import { Modal } from 'components/Modals/Modal'; +import { RadiusEndpoint, useUpdateRadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; +import useFormModal from 'hooks/useFormModal'; +import useFormRef from 'hooks/useFormRef'; +import { useNotification } from 'hooks/useNotification'; +import { AtLeast } from 'models/General'; + +type Props = { + endpoint: RadiusEndpoint; + modalProps: UseDisclosureReturn; + hideEdit?: boolean; +}; + +const ViewDetailsModal = ({ endpoint, modalProps, hideEdit }: Props) => { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useBoolean(); + const [formKey, setFormKey] = React.useState(uuid()); + const { form, formRef } = useFormRef>(); + const modal = useFormModal({ + isDirty: form.dirty || isEditing, + onCloseSideEffect: () => { + setFormKey(uuid()); + setIsEditing.off(); + modalProps.onClose(); + }, + }); + const { successToast, apiErrorToast } = useNotification(); + const update = useUpdateRadiusEndpoint(); + + const onSubmit = async ( + data: AtLeast, + helpers: FormikHelpers>, + ) => { + helpers.setSubmitting(true); + + await update.mutateAsync(data, { + onSuccess: () => { + successToast({ + description: t('roaming.account_created'), + }); + modal.closeCancelAndForm(); + helpers.resetForm(); + helpers.setSubmitting(false); + }, + onError: (error) => { + apiErrorToast({ e: error }); + helpers.setSubmitting(false); + }, + }); + }; + + const FormSchema = React.useMemo( + () => + Yup.object().shape({ + name: Yup.string().required(t('form.required')), + description: Yup.string(), + notes: Yup.array().of(Yup.string()), + }), + [t], + ); + + const isFieldDisabled = update.isLoading || !isEditing; + + React.useEffect(() => { + if (!modalProps.isOpen) return; + + modal.onOpen(); + }, [modalProps.isOpen]); + + React.useEffect(() => { + setFormKey(uuid()); + }, [isEditing]); + + return ( + <> + + + + + ); +}; + +export default ViewDetailsModal; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/useEditModal.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/useEditModal.tsx new file mode 100644 index 0000000..a8fc6d0 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/ViewDetailsModal/useEditModal.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { useDisclosure } from '@chakra-ui/react'; +import DetailsModal from '.'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; + +type Props = { + hideEdit?: boolean; +}; + +const useRadiusEndpointAccountModal = ({ hideEdit }: Props) => { + const [endpoint, setEndpoint] = React.useState(); + const modalProps = useDisclosure(); + + const openModal = (newEndpoint: RadiusEndpoint) => { + setEndpoint(newEndpoint); + modalProps.onOpen(); + }; + + return React.useMemo( + () => ({ + openModal, + modal: endpoint ? : null, + }), + [endpoint, modalProps], + ); +}; + +export default useRadiusEndpointAccountModal; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/index.tsx b/src/pages/SystemConfigurationPage/RadiusEndpoints/index.tsx new file mode 100644 index 0000000..39dab63 --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/index.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { Tag } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import CreateRadiusEndpointModal from './CreateModal'; +import LastRadiusEndpointUpdateButton from './LastUpdateButton'; +import RadiusEndpointActions from './TableActions'; +import { useRadiusEndpointsTable } from './useRadiusEndpointsTable'; +import useRadiusEndpointAccountModal from './ViewDetailsModal/useEditModal'; +import { DataGrid } from 'components/DataGrid'; +import { DataGridColumn } from 'components/DataGrid/useDataGrid'; +import FormattedDate from 'components/FormattedDate'; +import { useGetGlobalReachAccounts } from 'hooks/Network/GlobalReach'; +import { useGetGoogleOrionAccounts } from 'hooks/Network/GoogleOrion'; +import { RadiusEndpoint } from 'hooks/Network/RadiusEndpoints'; + +const dateCell = (date: number) => ; +const actionCell = (row: RadiusEndpoint, open: (acc: RadiusEndpoint) => void) => ( + +); +const typeCell = (row: RadiusEndpoint) => { + let colorScheme = 'blue'; + if (row.Type === 'orion') colorScheme = 'purple'; + if (row.Type === 'globalreach') colorScheme = 'green'; + if (row.Type === 'radsec') colorScheme = 'teal'; + + return ( + + {row.Type} + + ); +}; + +const RadiusEndpointsManagement = () => { + const { t } = useTranslation(); + const table = useRadiusEndpointsTable({ + tableSettingsId: 'system.radiusEndpoints.table', + }); + const getOrionAccounts = useGetGoogleOrionAccounts(); + const getGlobalReachAccounts = useGetGlobalReachAccounts(); + const modal = useRadiusEndpointAccountModal({}); + + const columns: DataGridColumn[] = React.useMemo( + () => [ + { + id: 'name', + header: t('common.name'), + accessorKey: 'name', + meta: { + alwaysShow: true, + anchored: true, + customWidth: '120px', + }, + }, + { + id: 'Type', + header: t('common.type'), + accessorKey: 'Type', + cell: (cell) => typeCell(cell.row.original), + meta: { + customWidth: '120px', + }, + }, + { + id: 'Index', + header: 'Index', + accessorKey: 'Index', + meta: { + customWidth: '80px', + }, + }, + { + id: 'PoolStrategy', + header: t('openroaming.pool_strategy'), + accessorKey: 'PoolStrategy', + meta: { + customWidth: '120px', + }, + }, + { + id: 'created', + header: t('common.created'), + + accessorKey: 'created', + cell: (cell) => dateCell(cell.row.original.created), + meta: { + customMinWidth: '150px', + customWidth: '150px', + }, + }, + { + id: 'modified', + header: t('common.modified'), + + accessorKey: 'modified', + cell: (cell) => dateCell(cell.row.original.modified), + meta: { + customMinWidth: '150px', + customWidth: '150px', + }, + }, + { + id: 'description', + header: t('common.description'), + accessorKey: 'description', + enableSorting: false, + }, + { + id: 'actions', + header: '', + accessorKey: 'id', + cell: (cell) => actionCell(cell.row.original, modal.openModal), + enableSorting: false, + meta: { + customWidth: '80px', + alwaysShow: true, + columnSelectorOptions: { + label: t('common.actions'), + }, + }, + }, + ], + [t], + ); + + return ( + <> + {modal.modal} + + controller={table.controller} + header={{ + title: t('openroaming.radius_endpoint_other'), + objectListed: t('openroaming.radius_endpoint_other'), + addButton: ( + + ), + otherButtons: , + }} + columns={columns} + data={table.getRadiusEndpoints.data ?? []} + isLoading={table.getRadiusEndpoints.isFetching} + options={{ + refetch: table.getRadiusEndpoints.refetch, + showAsCard: true, + onRowClick: (row) => () => modal.openModal(row), + }} + /> + + ); +}; + +export default RadiusEndpointsManagement; diff --git a/src/pages/SystemConfigurationPage/RadiusEndpoints/useRadiusEndpointsTable.ts b/src/pages/SystemConfigurationPage/RadiusEndpoints/useRadiusEndpointsTable.ts new file mode 100644 index 0000000..14f078d --- /dev/null +++ b/src/pages/SystemConfigurationPage/RadiusEndpoints/useRadiusEndpointsTable.ts @@ -0,0 +1,22 @@ +import { useDisclosure } from '@chakra-ui/react'; +import { useDataGrid } from 'components/DataGrid/useDataGrid'; +import { useGetRadiusEndpoints } from 'hooks/Network/RadiusEndpoints'; + +export type UseRadiusEndpointsTableProps = { + tableSettingsId: string; +}; +export const useRadiusEndpointsTable = ({ tableSettingsId }: UseRadiusEndpointsTableProps) => { + const controller = useDataGrid({ + tableSettingsId, + defaultSortBy: [{ id: 'name', desc: false }], + defaultOrder: ['name'], + }); + const getRadiusEndpoints = useGetRadiusEndpoints(); + const editModalProps = useDisclosure(); + + return { + controller, + getRadiusEndpoints, + editModalProps, + }; +}; diff --git a/src/pages/SystemConfigurationPage/Actions.tsx b/src/pages/SystemConfigurationPage/SystemSecrets/Actions.tsx similarity index 100% rename from src/pages/SystemConfigurationPage/Actions.tsx rename to src/pages/SystemConfigurationPage/SystemSecrets/Actions.tsx diff --git a/src/pages/SystemConfigurationPage/CreateButton.tsx b/src/pages/SystemConfigurationPage/SystemSecrets/CreateButton.tsx similarity index 98% rename from src/pages/SystemConfigurationPage/CreateButton.tsx rename to src/pages/SystemConfigurationPage/SystemSecrets/CreateButton.tsx index 2d8fc07..86419b2 100644 --- a/src/pages/SystemConfigurationPage/CreateButton.tsx +++ b/src/pages/SystemConfigurationPage/SystemSecrets/CreateButton.tsx @@ -10,7 +10,7 @@ import { useToast, } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; -import { Modal } from '../../components/Modals/Modal'; +import { Modal } from '../../../components/Modals/Modal'; import CreateButton from 'components/Buttons/CreateButton'; import SaveButton from 'components/Buttons/SaveButton'; import { useCreateSystemSecret } from 'hooks/Network/Secrets'; diff --git a/src/pages/SystemConfigurationPage/EditButton.tsx b/src/pages/SystemConfigurationPage/SystemSecrets/EditButton.tsx similarity index 100% rename from src/pages/SystemConfigurationPage/EditButton.tsx rename to src/pages/SystemConfigurationPage/SystemSecrets/EditButton.tsx diff --git a/src/pages/SystemConfigurationPage/Table.tsx b/src/pages/SystemConfigurationPage/SystemSecrets/Table.tsx similarity index 100% rename from src/pages/SystemConfigurationPage/Table.tsx rename to src/pages/SystemConfigurationPage/SystemSecrets/Table.tsx diff --git a/src/pages/SystemConfigurationPage/SystemSecrets/index.tsx b/src/pages/SystemConfigurationPage/SystemSecrets/index.tsx new file mode 100644 index 0000000..638ca53 --- /dev/null +++ b/src/pages/SystemConfigurationPage/SystemSecrets/index.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { + BackgroundProps, + EffectProps, + Heading, + InteractivityProps, + LayoutProps, + PositionProps, + SpaceProps, + Spacer, +} from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import SystemSecretCreateButton from './CreateButton'; +import SystemSecretsTable from './Table'; +import Card from 'components/Card'; +import CardBody from 'components/Card/CardBody'; +import CardHeader from 'components/Card/CardHeader'; +import { useAuth } from 'contexts/AuthProvider'; + +interface SystemConfigurationPageProps + extends LayoutProps, + SpaceProps, + BackgroundProps, + InteractivityProps, + PositionProps, + EffectProps {} + +const SystemSecrets = ({ ...props }: SystemConfigurationPageProps) => { + const { t } = useTranslation(); + const { user } = useAuth(); + + if (!user || user.userRole !== 'root') { + return null; + } + + return ( + + + + {t('system.secrets')} + + + + + + + + + ); +}; + +export default SystemSecrets; diff --git a/src/pages/SystemConfigurationPage/index.tsx b/src/pages/SystemConfigurationPage/index.tsx index 78dbfe8..25eaef4 100644 --- a/src/pages/SystemConfigurationPage/index.tsx +++ b/src/pages/SystemConfigurationPage/index.tsx @@ -1,51 +1,27 @@ -import * as React from 'react'; -import { - BackgroundProps, - EffectProps, - Heading, - InteractivityProps, - LayoutProps, - PositionProps, - SpaceProps, - Spacer, -} from '@chakra-ui/react'; +import React from 'react'; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; -import SystemSecretCreateButton from './CreateButton'; -import SystemSecretsTable from './Table'; -import Card from 'components/Card'; -import CardBody from 'components/Card/CardBody'; -import CardHeader from 'components/Card/CardHeader'; -import { useAuth } from 'contexts/AuthProvider'; +import RadiusEndpointsManagement from './RadiusEndpoints'; +import SystemSecrets from './SystemSecrets'; -interface SystemConfigurationPageProps - extends LayoutProps, - SpaceProps, - BackgroundProps, - InteractivityProps, - PositionProps, - EffectProps {} - -const SystemConfigurationPage = ({ ...props }: SystemConfigurationPageProps) => { +const SystemConfigurationPage = () => { const { t } = useTranslation(); - const { user } = useAuth(); - - if (!user || user.userRole !== 'root') { - return null; - } return ( - - - - {t('system.secrets')} - - - - - - - - + + + {t('openroaming.radius_endpoint_other')} + {t('system.secrets')} + + + + + + + + + + ); }; diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 9cec71d..3612210 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -6,6 +6,7 @@ import { Route } from 'models/Routes'; const ConfigurationPage = React.lazy(() => import('pages/ConfigurationPage')); const EntityPage = React.lazy(() => import('pages/EntityPage')); const InventoryPage = React.lazy(() => import('pages/InventoryPage')); +const OpenRoamingPage = React.lazy(() => import('pages/OpenRoamingPage')); const ProvLogsPage = React.lazy(() => import('pages/Notifications/GeneralLogs')); const VenueNotificationsPage = React.lazy(() => import('pages/Notifications/Notifications')); const FmsLogsPage = React.lazy(() => import('pages/Notifications/FmsLogs')); @@ -123,6 +124,14 @@ const routes: Route[] = [ name: 'system.configuration', component: SystemConfigurationPage, }, + { + id: 'system-globalroaming', + authorized: ['root', 'partner', 'admin', 'csr', 'system'], + path: '/openRoaming', + name: 'RAW-Open Roaming', + label: 'Open Roaming', + component: OpenRoamingPage, + }, { id: 'system-monitoring', authorized: ['root', 'partner', 'admin', 'csr', 'system'],