[WIFI-13031] Add support for radius endpoints

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2023-10-16 18:14:26 +01:00
parent 67a30fae24
commit 204e6e05a5
101 changed files with 6130 additions and 437 deletions

281
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -40,13 +40,15 @@ export type DataGridOptions<TValue extends object> = {
onRowClick?: (row: TValue) => (() => void) | undefined;
refetch?: () => void;
showAsCard?: boolean;
hideTablePreferences?: boolean;
};
export type DataGridProps<TValue extends object> = {
innerTableKey?: string | number;
controller: UseDataGridReturn;
columns: DataGridColumn<TValue>[];
header: {
title: string;
title: string | React.ReactNode;
objectListed: string;
leftContent?: React.ReactNode;
addButton?: React.ReactNode;
@@ -58,6 +60,7 @@ export type DataGridProps<TValue extends object> = {
};
export const DataGrid = <TValue extends object>({
innerTableKey,
controller,
columns,
header,
@@ -149,6 +152,20 @@ export const DataGrid = <TValue extends object>({
...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 (
<Center>
@@ -160,25 +177,29 @@ export const DataGrid = <TValue extends object>({
return options.showAsCard ? (
<Card>
<CardHeader>
<Heading size="md" my="auto" mr={2}>
{header.title}
</Heading>
{typeof header.title === 'string' ? (
<Heading size="md" my="auto" mr={2}>
{header.title}
</Heading>
) : (
header.title
)}
{header.leftContent}
<Spacer />
<HStack spacing={2}>
{header.otherButtons}
{header.addButton}
{
{options.hideTablePreferences ? null : (
// @ts-ignore
<TableSettingsModal<TValue> controller={controller} columns={columns} />
}
)}
{options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null}
</HStack>
</CardHeader>
<CardBody display="flex" flexDirection="column">
<LoadingOverlay isLoading={isLoading}>
<TableContainer minH={minimumHeight}>
<Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px">
<Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px" key={innerTableKey}>
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />
@@ -224,7 +245,7 @@ export const DataGrid = <TValue extends object>({
</Flex>
<LoadingOverlay isLoading={isLoading}>
<TableContainer minH={minimumHeight}>
<Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px">
<Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px" key={innerTableKey}>
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />

View File

@@ -23,6 +23,7 @@ interface StringInputProps extends FieldInputProps<string | undefined | string[]
isArea: boolean;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
explanation?: string;
placeholder?: string;
}
const StringInput: React.FC<StringInputProps> = ({
@@ -39,6 +40,7 @@ const StringInput: React.FC<StringInputProps> = ({
isDisabled,
definitionKey,
explanation,
placeholder,
h,
...props
}) => {
@@ -97,6 +99,7 @@ const StringInput: React.FC<StringInputProps> = ({
autoComplete="off"
border="2px solid"
_disabled={{ opacity: 0.8, cursor: 'not-allowed' }}
placeholder={placeholder}
/>
{hideButton && (
<InputRightElement width="4.5rem">

View File

@@ -7,6 +7,7 @@ import { FieldProps } from 'models/Form';
interface StringFieldProps extends FieldProps, LayoutProps {
hideButton?: boolean;
explanation?: string;
placeholder?: string;
}
const StringField: React.FC<StringFieldProps> = ({
@@ -20,6 +21,7 @@ const StringField: React.FC<StringFieldProps> = ({
emptyIsUndefined = false,
definitionKey,
explanation,
placeholder,
...props
}) => {
const { value, error, isError, onChange, onBlur } = useFastField<string | undefined>({ name });
@@ -47,6 +49,7 @@ const StringField: React.FC<StringFieldProps> = ({
isDisabled={isDisabled}
definitionKey={definitionKey}
explanation={explanation}
placeholder={placeholder}
{...props}
/>
);

View File

@@ -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<Props> = ({ 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 (
<>
<CreateButton ml={2} onClick={onOpen} />
<CreateButton ml={2} onClick={onOpen} isCompact />
<Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
@@ -68,16 +71,22 @@ const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => {
<ModalBody>
<FormControl isRequired mb={4}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
{t('resources.variable')}
Configuration Section
</FormLabel>
<Select value={selectedVariable} onChange={onVariableChange} borderRadius="15px" fontSize="sm" w="200px">
<option value="interface.captive">interface.captive</option>
<option value="interface.ipv4">interface.ipv4</option>
<option value="radio">radio</option>
<option value="interface.ssid">interface.ssid</option>
<option
value="interface.ssid.openroaming"
hidden={!getRadiusEndpoints.data || getRadiusEndpoints.data.length === 0}
>
Open Roaming SSID
</option>
<option value="interface.ssid.radius">interface.ssid.radius</option>
<option value="interface.tunnel">interface.tunnel</option>
<option value="interface.vlan">interface.vlan</option>
<option value="radio">radio</option>
</Select>
</FormControl>
{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 && (
<OpenRoamingSSID
isOpen={isOpen}
onClose={onClose}
refresh={refresh}
isDisabled={false}
formRef={formRef}
parent={{ entity: isVenue ? undefined : entityId, venue: isVenue ? entityId : undefined }}
radiusEndpoints={getRadiusEndpoints.data}
/>
)}
{selectedVariable === 'interface.tunnel' && (
<InterfaceTunnelResource
isOpen={isOpen}
@@ -120,16 +140,6 @@ const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => {
parent={{ entity: isVenue ? undefined : entityId, venue: isVenue ? entityId : undefined }}
/>
)}
{selectedVariable === 'interface.ipv4' && (
<InterfaceIpv4Resource
isOpen={isOpen}
onClose={onClose}
refresh={refresh}
isDisabled={false}
formRef={formRef}
parent={{ entity: isVenue ? undefined : entityId, venue: isVenue ? entityId : undefined }}
/>
)}
{selectedVariable === 'interface.ssid' && (
<InterfaceSsidResource
isOpen={isOpen}
@@ -150,6 +160,16 @@ const CreateResourceModal = ({ refresh, entityId, isVenue = false }: Props) => {
isDisabled={false}
/>
)}
{selectedVariable === 'interface.ipv4' && (
<InterfaceIpv4Resource
isOpen={isOpen}
onClose={onClose}
refresh={refresh}
formRef={formRef}
parent={{ entity: isVenue ? undefined : entityId, venue: isVenue ? entityId : undefined }}
isDisabled={false}
/>
)}
</ModalBody>
</ModalContent>
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />

View File

@@ -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<Props> = ({ 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) => {
</Center>
);
if (getType() === 'interface.captive')
const resourceType = getType();
if (resourceType === 'interface.captive')
return (
<InterfaceCaptiveResource
resource={resourceData}
@@ -87,7 +91,7 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => {
/>
);
if (getType() === 'interface.ssid.radius')
if (resourceType === 'interface.ssid.radius')
return (
<InterfaceSsidRadiusResource
resource={resourceData}
@@ -98,7 +102,7 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => {
isDisabled={!editing}
/>
);
if (getType() === 'interface.tunnel')
if (resourceType === 'interface.tunnel')
return (
<InterfaceTunnelResource
resource={resourceData}
@@ -110,19 +114,20 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => {
/>
);
if (getType() === 'interface.ipv4')
if (resourceType === 'interface.ssid.openroaming')
return (
<InterfaceIpv4Resource
<OpenRoamingSSID
resource={resourceData}
isOpen={isOpen}
onClose={onClose}
refresh={refreshAll}
formRef={formRef}
isDisabled={!editing}
radiusEndpoints={getRadiusEndpoints.data ?? []}
/>
);
if (getType() === 'interface.vlan')
if (resourceType === 'interface.vlan')
return (
<InterfaceVlanResource
resource={resourceData}
@@ -134,7 +139,7 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => {
/>
);
if (getType() === 'interface.ssid')
if (resourceType === 'interface.ssid')
return (
<InterfaceSsidResource
resource={resourceData}
@@ -145,8 +150,19 @@ const EditResourceModal = ({ isOpen, onClose, resource, refresh }: Props) => {
isDisabled={!editing}
/>
);
if (resourceType === 'interface.ipv4')
return (
<InterfaceIpv4Resource
resource={resourceData}
isOpen={isOpen}
onClose={onClose}
refresh={refreshAll}
formRef={formRef}
isDisabled={!editing}
/>
);
if (getType() === 'radio')
if (resourceType === 'radio')
return (
<SingleRadioResource
resource={resourceData}

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { SimpleGrid, Tab, TabList, TabPanel, TabPanels, Tabs, useToast } from '@chakra-ui/react';
import { AxiosError } from 'axios';
import { Formik, FormikProps } from 'formik';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
@@ -9,6 +8,7 @@ import InterfaceSsidForm from './Form';
import NotesTable from 'components/CustomFields/NotesTable';
import StringField from 'components/FormFields/StringField';
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';
@@ -34,7 +34,15 @@ interface Props {
};
}
const InterfaceSsidResource = ({ isOpen, onClose, refresh, formRef, resource, isDisabled = false, parent }: Props) => {
const InterfaceSsidResource: React.FC<Props> = ({
isOpen,
onClose,
refresh,
formRef,
resource,
isDisabled = false,
parent,
}) => {
const { t } = useTranslation();
const toast = useToast();
const [formKey, setFormKey] = useState(uuid());

View File

@@ -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 (
<>
<Heading size="md" mt={6} mb={2} textDecoration="underline">
OpenRoaming SSID
</Heading>
<SimpleGrid minChildWidth="300px" spacing="20px" mt={2}>
<StringField
name={`${namePrefix}.name`}
label="name"
definitionKey="interface.ssid.name"
isDisabled={isDisabled}
isRequired
/>
<SelectField
name={`${namePrefix}.bss-mode`}
label="bss-mode"
definitionKey="interface.ssid.bss-mode"
isDisabled={isDisabled}
options={[
{ value: 'ap', label: 'ap' },
{ value: 'sta', label: 'sta' },
{ value: 'mesh', label: 'mesh' },
{ value: 'wds-ap', label: 'wds-ap' },
{ value: 'wds-sta', label: 'wds-sta' },
]}
isRequired
/>
<MultiSelectField
name={`${namePrefix}.wifi-bands`}
label="wifi-bands"
definitionKey="interface.ssid.wifi-bands"
isDisabled={isDisabled}
options={[
{ value: '2G', label: '2G' },
{ value: '5G', label: '5G' },
{ value: '6G', label: '6G' },
]}
isRequired
/>
</SimpleGrid>
<OpenRoamingEncryption
editing={!isDisabled}
ssidName={namePrefix}
namePrefix={`${namePrefix}.encryption`}
radiusPrefix={`${namePrefix}.radius`}
isPasspoint={isPasspoint}
/>
<Box my={2}>
<PassPoint
isDisabled={isDisabled}
namePrefix={`${namePrefix}.pass-point`}
radiusPrefix={`${namePrefix}.radius`}
lockConsortium={['orion', 'globalreach'].includes(foundRadiusEndpoint?.Type ?? '')}
/>
</Box>
<AdvancedSettings editing={!isDisabled} namePrefix={namePrefix} />
</>
);
};
export default React.memo(InterfaceSsidResourceForm);

View File

@@ -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<HTMLSelectElement>) => void;
needIeee: boolean;
isKeyNeeded: boolean;
isPasspoint?: boolean;
}
const OpenRoamingEncryptionForm = ({
editing,
namePrefix,
radiusPrefix,
onProtoChange,
needIeee,
isKeyNeeded,
isPasspoint,
}: Props) => (
<>
<Flex mt={4}>
<Heading size="md" borderBottom="1px solid">
Authentication
</Heading>
</Flex>
<SimpleGrid minChildWidth="300px" spacing="20px">
<SelectField
name={`${namePrefix}.proto`}
label="protocol"
definitionKey="interface.ssid.encryption.proto"
options={ENCRYPTION_OPTIONS.filter(({ value }) => ENCRYPTION_PROTOS_REQUIRE_RADIUS.includes(value))}
isDisabled={!editing}
isRequired
onChange={onProtoChange}
w="300px"
/>
{isKeyNeeded && (
<StringField
name={`${namePrefix}.key`}
label="key"
definitionKey="interface.ssid.encryption.key"
isDisabled={!editing}
isRequired
hideButton
/>
)}
{needIeee && (
<SelectField
name={`${namePrefix}.ieee80211w`}
label="ieee80211w"
definitionKey="interface.ssid.encryption.ieee80211w"
options={[
{ value: 'disabled', label: 'disabled' },
{ value: 'optional', label: 'optional' },
{ value: 'required', label: 'required' },
]}
isDisabled={!editing}
isRequired
w="120px"
/>
)}
<ToggleField
name={`${namePrefix}.key-caching`}
label="key-caching"
definitionKey="interface.ssid.encryption.key-caching"
isDisabled={!editing}
defaultValue
/>
</SimpleGrid>
<OpenRoamingRadius editing={editing} namePrefix={radiusPrefix} isPasspoint={isPasspoint} />
</>
);
export default React.memo(OpenRoamingEncryptionForm);

View File

@@ -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<HTMLInputElement>) => void;
isAccountingEnabled: boolean;
// eslint-disable-next-line react/no-unused-prop-types
isPasspoint?: boolean;
};
const OpenRoamingRadiusForm = ({ editing, namePrefix, onAccountingChange, isAccountingEnabled }: Props) => (
<>
<Flex mt={6}>
<div>
<Heading size="md" display="flex" mt={2} mr={2} borderBottom="1px solid">
Radius
</Heading>
</div>
</Flex>
<FormControl isDisabled={!editing}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
Enable Accounting
</FormLabel>
<Switch
onChange={onAccountingChange}
isChecked={isAccountingEnabled}
borderRadius="15px"
size="lg"
isDisabled={!editing}
_disabled={{ opacity: 0.8, cursor: 'not-allowed' }}
/>
</FormControl>
{isAccountingEnabled && (
<SimpleGrid minChildWidth="300px" spacing="20px">
<StringField name={`${namePrefix}.accounting.host`} label="accounting.host" isDisabled={!editing} isRequired />
<NumberField
name={`${namePrefix}.accounting.port`}
label="accounting.port"
isDisabled={!editing}
isRequired
hideArrows
w={24}
/>
<StringField
name={`${namePrefix}.accounting.secret`}
label="accounting.secret"
isDisabled={!editing}
isRequired
hideButton
/>
</SimpleGrid>
)}
<SimpleGrid minChildWidth="300px" spacing="20px">
<StringField
name={`${namePrefix}.nas-identifier`}
label="nas-identifier"
isDisabled={!editing}
emptyIsUndefined
/>
<ToggleField
name={`${namePrefix}.chargeable-user-id`}
label="chargeable-user-id"
isDisabled={!editing}
falseIsUndefined
/>
</SimpleGrid>
</>
);
export default React.memo(OpenRoamingRadiusForm);

View File

@@ -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<HTMLInputElement>) => {
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 (
<RadiusForm
editing={editing}
onAccountingChange={onEnabledAccountingChange}
isAccountingEnabled={isAccountingEnabled}
namePrefix={namePrefix}
isPasspoint={isPasspoint}
/>
);
};
export default React.memo(OpenRoamingRadius);

View File

@@ -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<HTMLSelectElement>) => {
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 (
<OpenRoamingEncryptionForm
editing={editing}
namePrefix={namePrefix}
radiusPrefix={radiusPrefix}
onProtoChange={onProtoChange}
needIeee={needIeee}
isKeyNeeded={isKeyNeeded}
isPasspoint={isPasspoint}
/>
);
};
export default React.memo(OpenRoamingEncryption);

View File

@@ -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<string>({ 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 (
<SelectField
name={name}
label="Radius Endpoint"
options={options}
isDisabled={isDisabled || options.length === 0}
isRequired
w="max-content"
/>
);
};
export default RadiusEndpointSelector;

View File

@@ -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<FormikProps<Record<string, unknown>>> | 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 (
<Formik
innerRef={formRef}
key={formKey}
initialValues={
resource !== undefined && resource.variables[0]
? {
editing: { ...JSON.parse(resource.variables[0].value) },
_unused_name: resource.name,
_unused_description: resource.description,
entity: resource.entity !== '' ? `ent:${resource.entity}` : `ven:${resource.venue}`,
_unused_notes: resource.notes,
}
: {
editing: DEFAULT_VALUE(radiusEndpoints[0] as RadiusEndpoint),
_unused_name: 'OpenRoaming SSID',
_unused_description: '',
_unused_notes: [],
}
}
validationSchema={EDIT_SCHEMA(t)}
onSubmit={async (formData, { setSubmitting, resetForm }) => {
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);
},
},
);
}}
>
<Tabs variant="enclosed">
<TabList>
<Tab>{t('common.main')}</Tab>
<Tab>{t('common.notes')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Heading size="md" mb={2} textDecoration="underline">
Resource Details
</Heading>
<RadiusEndpointSelector name="editing.radius.__radiusEndpoint" isDisabled={isDisabled} />
<StringField name="_unused_name" label={t('common.name')} isRequired isDisabled={isDisabled} w="300px" />
<StringField
name="_unused_description"
label={t('common.description')}
isDisabled={isDisabled}
isArea
h="40px"
/>
<InterfaceSsidForm radiusEndpoints={radiusEndpoints} isDisabled={isDisabled} />
</TabPanel>
<TabPanel>
<NotesTable name="_unused_notes" isDisabled={isDisabled} />
</TabPanel>
</TabPanels>
</Tabs>
</Formik>
);
};
export default OpenRoamingSSID;

View File

@@ -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 = ({
<StringField name="description" label={t('common.description')} errors={errors} touched={touched} />
<StringField name="note" label={t('common.note')} errors={errors} touched={touched} />
</SimpleGrid>
<SpecialConfigurationManager editing isEnabledByDefault isOnlySections onChange={onConfigurationChange} />
<ConfigurationProvider entityId={getEntityId()}>
<SpecialConfigurationManager editing isEnabledByDefault isOnlySections onChange={onConfigurationChange} />
</ConfigurationProvider>
</>
)}
</Formik>

View File

@@ -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,}))$/,
);

View File

@@ -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 <ConfigurationContext.Provider value={value}>{children}</ConfigurationContext.Provider>;
};
export const useConfigurationContext = () => React.useContext(ConfigurationContext);

View File

@@ -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'))

View File

@@ -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]);
},
});
};

View File

@@ -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]);
},
});
};

View File

@@ -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<RadiusEndpoint, 'id'>) =>
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<RadiusEndpoint, 'id' | 'UsedBy' | 'created' | 'modified'>) =>
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]);
},
});
};

View File

@@ -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,

View File

@@ -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(

View File

@@ -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<typeof useNotification>;

View File

@@ -40,13 +40,14 @@ export const NavLinkButton = ({ isActive, route, toggleSidebar }: Props) => {
_hover={{
bg: hoverBg,
}}
whiteSpace="normal"
>
<Flex alignItems="center" w="100%">
<IconBox color="blue.300" h="30px" w="30px" me="6px" transition={variantChange} fontWeight="bold">
{route.icon(false)}
</IconBox>
<Text color={activeTextColor} fontSize="md" fontWeight="bold">
{t(route.name)}
<Text color={activeTextColor} fontSize="md" fontWeight="bold" textAlign="left">
{route.label ?? t(route.name)}
</Text>
</Flex>
</AccordionButton>
@@ -64,13 +65,14 @@ export const NavLinkButton = ({ isActive, route, toggleSidebar }: Props) => {
_hover={{
bg: hoverBg,
}}
whiteSpace="normal"
>
<Flex alignItems="center" w="100%">
<IconBox color="blue.300" h="30px" w="30px" me="6px" transition={variantChange} fontWeight="bold">
{route.icon(false)}
</IconBox>
<Text color={inactiveTextColor} fontSize="md" fontWeight="bold">
{t(route.name)}
<Text color={inactiveTextColor} fontSize="md" fontWeight="bold" textAlign="left">
{route.label ?? t(route.name)}
</Text>
</Flex>
</AccordionButton>

View File

@@ -29,8 +29,9 @@ const SubNavigationButton = ({ isActive, route }: Props) => {
bg: hoverBg,
}}
border="none"
textAlign="left"
>
{t(route.name)}
{route.label ?? t(route.name)}
</Button>
</NavLink>
);

View File

@@ -89,7 +89,7 @@ export const Sidebar = ({ routes, isOpen, toggle, logo, version, topNav, childre
</Box>
</>
),
[user?.userRole, location, topNav],
[user?.userRole, location, topNav, routes],
);
return (

View File

@@ -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]);

View File

@@ -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;

View File

@@ -7,7 +7,8 @@ export type SubRoute = {
authorized: string[];
path: string;
name: RouteName;
component: React.ReactElement | LazyExoticComponent<React.ComponentType<unknown>>;
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<React.ComponentType<unknown>>;
) => typeof React.Component | LazyExoticComponent<React.ComponentType<unknown>>;
isEntity?: boolean;
component: React.ReactElement | LazyExoticComponent<React.ComponentType<unknown>>;
component: typeof React.Component | React.LazyExoticComponent<() => JSX.Element | null>;
hidden?: boolean;
isCustom?: boolean;
children?: undefined;

View File

@@ -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) => (
<>
<Flex mt={4}>
@@ -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}

View File

@@ -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}
/>
);
};

View File

@@ -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
</Heading>
</Flex>
<SimpleGrid minChildWidth="300px" spacing="20px">
<SimpleGrid minChildWidth="300px" spacing="20px" hidden={isUsingRadiusEndpoint}>
<DisplayStringField
value={data?.radius?.authentication?.host}
label="authentication.host"
@@ -97,65 +98,69 @@ const LockedEncryption = ({ data }) => {
/>
<DisplayToggleField value={data?.radius?.authentication?.['mac-filter']} isDisabled />
</SimpleGrid>
<FormControl isDisabled>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
Accounting
</FormLabel>
</FormControl>
{data?.radius?.accounting && (
<SimpleGrid minChildWidth="300px" spacing="20px">
<DisplayStringField
value={data?.radius?.accounting?.host}
label="accounting.host"
isDisabled
isRequired
/>
<DisplayNumberField
value={data?.radius?.accounting?.port}
label="accounting.port"
isDisabled
isRequired
hideArrows
w={24}
/>
<DisplayStringField
value={data?.radius?.accounting?.secret}
label="accounting.secret"
isDisabled
isRequired
hideButton
/>
</SimpleGrid>
<>
<FormControl>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
Accounting
</FormLabel>
</FormControl>
<SimpleGrid minChildWidth="300px" spacing="20px">
<DisplayStringField
value={data?.radius?.accounting?.host}
label="accounting.host"
isDisabled
isRequired
/>
<DisplayNumberField
value={data?.radius?.accounting?.port}
label="accounting.port"
isDisabled
isRequired
hideArrows
w={24}
/>
<DisplayStringField
value={data?.radius?.accounting?.secret}
label="accounting.secret"
isDisabled
isRequired
hideButton
/>
</SimpleGrid>
</>
)}
<FormControl isDisabled>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
Accounting
</FormLabel>
</FormControl>
{data?.radius?.['dynamic-authorization'] && (
<SimpleGrid minChildWidth="300px" spacing="20px">
<DisplayStringField
value={data?.radius?.['dynamic-authorization']?.host}
label="dynamic-authorization.host"
isDisabled
isRequired
/>
<DisplayNumberField
value={data?.radius?.['dynamic-authorization']?.port}
label="dynamic-authorization.port"
isDisabled
isRequired
hideArrows
w={24}
/>
<DisplayStringField
value={data?.radius?.['dynamic-authorization']?.secret}
label="dynamic-authorization.secret"
isDisabled
isRequired
hideButton
/>
</SimpleGrid>
<>
<FormControl isDisabled hidden={isUsingRadiusEndpoint}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
Dynamic Authorization
</FormLabel>
</FormControl>
<SimpleGrid minChildWidth="300px" spacing="20px">
<DisplayStringField
value={data?.radius?.['dynamic-authorization']?.host}
label="dynamic-authorization.host"
isDisabled
isRequired
/>
<DisplayNumberField
value={data?.radius?.['dynamic-authorization']?.port}
label="dynamic-authorization.port"
isDisabled
isRequired
hideArrows
w={24}
/>
<DisplayStringField
value={data?.radius?.['dynamic-authorization']?.secret}
label="dynamic-authorization.secret"
isDisabled
isRequired
hideButton
/>
</SimpleGrid>
</>
)}
<SimpleGrid minChildWidth="300px" spacing="20px">
<DisplayStringField value={data?.radius?.['nas-identifier']} label="nas-identifier" isDisabled />

View File

@@ -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 }) => {
() => (
<>
<SimpleGrid minChildWidth="180px" gap={4} mb={4}>
<NumberInputField name="width" label="width" w="140px" emptyIsUndefined isRequired unit="px" />
<NumberField name="width" label="width" w="140px" emptyIsUndefined isRequired unit="px" />
<NumberField name="height" label="height" w="140px" isRequired unit="px" />
<StringField name="language" label="language" w="100px" isRequired />
</SimpleGrid>

View File

@@ -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 ? (
<Alert status="info" mb={4} onClick={() => modal.openModal(getEndpoint.data)} cursor="pointer" w="max-content">
<AlertIcon />
<Box>
<AlertTitle>Custom radius endpoint: {getEndpoint.data.name}</AlertTitle>
<AlertDescription>
Click <b>here</b> to view details
</AlertDescription>
</Box>
</Alert>
) : null}
<SimpleGrid minChildWidth="300px" spacing="20px">
<DisplayStringField value={data.name} label="name" definitionKey="interface.ssid.name" isRequired />
<DisplaySelectField
@@ -62,7 +81,7 @@ const LockedSsid = ({ variableBlockId }) => {
isRequired
/>
</SimpleGrid>
<LockedEncryption data={data} />
<LockedEncryption data={data} isUsingRadiusEndpoint={getEndpoint.data} />
<LockedPasspoint data={data} />
<LockedAdvanced data={data} />
</>

View File

@@ -14,16 +14,10 @@ interface Props {
namePrefix: string;
isEnabled: boolean;
onToggle: (event: React.ChangeEvent<HTMLInputElement>) => void;
lockConsortium?: boolean;
}
const PassPointForm = (
{
isDisabled,
namePrefix,
isEnabled,
onToggle
}: Props
) => {
const PassPointForm: React.FC<Props> = ({ isDisabled, namePrefix, isEnabled, onToggle, lockConsortium }) => {
const name = React.useCallback((suffix: string) => `${namePrefix}.${suffix}`, []);
const fieldProps = (suffix: string) => ({
@@ -157,7 +151,12 @@ const PassPointForm = (
<ToggleField {...fieldProps('esr')} falseIsUndefined />
<ToggleField {...fieldProps('uesa')} falseIsUndefined />
<StringField {...fieldProps('hessid')} emptyIsUndefined />
<CreatableSelectField {...fieldProps('roaming-consortium')} emptyIsUndefined placeholder="BAA2D00100" />
<CreatableSelectField
{...fieldProps('roaming-consortium')}
emptyIsUndefined
placeholder="BAA2D00100"
isDisabled={lockConsortium || isDisabled}
/>
<ToggleField {...fieldProps('disable-dgaf')} falseIsUndefined />
<NumberField {...fieldProps('ipaddr-type-available')} acceptEmptyValue emptyIsUndefined />
<SelectField

View File

@@ -8,9 +8,10 @@ type Props = {
isDisabled?: boolean;
namePrefix: string;
radiusPrefix: string;
lockConsortium?: boolean;
};
const PassPointConfig = ({ isDisabled, namePrefix, radiusPrefix }: Props) => {
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 <PassPointForm isDisabled={isDisabled} namePrefix={namePrefix} isEnabled={isEnabled} onToggle={onToggle} />;
return (
<PassPointForm
isDisabled={isDisabled}
namePrefix={namePrefix}
isEnabled={isEnabled}
onToggle={onToggle}
lockConsortium={lockConsortium}
/>
);
};
export default React.memo(PassPointConfig);

View File

@@ -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 }) => {
<Heading size="md" mr={2} pt={2}>
#{index}
</Heading>
<ConfigurationResourcePicker
name={namePrefix}
prefix="interface.ssid"
isDisabled={!editing}
defaultValue={INTERFACE_SSID_SCHEMA}
/>
<SsidResourcePicker name={namePrefix} isDisabled={!editing} />
<Spacer />
<DeleteButton isDisabled={!editing} onClick={removeSsid} label={t('configurations.delete_ssid')} />
</Flex>
<CardBody display="unset">
{isUsingCustomRadius ? (
<>
<SimpleGrid minChildWidth="300px" spacing="20px" mt={2}>
<StringField
name={`${namePrefix}.name`}
label="SSID"
definitionKey="interface.ssid.name"
isDisabled={!editing}
isRequired
/>
<SelectField
name={`${namePrefix}.bss-mode`}
label="bss-mode"
definitionKey="interface.ssid.bss-mode"
isDisabled={!editing}
options={[
{ value: 'ap', label: 'ap' },
{ value: 'sta', label: 'sta' },
{ value: 'mesh', label: 'mesh' },
{ value: 'wds-ap', label: 'wds-ap' },
{ value: 'wds-sta', label: 'wds-sta' },
]}
isRequired
/>
<MultiSelectField
name={`${namePrefix}.wifi-bands`}
label="wifi-bands"
definitionKey="interface.ssid.wifi-bands"
isDisabled={!editing}
options={[
{ value: '2G', label: '2G' },
{ value: '5G', label: '5G' },
{ value: '5G-lower', label: '5G-lower' },
{ value: '5G-upper', label: '5G-upper' },
{ value: '6G', label: '6G' },
]}
isRequired
/>
</SimpleGrid>
<Encryption
editing={editing}
ssidName={namePrefix}
namePrefix={`${namePrefix}.encryption`}
radiusPrefix={`${namePrefix}.radius`}
isPasspoint={isPasspoint}
/>
<Box my={2}>
<PassPoint
isDisabled={!editing}
namePrefix={`${namePrefix}.pass-point`}
<Box>
{isUsingCustomRadius ? (
<>
<SimpleGrid minChildWidth="300px" spacing="20px" mt={2}>
<StringField
name={`${namePrefix}.name`}
label="SSID"
definitionKey="interface.ssid.name"
isDisabled={!editing}
isRequired
/>
<SelectField
name={`${namePrefix}.bss-mode`}
label="bss-mode"
definitionKey="interface.ssid.bss-mode"
isDisabled={!editing}
options={[
{ value: 'ap', label: 'ap' },
{ value: 'sta', label: 'sta' },
{ value: 'mesh', label: 'mesh' },
{ value: 'wds-ap', label: 'wds-ap' },
{ value: 'wds-sta', label: 'wds-sta' },
]}
isRequired
/>
<MultiSelectField
name={`${namePrefix}.wifi-bands`}
label="wifi-bands"
definitionKey="interface.ssid.wifi-bands"
isDisabled={!editing}
options={[
{ value: '2G', label: '2G' },
{ value: '5G', label: '5G' },
{ value: '5G-lower', label: '5G-lower' },
{ value: '5G-upper', label: '5G-upper' },
{ value: '6G', label: '6G' },
]}
isRequired
/>
</SimpleGrid>
<Encryption
editing={editing}
ssidName={namePrefix}
namePrefix={`${namePrefix}.encryption`}
radiusPrefix={`${namePrefix}.radius`}
isPasspoint={isPasspoint}
/>
</Box>
<AdvancedSettings editing={editing} namePrefix={namePrefix} />
</>
) : (
<LockedSsid variableBlockId={getIn(values, `${namePrefix}.__variableBlock`)[0]} />
)}
<Box my={2}>
<PassPoint
isDisabled={!editing}
namePrefix={`${namePrefix}.pass-point`}
radiusPrefix={`${namePrefix}.radius`}
/>
</Box>
<AdvancedSettings editing={editing} namePrefix={namePrefix} />
</>
) : (
<LockedSsid variableBlockId={getIn(values, `${namePrefix}.__variableBlock`)[0]} />
)}
</Box>
</CardBody>
</>
);

View File

@@ -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<HTMLSelectElement>) => {
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 (
<Select value={selectValue} isDisabled={isDisabled} maxW={72} onChange={onChange}>
<option value="">{t('configurations.no_resource_selected')}</option>
{availableResources.map((res) => (
<option key={res.value} value={res.value}>
{res.label}
</option>
))}
{selectValue !== '' && !availableResources.find(({ value: resource }) => resource === selectValue) && (
<option value={selectValue}>{t('configurations.invalid_resource')}</option>
)}
</Select>
);
};
export default SsidResourcePicker;

View File

@@ -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),
});

View File

@@ -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),

View File

@@ -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 (
<>
<Card mb={4}>
<CardHeader>
<Box pt={1}>
<CardHeader mb={0}>
<Box>
<Heading size="md">{configuration?.name}</Heading>
</Box>
<Spacer />
@@ -182,7 +183,9 @@ const ConfigurationCard = ({ id }) => {
)}
</CardBody>
</Card>
<ConfigurationSectionsCard editing={editing} configId={id} setSections={setSections} />
<ConfigurationProvider configurationId={id}>
<ConfigurationSectionsCard editing={editing} configId={id} setSections={setSections} />
</ConfigurationProvider>
<ConfirmConfigurationWarnings
isOpen={showWarnings}
onClose={closeWarnings}

View File

@@ -0,0 +1,172 @@
import * as React from 'react';
import { State } from 'country-state-city';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import ActionCell from './ActionCell';
import CreateGlobalReachAccountModal from './CreateGlobalReachAccountModal';
import useGlobalAccountModal from './DetailsModal/useEditModal';
import { DataGrid } from 'components/DataGrid';
import { DataGridColumn, useDataGrid } from 'components/DataGrid/useDataGrid';
import FormattedDate from 'components/FormattedDate';
import COUNTRY_LIST from 'constants/countryList';
import { GlobalReachAccount, useGetGlobalReachAccounts } from 'hooks/Network/GlobalReach';
const dateCell = (date: number) => <FormattedDate date={date} key={uuid()} />;
const actionCell = (row: GlobalReachAccount, open: (acc: GlobalReachAccount) => void) => (
<ActionCell account={row} openDetailsModal={open} />
);
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<GlobalReachAccount>[] = 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}
<DataGrid<GlobalReachAccount>
controller={tableController}
header={{
title: `${t('roaming.account', { count: getAccounts.data?.length ?? 0 })} ${
getAccounts.data?.length ? `(${getAccounts.data.length})` : ''
}`,
objectListed: t('roaming.account_other'),
addButton: <CreateGlobalReachAccountModal />,
}}
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;

View File

@@ -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 (
<HStack spacing={2}>
<Popover placement="start">
{({ onClose, isOpen }) => (
<>
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="334px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.delete')} {account.name}
</PopoverHeader>
<PopoverBody>{t('crud.delete_confirm', { obj: t('roaming.account_one') })}</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={onDelete(onClose)} isLoading={deleteAccount.isLoading}>
{t('common.yes')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</>
)}
</Popover>
<Tooltip label={t('common.view_details')}>
<IconButton
aria-label={t('common.view_details')}
onClick={() => openDetailsModal(account)}
size="sm"
icon={<MagnifyingGlass size={20} />}
colorScheme="blue"
/>
</Tooltip>
</HStack>
);
};
export default ActionCell;

View File

@@ -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<CreateGlobalReachAccountRequest>();
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<CreateGlobalReachAccountRequest>,
) => {
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 (
<>
<CreateButton onClick={modal.onOpen} />
<Modal
isOpen={modal.isOpen}
onClose={modal.closeModal}
title={`${t('common.create')} ${t('roaming.account_one')}`}
topRightButtons={<SaveButton onClick={form.submitForm} isLoading={form.isSubmitting} />}
>
<Box>
<Formik
key={formKey}
innerRef={formRef}
initialValues={
{
name: '',
description: '',
notes: [],
privateKey: '',
country: 'US',
province: 'AL',
city: '',
organization: '',
commonName: '',
GlobalReachAcctId: '',
} as CreateGlobalReachAccountRequest
}
validateOnMount
validationSchema={FormSchema}
onSubmit={onSubmit}
>
<Box>
<Heading size="md" textDecoration="underline">
{t('roaming.account_one')} {t('common.details')}
</Heading>
<Flex my={2}>
<StringField name="name" label={t('common.name')} isRequired isDisabled={isFieldDisabled} w="300px" />
<Box ml={2}>
<StringField
name="commonName"
label={`${t('roaming.common_name')}`}
isRequired
placeholder="example.com"
isDisabled={isFieldDisabled}
w="300px"
/>
</Box>
</Flex>{' '}
<StringField name="Description" isArea h="80px" isDisabled={isFieldDisabled} />
<Heading size="md" textDecoration="underline" mt={2}>
{t('roaming.global_reach')}
</Heading>
<Flex my={2}>
<StringField
name="GlobalReachAcctId"
label={t('roaming.global_reach_account_id')}
isRequired
isDisabled={isFieldDisabled}
w="266px"
/>
<Box ml={2}>
<PrivateKeyField isDisabled={isFieldDisabled} />
</Box>
</Flex>
<Heading size="md" textDecoration="underline">
{t('roaming.location_details_title')}
</Heading>{' '}
<Flex my={2}>
<Box>
<SelectField
name="country"
label={t('roaming.country')}
options={COUNTRY_LIST}
isRequired
w="max-content"
/>
</Box>
<Box ml={2}>
<StatePicker isDisabled={isFieldDisabled} />
</Box>
</Flex>
<Flex my={2}>
<StringField
name="city"
label={`${t('roaming.city')}`}
isRequired
isDisabled={isFieldDisabled}
w="266px"
/>
<Box ml={2}>
<StringField
name="organization"
label={`${t('roaming.organization')}`}
isRequired
isDisabled={isFieldDisabled}
w="300px"
/>
</Box>
</Flex>
</Box>
</Formik>
</Box>
</Modal>
<ConfirmCloseAlert isOpen={modal.isConfirmOpen} confirm={modal.closeCancelAndForm} cancel={modal.closeConfirm} />
</>
);
};
export default CreateGlobalReachAccountModal;

View File

@@ -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 (
<HStack>
<Popover placement="start">
{({ onClose, isOpen }) => (
<>
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="334px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.delete')} {certificate.name}
</PopoverHeader>
<PopoverBody>{t('crud.delete_confirm', { obj: t('roaming.certificate_one') })}</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={onDelete(onClose)} isLoading={deleteCertificate.isLoading}>
{t('common.yes')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</>
)}
</Popover>
<Tooltip hasArrow label="Recreate" placement="top">
<IconButton
aria-label="Recreate"
colorScheme="blue"
icon={<Recycle size={20} />}
size="sm"
isLoading={renewCertificate.isLoading}
onClick={onRenew}
/>
</Tooltip>
</HStack>
);
};
export default GlobalReachCertActionCell;

View File

@@ -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 (
<Popover {...popoverProps} placement="start">
<PopoverTrigger>
<Box>
<CreateButton onClick={popoverProps.onOpen} />
</Box>
</PopoverTrigger>
<PopoverContent w="334px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.create')} {t('certificates.certificate')}
</PopoverHeader>
<PopoverBody>
<Heading size="sm">What should be this certificate&apos;s name?</Heading>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={popoverProps.onClose}>
{t('common.cancel')}
</Button>
<Button
colorScheme="blue"
ml="1"
onClick={handleCreate}
isLoading={create.isLoading}
isDisabled={name.length < 3}
>
{t('common.create')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</Popover>
);
};
export default CreateGlobalReachCertificateButton;

View File

@@ -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) => <FormattedDate date={date} key={uuid()} />;
const copyCell = (value: string) => <CopyCell value={value} key={uuid()} isCompact />;
const actionCell = (row: GlobalReachCertificate) => <GlobalReachCertActionCell certificate={row} />;
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<GlobalReachCertificate>[] = 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 (
<DataGrid<GlobalReachCertificate>
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 : <CreateGlobalReachCertificateButton account={account} />,
}}
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;

View File

@@ -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 (
<Tooltip label={copy.hasCopied ? 'Copied!' : 'Copy'} placement="top" closeOnClick={false}>
<IconButton
aria-label="Copy"
size="sm"
onClick={copy.onCopy}
isDisabled={value.length === 0}
colorScheme="teal"
icon={<CopyIcon />}
/>
</Tooltip>
);
}
return (
<Center>
<Button size="sm" onClick={copy.onCopy} isDisabled={value.length === 0}>
{copy.hasCopied ? 'Copied!' : 'Copy'}
</Button>
</Center>
);
};
export default CopyCell;

View File

@@ -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 (
<Modal {...modalProps} title={account.name}>
<Box>
<Tabs>
<TabList>
<Tab>{t('common.details')}</Tab>
<Tab>{t('certificates.title')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Heading size="md" textDecoration="underline">
{t('roaming.account_one')} {t('common.details')}
</Heading>
<Flex my={2} alignItems="center">
<Heading size="sm" {...labelProps}>
{t('common.name')}:
</Heading>
<Text>{account.name}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" {...labelProps}>
{t('roaming.common_name')}:
</Heading>
<Text>{account.commonName}</Text>
</Flex>
<Flex my={2} alignItems="center" hidden={account.description.length === 0}>
<Heading size="sm" {...labelProps}>
{t('common.description')}:
</Heading>
<Text>{account.description}</Text>
</Flex>
<Heading size="md" textDecoration="underline" mt={2}>
{t('roaming.global_reach')}
</Heading>
<Flex my={2} alignItems="center">
<Heading size="sm" {...labelProps}>
{t('roaming.global_reach_account_id')}:
</Heading>
<Text>{account.GlobalReachAcctId}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" {...labelProps}>
{t('roaming.private_key')}:
</Heading>
<Button onClick={privateKeyCopy.onCopy} size="sm" colorScheme="blue">
{privateKeyCopy.hasCopied ? 'Copied!' : t('common.copy')}
</Button>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" {...labelProps}>
CSR:
</Heading>
<Button onClick={csrCopy.onCopy} size="sm" colorScheme="blue">
{csrCopy.hasCopied ? 'Copied!' : t('common.copy')}
</Button>
</Flex>
<Heading size="md" textDecoration="underline">
{t('roaming.location_details_title')}
</Heading>
<Heading size="sm" my={2}>
{account.city}, {state()},{' '}
{COUNTRY_LIST.find((acc) => acc.value === account.country)?.label ?? account.country}
</Heading>
<Flex my={2} alignItems="center">
<Heading size="sm" {...labelProps}>
{t('roaming.organization')}:
</Heading>
<Text>{account.organization}</Text>
</Flex>
</TabPanel>
<TabPanel>
<CertificatesTable account={account} />
</TabPanel>
</TabPanels>
</Tabs>
</Box>
</Modal>
);
};
export default DetailsModal;

View File

@@ -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<GlobalReachAccount | null>(null);
const modalProps = useDisclosure();
const openModal = (newAcc: GlobalReachAccount) => {
setAccount(newAcc);
modalProps.onOpen();
};
return React.useMemo(
() => ({
openModal,
modal: account ? <DetailsModal account={account} modalProps={modalProps} /> : null,
}),
[account, modalProps],
);
};
export default useGlobalAccountModal;

View File

@@ -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<string>({ name: 'privateKey' });
React.useEffect(() => {
setRefreshId(uuid());
}, [privateKey.value]);
return (
<FormControl id="privateKey" isRequired isInvalid={privateKey.isError} isDisabled={isDisabled}>
<FormLabel>{t('roaming.private_key')}</FormLabel>
<FileInputButton
value={privateKey.value}
setValue={privateKey.onChange}
refreshId={refreshId}
accept=".pem"
isStringFile
/>
<FormErrorMessage>{privateKey.error}</FormErrorMessage>
</FormControl>
);
};
export default PrivateKeyField;

View File

@@ -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<string>({ name: 'country' });
const province = useFastField<string>({ 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 (
<StringField
name="province"
label={`${t('roaming.province')}/${t('roaming.state')}`}
isRequired
isDisabled={isDisabled}
w="300px"
/>
);
return (
<SelectField
name="province"
label={`${t('roaming.province')}/${t('roaming.state')}`}
isRequired
isDisabled={isDisabled}
w="max-content"
options={options}
/>
);
};
export default StatePicker;

View File

@@ -0,0 +1,6 @@
import * as React from 'react';
import GlobalReachAccountTable from './AccountTable';
const GlobalReachPage = () => <GlobalReachAccountTable />;
export default GlobalReachPage;

View File

@@ -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) => <FormattedDate date={date} key={uuid()} />;
const actionCell = (row: GoogleOrionAccount, open: (acc: GoogleOrionAccount) => void) => (
<GoogleOrionAccountActionCell account={row} openDetailsModal={open} />
);
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<GoogleOrionAccount>[] = 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}
<DataGrid<GoogleOrionAccount>
controller={tableController}
header={{
title: `${t('roaming.account', { count: getAccounts.data?.length ?? 0 })} ${
getAccounts.data?.length ? `(${getAccounts.data.length})` : ''
}`,
objectListed: t('roaming.account_other'),
addButton: <CreateGoogleOrionAccountModal />,
}}
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;

View File

@@ -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 (
<HStack spacing={2}>
<Popover placement="start">
{({ onClose, isOpen }) => (
<>
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="334px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.delete')} {account.name}
</PopoverHeader>
<PopoverBody>{t('crud.delete_confirm', { obj: t('roaming.account_one') })}</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={onDelete(onClose)} isLoading={deleteAccount.isLoading}>
{t('common.yes')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</>
)}
</Popover>
<Tooltip label={t('common.view_details')}>
<IconButton
aria-label={t('common.view_details')}
onClick={() => openDetailsModal(account)}
size="sm"
icon={<MagnifyingGlass size={20} />}
colorScheme="blue"
/>
</Tooltip>
</HStack>
);
};
export default GoogleOrionAccountActionCell;

View File

@@ -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 (
<>
<FormControl id={name ?? 'temporaryCerts'} isDisabled={isDisabled} w="300px" isRequired isInvalid={field.isError}>
<FormLabel>
CA Certificates ({field.value?.length})
{name ? null : (
<Tooltip hasArrow label="Upload every file inside the 'cacerts' directory you have obtained from Google">
<InfoIcon ml={2} mb="2px" />
</Tooltip>
)}
</FormLabel>
<FileInputButton value="" setValue={onAdd} refreshId={refreshId} accept="" isStringFile />
<FormErrorMessage>You need to upload at least one file to CA Certs</FormErrorMessage>
</FormControl>
<UnorderedList>
{field.value.map((v: { filename: string }, i: number) => (
<ListItem key={uuid()}>
{v.filename}
<Tooltip label={t('common.remove')}>
<IconButton
aria-label={t('common.remove')}
icon={<Trash size={20} />}
size="sm"
ml={2}
colorScheme="red"
onClick={() => onRemove(i)}
/>
</Tooltip>
</ListItem>
))}
</UnorderedList>
</>
);
};
export default GoogleOrionCaCertificateField;

View File

@@ -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<string>({ name: name ?? 'certificate' });
React.useEffect(() => {
setRefreshId(uuid());
}, [certificate.value]);
return (
<FormControl id="privateKey" isRequired isInvalid={certificate.isError} isDisabled={isDisabled}>
<FormLabel>{t('certificates.certificate')}</FormLabel>
<FileInputButton
value={certificate.value}
setValue={certificate.onChange}
refreshId={refreshId}
accept=".pem"
isStringFile
/>
<FormErrorMessage>{certificate.error}</FormErrorMessage>
</FormControl>
);
};
export default GoogleOrionCertificateField;

View File

@@ -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<CreateGoogleOrionAccountRequest & { temporaryCerts: { value: string; filename: string }[] }>,
) => {
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 (
<>
<CreateButton onClick={modal.onOpen} />
<Modal
isOpen={modal.isOpen}
onClose={modal.closeModal}
title={`${t('common.create')} ${t('roaming.account_one')}`}
topRightButtons={
<SaveButton onClick={form.submitForm} isLoading={form.isSubmitting} isDisabled={!form.isValid} />
}
>
<Box>
<Formik
key={formKey}
innerRef={formRef}
initialValues={
{
name: '',
description: '',
notes: [],
privateKey: '',
certificate: '',
cacerts: [],
temporaryCerts: [],
} as CreateGoogleOrionAccountRequest & { temporaryCerts: { value: string; filename: string }[] }
}
validateOnMount
validationSchema={FormSchema}
onSubmit={onSubmit}
>
<Box>
<Heading size="md" textDecoration="underline">
{t('roaming.account_one')} {t('common.details')}
</Heading>
<Flex my={2}>
<StringField name="name" label={t('common.name')} isRequired isDisabled={isFieldDisabled} w="300px" />
</Flex>
<StringField name="Description" isArea h="80px" isDisabled={isFieldDisabled} />
<Heading size="md" textDecoration="underline" mt={2}>
Google Orion
</Heading>
<Flex my={2}>
<Box w="300px">
<GoogleOrionCertificateField isDisabled={isFieldDisabled} />
</Box>
<Box w="300px" ml={2}>
<GoogleOrionPrivateKeyField isDisabled={isFieldDisabled} />
</Box>
</Flex>
<GoogleOrionCaCertificateField isDisabled={isFieldDisabled} />
</Box>
</Formik>
</Box>
</Modal>
<ConfirmCloseAlert isOpen={modal.isConfirmOpen} confirm={modal.closeCancelAndForm} cancel={modal.closeConfirm} />
</>
);
};
export default CreateGoogleOrionAccountModal;

View File

@@ -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<UpdateGoogleOrionAccountRequest>();
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<UpdateGoogleOrionAccountRequest>,
) => {
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 (
<>
<Modal
isOpen={modal.isOpen}
onClose={modal.closeModal}
title={account.name}
topRightButtons={
<>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!form.isValid}
hidden={!isEditing}
/>
<ToggleEditButton toggleEdit={setIsEditing.toggle} isEditing={isEditing} isDirty={form.dirty} />
</>
}
>
<Box>
<Formik
key={formKey}
innerRef={formRef}
initialValues={{
id: account.id,
name: account.name,
description: account.description,
// notes: account.notes,
}}
validateOnMount
validationSchema={FormSchema}
onSubmit={onSubmit}
>
<Box>
<Heading size="md" textDecoration="underline">
{t('roaming.account_one')} {t('common.details')}
</Heading>
<Flex my={2}>
<StringField name="name" label={t('common.name')} isRequired isDisabled={isFieldDisabled} w="300px" />
</Flex>
<StringField name="description" isArea h="80px" isDisabled={isFieldDisabled} />
<Heading size="md" textDecoration="underline" mt={2}>
Google Orion
</Heading>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Certificate:{' '}
</Heading>
<CopyCell value={account.certificate} />
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Private Key:{' '}
</Heading>
<CopyCell value={account.privateKey} />
</Flex>
<Heading size="sm">CA Certificates ({account.cacerts.length}):</Heading>
<UnorderedList>
{account.cacerts.map((v, i) => (
<ListItem key={uuid()} display="flex" alignItems="center">
<Text mr={2} my={2}>
Certificate #{i}:
</Text>
<CopyCell key={uuid()} value={v} />
</ListItem>
))}
</UnorderedList>
</Box>
</Formik>
</Box>
</Modal>
<ConfirmCloseAlert isOpen={modal.isConfirmOpen} confirm={modal.closeCancelAndForm} cancel={modal.closeConfirm} />
</>
);
};
export default GoogleOrionAccountEditModal;

View File

@@ -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<GoogleOrionAccount>();
const modalProps = useDisclosure();
const openModal = (newAcc: GoogleOrionAccount) => {
setAccount(newAcc);
modalProps.onOpen();
};
return React.useMemo(
() => ({
openModal,
modal: account ? <DetailsModal account={account} modalProps={modalProps} /> : null,
}),
[account, modalProps],
);
};
export default useGoogleOrionAccountModal;

View File

@@ -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<string>({ name: name ?? 'privateKey' });
React.useEffect(() => {
setRefreshId(uuid());
}, [privateKey.value]);
return (
<FormControl id="privateKey" isRequired isInvalid={privateKey.isError} isDisabled={isDisabled}>
<FormLabel>{t('roaming.private_key')}</FormLabel>
<FileInputButton
value={privateKey.value}
setValue={privateKey.onChange}
refreshId={refreshId}
accept=".pem"
isStringFile
/>
<FormErrorMessage>{privateKey.error}</FormErrorMessage>
</FormControl>
);
};
export default GoogleOrionPrivateKeyField;

View File

@@ -0,0 +1,6 @@
import * as React from 'react';
import GoogleOrionAccountTable from './AccountTable';
const GoogleOrionPage = () => <GoogleOrionAccountTable />;
export default GoogleOrionPage;

View File

@@ -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 (
<Tabs>
<TabList>
<Tab _selected={tabStyle}>Global Reach</Tab>
<Tab _selected={tabStyle}>Google Orion</Tab>
</TabList>
<TabPanels>
<TabPanel px={0}>
<Box w="100%">
<GlobalReachAccountTable />
</Box>
</TabPanel>
<TabPanel px={0}>
<Box w="100%">
<GoogleOrionPage />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
);
};
export default OpenRoamingPage;

View File

@@ -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 = () => {
<CardHeader>
<Heading size="md">{t('profile.your_profile')}</Heading>
<Spacer />
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!form.isValid || !form.dirty}
hidden={!isEditing}
/>
<ToggleEditButton toggleEdit={toggleEditing} isEditing={isEditing} ml={2} />
<HStack>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!form.isValid || !form.dirty}
hidden={!isEditing}
/>
<ToggleEditButton toggleEdit={toggleEditing} isEditing={isEditing} />
</HStack>
</CardHeader>
<CardBody display="block">
{!user ? (
@@ -86,8 +88,7 @@ const GeneralInformationProfile = () => {
) : (
<Formik<{
description: string;
firstName: string;
lastName: string;
name: string;
newPassword?: string;
}>
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 }) => (
<Form>
<StringField name="email" label={t('common.email')} isDisabled />
<Flex my={4}>
<StringField
name="firstName"
label={t('contacts.first_name')}
isDisabled={isSubmitting || !isEditing}
isRequired
/>
<Flex>
<StringField name="email" label={t('common.email')} isDisabled />
<Box w={8} />
<StringField
name="lastName"
label={t('contacts.last_name')}
name="name"
label={t('common.name')}
isDisabled={isSubmitting || !isEditing}
isRequired
/>
</Flex>
<StringField
h="100px"
name="description"
label={t('profile.about_me')}
isDisabled={isSubmitting || !isEditing}
isArea
/>
<Flex my={4}>
<StringField
name="newPassword"
@@ -185,6 +171,13 @@ const GeneralInformationProfile = () => {
hideButton
/>
</Flex>
<StringField
h="100px"
name="description"
label={t('profile.about_me')}
isDisabled={isSubmitting || !isEditing}
isArea
/>
<Box w="100%" mt={4} textAlign="right">
<Link href={passwordPolicyLink} isExternal>
{t('login.password_policy')}

View File

@@ -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<FormikProps<Record<string, unknown>>> | undefined;
finishStep: (v: Record<string, unknown>) => 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<typeof FormSchema>;
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 (
<Formik
innerRef={formRef as (instance: FormikProps<FormValues> | null) => void}
initialValues={initialValues}
validateOnMount
validationSchema={FormSchema}
onSubmit={(values: FormValues) => {
finishStep(values);
}}
>
<Box>
<Heading mb={4} size="md" textDecoration="underline">
{t('common.details')}
</Heading>
<Flex mb={2}>
<StringField name="name" label={t('common.name')} isRequired maxW="400px" />
</Flex>
<Box mb={2}>
<StringField name="description" label={t('common.description')} h="80px" isArea />
</Box>
<Heading mb={4} size="md" textDecoration="underline">
Endpoint
</Heading>
<SelectField name="Type" label="Endpoint Type" w="max-content" options={typeOptions} isRequired />
<Flex my={2}>
<Box>
<StringField name="Index" label="IP Index" isRequired />
</Box>
<Box mx={4}>
<SelectField
name="PoolStrategy"
label="Pool Strategy"
w="140px"
options={[
{ value: 'random', label: 'Random' },
{ value: 'round_robin', label: 'Round-Robin' },
{ value: 'weighted', label: 'Weighted' },
]}
isRequired
/>
</Box>
<ToggleField name="UseGWProxy" label="Use Gateway Proxy" />
</Flex>
<Flex my={2}>
<StringField name="NasIdentifier" label="NAS Identifier" w="300px" />
<Box mx={4}>
<NumberField name="AccountingInterval" label="Accounting Interval" w="120px" isRequired unit="s" />
</Box>
</Flex>
</Box>
</Formik>
);
};
export default CreateRadiusEndpointDetailsStep;

View File

@@ -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 (
<Box>
<RadioGroup
isDisabled={isDisabled}
value={field.value[0]?.UseOpenRoamingAccount}
onChange={(v) =>
field.onChange([
{
UseOpenRoamingAccount: v,
Weight: 0,
},
])
}
>
<Stack>
{certificates.map((certificate) => (
<Radio value={certificate.id} key={certificate.id}>
{certificate.name}
</Radio>
))}
</Stack>
</RadioGroup>
</Box>
);
};
export default GlobalReachAccountField;

View File

@@ -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<FormikProps<Record<string, unknown>>> | undefined;
finishStep: (v: Record<string, unknown>) => void;
accounts: GlobalReachAccount[];
};
const CreateRadiusEndpointGlobalReachStep = ({ formRef, finishStep, accounts }: Props) => {
const { t } = useTranslation();
const [selectedAccount, setSelectedAccount] = React.useState<GlobalReachAccount>(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<typeof FormSchema>;
const initialValues: FormValues = FormSchema.cast({});
return (
<Formik
innerRef={formRef as (instance: FormikProps<FormValues> | null) => void}
initialValues={initialValues}
validateOnMount
validationSchema={FormSchema}
onSubmit={async (values: FormValues) => {
await finishStep({
RadsecServers: values.Accounts,
});
}}
>
{({ isSubmitting }) => (
<Box>
<Heading mb={4} size="md" textDecoration="underline">
What Global Reach account would like to use?
</Heading>
<Select
mb={2}
value={selectedAccount.id}
onChange={(e) => {
const found = accounts.find((account) => account.id === e.target.value);
if (found) {
setSelectedAccount(found);
}
}}
w="max-content"
>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</Select>
<Heading mb={4} size="md" textDecoration="underline">
Please choose one or more certificates to use:
</Heading>
<Box>
{getCertificates.data ? (
<GlobalReachAccountField certificates={getCertificates.data} name="Accounts" isDisabled={isSubmitting} />
) : (
<Center my={8}>
<Spinner size="xl" />
</Center>
)}
</Box>
</Box>
)}
</Formik>
);
};
export default CreateRadiusEndpointGlobalReachStep;

View File

@@ -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 (
<Box>
<RadioGroup
isDisabled={isDisabled}
value={field.value[0]?.UseOpenRoamingAccount}
onChange={(v) =>
field.onChange([
{
UseOpenRoamingAccount: v,
Weight: 0,
},
])
}
>
<Stack>
{accounts.map((account) => (
<Radio value={account.id} key={account.id}>
{account.name} {account.description ? ` - ${account.description}` : ''}
</Radio>
))}
</Stack>
</RadioGroup>
</Box>
);
};
export default OrionAccountField;

View File

@@ -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<FormikProps<Record<string, unknown>>> | undefined;
finishStep: (v: Record<string, unknown>) => 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<typeof FormSchema>;
const initialValues: FormValues = FormSchema.cast({});
return (
<Formik
innerRef={formRef as (instance: FormikProps<FormValues> | null) => void}
initialValues={initialValues}
validateOnMount
validationSchema={FormSchema}
onSubmit={async (values: FormValues) => {
await finishStep({
RadsecServers: values.Accounts,
});
}}
>
{({ isSubmitting }) => (
<Box>
<Heading mb={4} size="md" textDecoration="underline">
Please choose one or more Google Orion accounts you would like to use:
</Heading>
<OrionAccountField accounts={accounts} name="Accounts" isDisabled={isSubmitting} />
</Box>
)}
</Formik>
);
};
export default CreateRadiusEndpointOrionStep;

View File

@@ -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 (
<Formik
key={key}
innerRef={formRef}
initialValues={{
Authentication: [
{
Hostname: '',
IP: '',
Port: 1812,
Secret: '',
},
],
Accounting: [
{
Hostname: '',
IP: '',
Port: 1813,
Secret: '',
},
],
CoA: [
{
Hostname: '',
IP: '',
Port: 1814,
Secret: '',
},
],
AccountingInterval: 60,
}}
validateOnMount
validationSchema={RadiusEndpointServerSchema}
onSubmit={async (values) => {
onAdd(values);
resetForm();
}}
>
<Box>
<NumberField name="AccountingInterval" label="Accounting Interval" isRequired w="120px" />
<RadiusServerForm label="Authentication" namePrefix="Authentication" />
<RadiusServerForm label="Accounting" namePrefix="Accounting" />
<RadiusServerForm label="Change of Authorization" namePrefix="CoA" />
<Center my={8}>
<Button
onClick={form.submitForm}
isDisabled={!form.isValid}
colorScheme="blue"
rightIcon={<Plus size={20} />}
>
Add Server
</Button>
</Center>
</Box>
</Formik>
);
};
export default RadiusEndpointServerForm;

View File

@@ -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 (
<>
<Flex mt={4} mb={2} alignItems="center">
<Heading size="sm" mr={2}>
{label} ({field.value.length})
</Heading>
<Button onClick={onAdd} colorScheme="blue" size="sm">
Add New Entry
</Button>
</Flex>
<Box>
{field.value.map((_: unknown, i: number) => (
<Box key={i} borderWidth={1} borderRadius="15px" p={4} mb={4}>
<Flex alignItems="center">
<Heading size="sm" mr={2}>
#{i}
</Heading>
<DeleteButton onClick={() => onRemove(i)} isDisabled={field.value.length === 1} />
</Flex>
<Flex>
<Box w="300px">
<StringField name={name('Hostname', i)} label="Hostname" isRequired />
</Box>
<Box w="240px" mx={4}>
<StringField name={name('IP', i)} label="IP" isRequired />
</Box>
<Box>
<NumberField name={name('Port', i)} label="Port" w="120px" isRequired />
</Box>
</Flex>
<StringField name={name('Secret', i)} label="Secret" hideButton w="300px" isRequired />
</Box>
))}
</Box>
</>
);
};
export default React.memo(RadiusServerForm);

View File

@@ -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<FormikProps<Record<string, unknown>>> | undefined;
finishStep: (v: Record<string, unknown>) => 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<typeof FormSchema>;
const initialValues: FormValues = FormSchema.cast({});
return (
<Formik
innerRef={formRef as (instance: FormikProps<FormValues> | null) => void}
initialValues={initialValues}
validateOnMount
validationSchema={FormSchema}
onSubmit={async (values: FormValues) => {
await finishStep({
RadiusServers: values.RadiusServers,
});
}}
>
{({ setFieldValue, values }) => (
<Box>
<Heading mb={4} size="md" textDecoration="underline">
Please input the information of one or more generic radius servers:
</Heading>
<RadiusEndpointServerForm setValue={setFieldValue} value={values.RadiusServers} />
<Heading mt={8} mb={4} size="md" textDecoration="underline">
Servers ({values.RadiusServers.length})
</Heading>
<UnorderedList>
{values.RadiusServers.map((v: { Authentication: { Hostname: string }[] }) => (
<ListItem key={uuid()} display="flex" alignItems="center">
<Heading size="sm">{v.Authentication[0]?.Hostname}</Heading>
<DeleteButton
ml={4}
onClick={() => {
setFieldValue(
'RadiusServers',
values.RadiusServers.filter(
(s: { Authentication: { Hostname: string }[] }) =>
s.Authentication[0]?.Hostname !== v.Authentication[0]?.Hostname,
),
);
}}
/>
</ListItem>
))}
</UnorderedList>
</Box>
)}
</Formik>
);
};
export default CreateRadiusEndpointRadiusStep;

View File

@@ -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<FormikProps<Record<string, unknown>>> | undefined;
finishStep: (v: Record<string, unknown>) => 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<typeof FormSchema>;
const initialValues: FormValues = FormSchema.cast({});
return (
<Formik
innerRef={formRef as (instance: FormikProps<FormValues> | null) => void}
initialValues={initialValues}
validateOnMount
validationSchema={FormSchema}
onSubmit={async (values: FormValues) => {
await finishStep({
RadsecServers: values.RadsecServers,
});
}}
>
{({ setFieldValue, values }) => (
<Box>
<Heading mb={4} size="md" textDecoration="underline">
Please input the information of one or more Radsec Servers:
</Heading>
<RadiusEndpointServerForm setValue={setFieldValue} value={values.RadsecServers} />
<Heading mt={8} mb={4} size="md" textDecoration="underline">
Radsec Servers ({values.RadsecServers.length})
</Heading>
<UnorderedList>
{values.RadsecServers.map((v: { Hostname: string }) => (
<ListItem key={uuid()} display="flex" alignItems="center">
<Heading size="sm">{v.Hostname}</Heading>
<DeleteButton
ml={4}
onClick={() => {
setFieldValue(
'RadsecServers',
values.RadsecServers.filter((s: { Hostname: string }) => s.Hostname !== v.Hostname),
);
}}
/>
</ListItem>
))}
</UnorderedList>
</Box>
)}
</Formik>
);
};
export default CreateRadiusEndpointRadsecStep;

View File

@@ -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 (
<Formik
key={key}
innerRef={formRef}
initialValues={RadiusEndpointServerSchema.cast({})}
validateOnMount
validationSchema={RadiusEndpointServerSchema}
onSubmit={async (values) => {
const cleanedCaCerts = values.CaCerts.map((cert: { value: string }) => cert.value);
onAdd({
...values,
CaCerts: cleanedCaCerts,
});
resetForm();
}}
>
<Box>
<Heading mb={4} size="sm">
Radsec Server
</Heading>
<Flex mb={4}>
<Box w="300px">
<StringField name="Hostname" label="Hostname" isRequired />
</Box>
<Box w="240px" mx={4}>
<StringField name="IP" label="IP" />
</Box>
<Box>
<NumberField name="Port" label="Port" w="120px" isRequired />
</Box>
</Flex>
<Flex mb={4}>
<Box>
<StringField name="Secret" label="Secret" hideButton w="300px" isRequired />
</Box>
<Box ml={4}>
<ToggleField name="AllowSelfSigned" label="Allow Self Signed" />
</Box>
</Flex>
<Heading mb={4} size="sm">
Certificates
</Heading>
<Flex mb={2}>
<Box w="300px">
<GoogleOrionCertificateField name="Certificate" />
</Box>
<Box w="300px" ml={2}>
{' '}
<GoogleOrionPrivateKeyField name="PrivateKey" />
</Box>
</Flex>
<GoogleOrionCaCertificateField name="CaCerts" />
<Center mt={4} mb={8}>
<Button
onClick={form.submitForm}
isDisabled={!form.isValid}
colorScheme="blue"
rightIcon={<Plus size={20} />}
>
Add Server
</Button>
</Center>
</Box>
</Formik>
);
};
export default RadsecEndpointServerForm;

View File

@@ -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<RadiusEndpoint>;
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<RadiusEndpoint>) => {
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 (
<CreateRadiusEndpointDetailsStep
formRef={formRef}
finishStep={handleNextStep}
orionAccounts={orionAccounts}
globalReachAccounts={globalReachAccounts}
/>
);
}
const type = data.data.Type ?? 'generic';
if (type === 'orion') {
return <CreateRadiusEndpointOrionStep accounts={orionAccounts} formRef={formRef} finishStep={handleNextStep} />;
}
if (type === 'globalreach') {
return (
<CreateRadiusEndpointGlobalReachStep
accounts={globalReachAccounts}
formRef={formRef}
finishStep={handleNextStep}
/>
);
}
if (type === 'generic') {
return <CreateRadiusEndpointRadiusStep formRef={formRef} finishStep={handleNextStep} />;
}
return <CreateRadiusEndpointRadsecStep formRef={formRef} finishStep={handleNextStep} />;
};
return (
<>
<CreateButton onClick={modal.onOpen} />
<Modal
title="Create Radius Endpoint"
isOpen={modal.isOpen}
onClose={modal.closeModal}
topRightButtons={
<>
<Tooltip label={t('common.reset')}>
<IconButton
aria-label={t('common.reset')}
onClick={onReset}
icon={<ArrowLeft size={20} />}
isDisabled={data.step === 0}
/>
</Tooltip>
<StepButton
onNext={form.submitForm}
currentStep={data.step}
lastStep={1}
isLoading={create.isLoading}
isDisabled={!form.isValid}
/>
</>
}
>
<Box>{body()}</Box>
</Modal>
<ConfirmCloseAlert isOpen={modal.isConfirmOpen} confirm={modal.closeCancelAndForm} cancel={modal.closeConfirm} />
</>
);
};
export default CreateRadiusEndpointModal;

View File

@@ -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' : <FormattedDate date={getLastUpdate.data.lastUpdate} />;
const lastConfigurationChange =
getLastUpdate.data.lastConfigurationChange === 0 ? (
'Never'
) : (
<FormattedDate date={getLastUpdate.data.lastConfigurationChange} />
);
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 (
<>
<Tooltip
label={
lastUpdateInfo.isUpToDate
? 'The RADIUS configuration of your controller matches your RADIUS endpoints'
: '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'
}
>
<Tag colorScheme={lastUpdateInfo.isUpToDate ? 'green' : 'yellow'} size="lg">
<TagLeftIcon as={Warning} hidden={lastUpdateInfo.isUpToDate} />
<Text mr={2}>Last Update:</Text>
{lastUpdateInfo.lastUpdate}
</Tag>
</Tooltip>
<Tooltip label="Update Controller" closeOnClick={false}>
<IconButton aria-label="Update" onClick={onOpen} colorScheme="purple" icon={<CloudArrowUp size={20} />} />
</Tooltip>
<Modal
{...modalProps}
title="Update Endpoints"
options={
{
// modalSize: 'sm',
}
}
topRightButtons={<RefreshButton onClick={getLastUpdate.refetch} isFetching={getLastUpdate.isFetching} />}
>
<Box>
{!lastUpdateInfo.isUpToDate ? (
<Alert status="warning" mb={4}>
<AlertIcon />
<Box>
<AlertDescription>
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
</AlertDescription>
</Box>
</Alert>
) : null}
<Heading size="sm">Last Provisioning change: {lastUpdateInfo.lastConfigurationChange}</Heading>
<Heading size="sm">Last Controller update: {lastUpdateInfo.lastUpdate}</Heading>
<Alert status="error" mt={4}>
<AlertIcon />
<Box>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
Updating the Controller with the latest RADIUS endpoint values may cause some RADIUS disruption for 1-2
minutes
</AlertDescription>
</Box>
</Alert>
<Center mt={4}>
<Button onClick={modalProps.onClose} ml={-2} mr={2}>
Cancel
</Button>
<Button ml={2} colorScheme="red" onClick={onTrigger} isLoading={triggerUpdate.isLoading}>
Proceed
</Button>
</Center>
</Box>
</Modal>
</>
);
};
export default LastRadiusEndpointUpdateButton;

View File

@@ -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 : (
<Box borderWidth={1} borderRadius="15px" p={4} my={2}>
<Flex alignItems="center">
<Heading w="100px" size="sm">
Name:
</Heading>
<Box>{endpoint.name}</Box>
</Flex>
<Flex alignItems="center">
<Heading w="100px" size="sm">
Description:
</Heading>
<Box>{endpoint.description}</Box>
</Flex>
<Flex alignItems="center">
<Heading w="100px" size="sm">
Type:
</Heading>
<Box>{prettyTypes[endpoint.Type]}</Box>
</Flex>
</Box>
);
export default RadiusEndpointSummary;

View File

@@ -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 (
<HStack spacing={2}>
<Popover placement="start">
{({ onClose, isOpen }) => (
<>
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="350px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.delete')} {endpoint.name}
</PopoverHeader>
<PopoverBody>{t('crud.delete_confirm', { obj: 'radius endpoint' })}</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={onDelete(onClose)} isLoading={deleteEndpoint.isLoading}>
{t('common.yes')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</>
)}
</Popover>
<Tooltip label={t('common.view_details')}>
<IconButton
aria-label={t('common.view_details')}
onClick={() => onEdit(endpoint)}
size="sm"
icon={<MagnifyingGlass size={20} />}
colorScheme="blue"
/>
</Tooltip>
</HStack>
);
};
export default RadiusEndpointActions;

View File

@@ -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 <GoogleOrionEndpointDetails endpoint={endpoint} />;
if (endpoint.Type === 'generic') return <RadiusEndpointDetails endpoint={endpoint} />;
if (endpoint.Type === 'radsec') return <RadsecEndpointDetails endpoint={endpoint} />;
if (endpoint.Type === 'globalreach') return <GlobalReachEndpointDetails endpoint={endpoint} />;
return null;
};
return (
<Box mt={2}>
<Heading mb={4} size="md" textDecoration="underline">
Endpoint
</Heading>
<Flex>
<Heading {...firstColProps} size="sm">
Type:
</Heading>
<Text {...secondColProps}>{prettyTypes[endpoint.Type]}</Text>
</Flex>
<Flex>
<Heading {...firstColProps} size="sm">
IP Index:
</Heading>
<Text {...secondColProps}>{endpoint.Index}</Text>
</Flex>
<Flex>
<Heading {...firstColProps} size="sm">
Pool Strategy:
</Heading>
<Text {...secondColProps}>{uppercaseFirstLetter(endpoint.PoolStrategy)}</Text>
</Flex>
<Flex>
<Heading {...firstColProps} size="sm">
Gateway Proxy:
</Heading>
<Text {...secondColProps}>{endpoint.UseGWProxy ? 'On' : 'Off'}</Text>
</Flex>
<Flex>
<Heading {...firstColProps} size="sm">
NAS Identifier:
</Heading>
<Text {...secondColProps}>{endpoint.NasIdentifier}</Text>
</Flex>
<Flex>
<Heading {...firstColProps} size="sm">
Accounting Interval:
</Heading>
<Text {...secondColProps}>{endpoint.AccountingInterval}s</Text>
</Flex>
{furtherDetails()}
</Box>
);
};
export default EndpointDisplay;

View File

@@ -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) => <CopyCell value={value} key={uuid()} isCompact />;
type Props = {
endpoint: RadiusEndpoint;
};
const GlobalReachEndpointDetails = ({ endpoint }: Props) => {
const getCertificates = useGetSelectedGlobalReachCertificates({
certIds: endpoint.RadsecServers.map((v) => v.UseOpenRoamingAccount),
});
const certificate = getCertificates.data?.[0];
return (
<Box mt={2}>
<Heading size="md" textDecoration="underline">
Global Reach Certificate
</Heading>{' '}
{certificate ? (
<Box>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Name:{' '}
</Heading>
<Text>{certificate.name}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Created:{' '}
</Heading>
<FormattedDate date={certificate.created} />
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Expiry:{' '}
</Heading>
<FormattedDate date={certificate.expiresAt} />
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Certificate:{' '}
</Heading>
{copyCell(certificate.certificate)}
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Cert. Chain:{' '}
</Heading>
{copyCell(certificate.certificateChain)}
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
CSR:{' '}
</Heading>
{copyCell(certificate.csr)}
</Flex>
</Box>
) : (
<Alert status="warning" mt={4}>
<AlertIcon /> <AlertDescription>Cannot retrieve certificate information for now</AlertDescription>
</Alert>
)}
</Box>
);
};
export default GlobalReachEndpointDetails;

View File

@@ -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 (
<Box mt={2}>
<Heading size="md" textDecoration="underline">
Google Orion Account
</Heading>
{account ? (
<Box mt={2}>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Certificate:{' '}
</Heading>
<CopyCell value={account.certificate} />
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Private Key:{' '}
</Heading>
<CopyCell value={account.privateKey} />
</Flex>
<Heading size="sm">CA Certificates ({account.cacerts.length}):</Heading>
<UnorderedList>
{account.cacerts.map((v, i) => (
<ListItem key={uuid()} display="flex" alignItems="center">
<Text mr={2} my={2}>
Certificate #{i}:
</Text>
<CopyCell key={uuid()} value={v} />
</ListItem>
))}
</UnorderedList>
</Box>
) : (
'No account found'
)}
</Box>
);
};
export default GoogleOrionEndpointDetails;

View File

@@ -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) => (
<Box>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Hostname:
</Heading>
<Text>{Hostname}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
IP:
</Heading>
<Text>{IP}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Port:
</Heading>
<Text>{Port}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Secret:
</Heading>
<Text>{Secret}</Text>
</Flex>
</Box>
);
type Props = {
endpoint: RadiusEndpoint;
};
const RadiusEndpointDetails = ({ endpoint }: Props) => (
<Box mt={2}>
<Heading size="md" textDecoration="underline">
Servers
</Heading>
<Accordion allowToggle defaultIndex={[0]} mt={2}>
{endpoint.RadiusServers.map((server) => (
<AccordionItem key={uuid()}>
<AccordionButton>
<Heading size="sm">{server.Authentication.map((data) => data.Hostname).join(', ')}</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Accounting Interval:{' '}
</Heading>
<Text>{server.AccountingInterval}s</Text>
</Flex>
<Heading size="md" my={2} textDecoration="underline">
Authentication
</Heading>
{server.Authentication.map((data) => (
<SectionDisplay {...data} />
))}
<Heading size="md" my={2} textDecoration="underline">
Accounting
</Heading>
{server.Accounting.map((data) => (
<SectionDisplay {...data} />
))}
<Heading size="md" my={2} textDecoration="underline">
Change of Authorization (CoA)
</Heading>
{server.CoA.map((data) => (
<SectionDisplay {...data} />
))}
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</Box>
);
export default RadiusEndpointDetails;

View File

@@ -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) => (
<Box mt={2}>
<Heading size="md" textDecoration="underline">
RadSec Servers
</Heading>
<Accordion allowToggle defaultIndex={[0]} mt={2}>
{endpoint.RadsecServers.map((server) => (
<AccordionItem key={uuid()}>
<AccordionButton>
<Heading size="sm">{server.Hostname}</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
IP Address:{' '}
</Heading>
<Text>{server.IP}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Port:{' '}
</Heading>
<Text>{server.Port}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Secret:{' '}
</Heading>
<Text>{server.Secret}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Allow Self-Signed:{' '}
</Heading>
<Text>{server.AllowSelfSigned ? 'Yes' : 'No'}</Text>
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Certificate:{' '}
</Heading>
<CopyCell value={server.Certificate} />
</Flex>
<Flex my={2} alignItems="center">
<Heading size="sm" mr={2}>
Private Key:{' '}
</Heading>
<CopyCell value={server.PrivateKey} />
</Flex>
<Heading size="sm">CA Certificates ({server.CaCerts.length}):</Heading>
<UnorderedList>
{server.CaCerts.map((v, i) => (
<ListItem key={uuid()} display="flex" alignItems="center">
<Text mr={2} my={2}>
Certificate #{i}:
</Text>
<CopyCell key={uuid()} value={v} />
</ListItem>
))}
</UnorderedList>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</Box>
);
export default RadsecEndpointDetails;

View File

@@ -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<AtLeast<RadiusEndpoint, 'id'>>();
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<RadiusEndpoint, 'id'>,
helpers: FormikHelpers<AtLeast<RadiusEndpoint, 'id'>>,
) => {
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 (
<>
<Modal
isOpen={modal.isOpen}
onClose={modal.closeModal}
title={endpoint.name}
topRightButtons={
<>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!form.isValid}
hidden={!isEditing}
/>
{!hideEdit ? (
<ToggleEditButton toggleEdit={setIsEditing.toggle} isEditing={isEditing} isDirty={form.dirty} />
) : null}
</>
}
>
<Box>
<Formik
key={formKey}
innerRef={formRef}
initialValues={{
id: endpoint.id,
name: endpoint.name,
description: endpoint.description,
}}
validateOnMount
validationSchema={FormSchema}
onSubmit={onSubmit}
>
<Box>
<Heading size="md" textDecoration="underline">
{t('roaming.account_one')} {t('common.details')}
</Heading>
<Flex my={2}>
<StringField name="name" label={t('common.name')} isRequired isDisabled={isFieldDisabled} w="300px" />
</Flex>
<StringField
name="description"
label={t('common.description')}
isArea
h="80px"
isDisabled={isFieldDisabled}
/>
</Box>
</Formik>
<EndpointDisplay endpoint={endpoint} />
</Box>
</Modal>
<ConfirmCloseAlert isOpen={modal.isConfirmOpen} confirm={modal.closeCancelAndForm} cancel={modal.closeConfirm} />
</>
);
};
export default ViewDetailsModal;

View File

@@ -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<RadiusEndpoint>();
const modalProps = useDisclosure();
const openModal = (newEndpoint: RadiusEndpoint) => {
setEndpoint(newEndpoint);
modalProps.onOpen();
};
return React.useMemo(
() => ({
openModal,
modal: endpoint ? <DetailsModal endpoint={endpoint} modalProps={modalProps} hideEdit={hideEdit} /> : null,
}),
[endpoint, modalProps],
);
};
export default useRadiusEndpointAccountModal;

View File

@@ -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) => <FormattedDate date={date} />;
const actionCell = (row: RadiusEndpoint, open: (acc: RadiusEndpoint) => void) => (
<RadiusEndpointActions endpoint={row} onEdit={open} />
);
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 (
<Tag colorScheme={colorScheme} size="md">
{row.Type}
</Tag>
);
};
const RadiusEndpointsManagement = () => {
const { t } = useTranslation();
const table = useRadiusEndpointsTable({
tableSettingsId: 'system.radiusEndpoints.table',
});
const getOrionAccounts = useGetGoogleOrionAccounts();
const getGlobalReachAccounts = useGetGlobalReachAccounts();
const modal = useRadiusEndpointAccountModal({});
const columns: DataGridColumn<RadiusEndpoint>[] = 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}
<DataGrid<RadiusEndpoint>
controller={table.controller}
header={{
title: t('openroaming.radius_endpoint_other'),
objectListed: t('openroaming.radius_endpoint_other'),
addButton: (
<CreateRadiusEndpointModal
// @ts-ignore
orionAccounts={getOrionAccounts.data ?? []}
// @ts-ignore
globalReachAccounts={getGlobalReachAccounts.data ?? []}
/>
),
otherButtons: <LastRadiusEndpointUpdateButton />,
}}
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;

View File

@@ -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,
};
};

View File

@@ -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';

View File

@@ -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 (
<Card {...props}>
<CardHeader>
<Heading size="md" my="auto">
{t('system.secrets')}
</Heading>
<Spacer />
<SystemSecretCreateButton />
</CardHeader>
<CardBody p={4}>
<SystemSecretsTable />
</CardBody>
</Card>
);
};
export default SystemSecrets;

View File

@@ -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 (
<Card {...props}>
<CardHeader>
<Heading size="md" my="auto">
{t('system.secrets')}
</Heading>
<Spacer />
<SystemSecretCreateButton />
</CardHeader>
<CardBody p={4}>
<SystemSecretsTable />
</CardBody>
</Card>
<Tabs>
<TabList>
<Tab>{t('openroaming.radius_endpoint_other')}</Tab>
<Tab>{t('system.secrets')}</Tab>
</TabList>
<TabPanels>
<TabPanel px={0}>
<RadiusEndpointsManagement />
</TabPanel>
<TabPanel px={0}>
<SystemSecrets />
</TabPanel>
</TabPanels>
</Tabs>
);
};

Some files were not shown because too many files have changed in this diff Show More