Add users and network settings tabs with admin protections.
List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
Generated
+281
-4
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
@@ -16,6 +17,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
@@ -1031,6 +1033,15 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -1184,8 +1195,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1230,6 +1239,14 @@
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1239,6 +1256,32 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1283,6 +1326,14 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -1307,6 +1358,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
@@ -1314,6 +1370,11 @@
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
@@ -1367,6 +1428,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1381,6 +1454,14 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
@@ -1402,6 +1483,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
@@ -1704,6 +1793,17 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
@@ -1826,6 +1926,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
@@ -1838,6 +1971,14 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1862,6 +2003,14 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
@@ -1914,6 +2063,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
@@ -1953,6 +2118,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -1962,6 +2135,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
@@ -2012,6 +2190,11 @@
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -2039,6 +2222,30 @@
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -2384,6 +2591,11 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -2400,6 +2612,33 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
@@ -2414,6 +2653,44 @@
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
@@ -20,6 +21,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
|
||||
@@ -51,6 +51,15 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section-divider {
|
||||
width: 40%;
|
||||
height: 1px;
|
||||
margin-block: 0.4rem;
|
||||
margin-inline: auto;
|
||||
background: var(--ctp-surface1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -311,6 +320,19 @@
|
||||
font-weight: 650;
|
||||
color: var(--ctp-text);
|
||||
margin-bottom: 0.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.groups-li-meta {
|
||||
@@ -331,6 +353,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups-delete:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.groups-delete:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.groups-delete:hover {
|
||||
background: var(--ctp-mauve-hover);
|
||||
color: var(--ctp-mauve);
|
||||
@@ -468,3 +500,102 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(ellipse at top, color-mix(in srgb, var(--ctp-mauve) 18%, transparent), transparent 55%),
|
||||
var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.wizard-card {
|
||||
width: min(560px, 100%);
|
||||
padding: 1.75rem 1.75rem 1.5rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
box-shadow: 0 12px 40px color-mix(in srgb, var(--ctp-crust) 35%, transparent);
|
||||
}
|
||||
|
||||
.wizard-brand {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wizard-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wizard-step-indicator {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-body h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.wizard-body p {
|
||||
margin: 0 0 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.wizard-error {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wizard-rules {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.totp-setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-base);
|
||||
}
|
||||
|
||||
.totp-qr {
|
||||
align-self: center;
|
||||
border-radius: 0.25rem;
|
||||
background: #fff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.totp-secret {
|
||||
display: block;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
+335
-14
@@ -10,6 +10,24 @@ function stubFetchOk() {
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/health')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -49,6 +67,52 @@ function stubFetchOk() {
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/users')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
users: [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'Admin',
|
||||
enabled: true,
|
||||
totp_confirmed: false,
|
||||
group_names: ['Administrators'],
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
password_changed_at: '2026-01-01T00:00:00.000Z',
|
||||
is_admin: true,
|
||||
can_delete: false,
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
username: 'operator',
|
||||
enabled: true,
|
||||
totp_confirmed: true,
|
||||
group_names: ['Operators'],
|
||||
created_at: '2026-02-01T00:00:00.000Z',
|
||||
password_changed_at: '2026-02-01T00:00:00.000Z',
|
||||
is_admin: false,
|
||||
can_delete: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/network')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
network: {
|
||||
listen_address: '0.0.0.0',
|
||||
public_hostname: '',
|
||||
access_mode: 'open',
|
||||
rules: [],
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
@@ -95,7 +159,7 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Cluster Canvas' }),
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`),
|
||||
@@ -103,14 +167,14 @@ describe('App', () => {
|
||||
expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'VMs' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Virtual Machines' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Containers' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Configuration' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Overview', level: 2 }),
|
||||
@@ -124,10 +188,12 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Profile' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Admin' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Profile', level: 2 }),
|
||||
screen.getByRole('heading', { name: 'Admin', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(window.location.pathname).toBe('/profile')
|
||||
expect(
|
||||
@@ -142,9 +208,9 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Profile', level: 2 }),
|
||||
await screen.findByRole('heading', { name: 'Admin', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Profile' })).toHaveAttribute(
|
||||
expect(screen.getByRole('button', { name: 'Admin' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
)
|
||||
@@ -156,12 +222,20 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'VMs' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Virtual Machines' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'VMs', level: 2 }),
|
||||
screen.getByRole('heading', { name: 'Virtual Machines', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(window.location.pathname).toBe('/vms')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Docker' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Docker', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(window.location.pathname).toBe('/docker')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
@@ -180,6 +254,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Groups' }))
|
||||
|
||||
@@ -197,7 +273,7 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
await screen.findByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
@@ -212,6 +288,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
|
||||
for (const label of [
|
||||
@@ -277,6 +355,29 @@ describe('App', () => {
|
||||
expect(screen.getByLabelText('Re-auth grace period (minutes)')).toBeDisabled()
|
||||
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Users' }))
|
||||
expect(screen.getByRole('tab', { name: 'Users' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(await screen.findByText('operator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Admin', { selector: '.user-badge' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Configured accounts')).toBeInTheDocument()
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
expect(deleteButtons[0]).toBeDisabled()
|
||||
expect(deleteButtons[1]).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Network' }))
|
||||
expect(screen.getByRole('tab', { name: 'Network' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(await screen.findByLabelText('Listen address')).toHaveValue('0.0.0.0')
|
||||
expect(screen.getByLabelText('Access mode')).toHaveValue('open')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Save network settings' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Integrations' }))
|
||||
expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
@@ -285,12 +386,202 @@ describe('App', () => {
|
||||
expect(screen.getByText('Coming soon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes a deletable user from the Users tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/health')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ok' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/users') && init?.method === 'DELETE') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
users: [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'Admin',
|
||||
enabled: true,
|
||||
totp_confirmed: false,
|
||||
group_names: ['Administrators'],
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
password_changed_at: '2026-01-01T00:00:00.000Z',
|
||||
is_admin: true,
|
||||
can_delete: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/users')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
users: [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'Admin',
|
||||
enabled: true,
|
||||
totp_confirmed: false,
|
||||
group_names: ['Administrators'],
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
password_changed_at: '2026-01-01T00:00:00.000Z',
|
||||
is_admin: true,
|
||||
can_delete: false,
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
username: 'operator',
|
||||
enabled: true,
|
||||
totp_confirmed: true,
|
||||
group_names: ['Operators'],
|
||||
created_at: '2026-02-01T00:00:00.000Z',
|
||||
password_changed_at: '2026-02-01T00:00:00.000Z',
|
||||
is_admin: false,
|
||||
can_delete: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Users' }))
|
||||
|
||||
expect(await screen.findByText('operator')).toBeInTheDocument()
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await user.click(deleteButtons[1])
|
||||
|
||||
expect(await screen.findByText('Configured accounts')).toBeInTheDocument()
|
||||
expect(screen.queryByText('operator')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Admin', { selector: '.user-badge' })).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/users?id=u2'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('saves network settings from the Network tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/health')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ok' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/network') && init?.method === 'PUT') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
network: {
|
||||
listen_address: '127.0.0.1',
|
||||
public_hostname: '',
|
||||
access_mode: 'open',
|
||||
rules: [],
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/network')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
network: {
|
||||
listen_address: '0.0.0.0',
|
||||
public_hostname: '',
|
||||
access_mode: 'open',
|
||||
rules: [],
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Network' }))
|
||||
|
||||
const listenInput = await screen.findByLabelText('Listen address')
|
||||
await user.clear(listenInput)
|
||||
await user.type(listenInput, '127.0.0.1')
|
||||
await user.click(screen.getByRole('button', { name: 'Save network settings' }))
|
||||
|
||||
expect(await screen.findByText('Network settings saved.')).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/network'),
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid idle timeout on Security save', async () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Security' }))
|
||||
|
||||
@@ -311,9 +602,29 @@ describe('App', () => {
|
||||
it('shows unavailable when backend status fetch fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -324,12 +635,15 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to latte when preference is auto and browser is light', () => {
|
||||
it('defaults to latte when preference is auto and browser is light', async () => {
|
||||
stubFetchOk()
|
||||
stubMatchMedia(false)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(document.documentElement.dataset.theme).toBe('latte')
|
||||
})
|
||||
|
||||
@@ -340,6 +654,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
@@ -368,6 +684,9 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
@@ -394,6 +713,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
+468
-6
@@ -1,16 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteUser,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
fetchGroups,
|
||||
fetchMe,
|
||||
fetchNetwork,
|
||||
fetchSecurity,
|
||||
fetchSetupStatus,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
type AccessMode,
|
||||
type Group,
|
||||
type GroupScopeKind,
|
||||
type NetworkSettings,
|
||||
type SecuritySettings,
|
||||
type User,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} from './api/client'
|
||||
import { LoginPage } from './LoginPage'
|
||||
import {
|
||||
navigateTo,
|
||||
parseLocation,
|
||||
@@ -19,6 +30,11 @@ import {
|
||||
type ConfigTabId,
|
||||
type SectionId,
|
||||
} from './navigation'
|
||||
import {
|
||||
validateHostname,
|
||||
validateListenAddress,
|
||||
} from './passwordPolicy'
|
||||
import { SetupWizard } from './SetupWizard'
|
||||
import {
|
||||
applyThemeFromPreference,
|
||||
getSystemPrefersDark,
|
||||
@@ -30,16 +46,20 @@ import './App.css'
|
||||
|
||||
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'vms', label: 'VMs' },
|
||||
{ id: 'containers', label: 'Containers' },
|
||||
{ id: 'docker', label: 'Docker' },
|
||||
{ id: 'vms', label: 'Virtual Machines' },
|
||||
]
|
||||
|
||||
const SECTION_TITLES: Record<SectionId, string> = {
|
||||
overview: 'Overview',
|
||||
vms: 'VMs',
|
||||
vms: 'Virtual Machines',
|
||||
docker: 'Docker',
|
||||
containers: 'Containers',
|
||||
profile: 'Profile',
|
||||
configuration: 'Configuration',
|
||||
setup: 'Setup',
|
||||
login: 'Sign in',
|
||||
}
|
||||
|
||||
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||||
@@ -64,6 +84,16 @@ const DEFAULT_PERMISSIONS = [
|
||||
'roles.manage',
|
||||
] as const
|
||||
|
||||
const ADMINISTRATORS_GROUP_NAME = 'Administrators'
|
||||
|
||||
function formatUserTimestamp(value: string): string {
|
||||
const parsed = Date.parse(value)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return value || '—'
|
||||
}
|
||||
return new Date(parsed).toLocaleString()
|
||||
}
|
||||
|
||||
function CogIcon() {
|
||||
return (
|
||||
<svg
|
||||
@@ -292,6 +322,12 @@ function GroupsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
disabled={group.name === ADMINISTRATORS_GROUP_NAME}
|
||||
title={
|
||||
group.name === ADMINISTRATORS_GROUP_NAME
|
||||
? 'Administrators group cannot be deleted'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void handleDeleteGroup(group.name)}
|
||||
>
|
||||
Delete
|
||||
@@ -380,6 +416,312 @@ function GroupsConfigTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function UsersConfigTab() {
|
||||
const [users, setUsers] = useState<ReadonlyArray<User>>([])
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(true)
|
||||
const [usersLoadError, setUsersLoadError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadUsers() {
|
||||
setIsLoadingUsers(true)
|
||||
setUsersLoadError(null)
|
||||
try {
|
||||
const response = await fetchUsers()
|
||||
if (!isCancelled) {
|
||||
setUsers(response.users)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setUsersLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load users',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadUsers()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleDeleteUser(userId: string) {
|
||||
setActionError(null)
|
||||
|
||||
try {
|
||||
const response = await deleteUser(userId)
|
||||
setUsers(response.users)
|
||||
} catch (err) {
|
||||
setActionError(
|
||||
err instanceof Error ? err.message : 'Failed to delete user',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groups-panel users-panel">
|
||||
<div className="groups-list">
|
||||
<p className="config-hint">Configured accounts</p>
|
||||
{actionError ? (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{isLoadingUsers ? (
|
||||
<p className="config-empty">Loading…</p>
|
||||
) : usersLoadError ? (
|
||||
<p className="config-empty" role="alert">
|
||||
{usersLoadError}
|
||||
</p>
|
||||
) : users.length === 0 ? (
|
||||
<p className="config-empty">No users configured yet.</p>
|
||||
) : (
|
||||
<ul className="groups-ul">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} className="groups-li">
|
||||
<div className="groups-li-content">
|
||||
<div className="groups-li-title">
|
||||
{user.username}
|
||||
{user.is_admin ? (
|
||||
<span className="user-badge">Admin</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
Groups:{' '}
|
||||
{user.group_names.length > 0
|
||||
? user.group_names.join(', ')
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '}
|
||||
{user.totp_confirmed ? 'confirmed' : 'not confirmed'}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
Created: {formatUserTimestamp(user.created_at)} · Password
|
||||
changed: {formatUserTimestamp(user.password_changed_at)}
|
||||
</div>
|
||||
<div className="groups-li-meta">ID: {user.id}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
disabled={!user.can_delete}
|
||||
title={
|
||||
user.can_delete
|
||||
? undefined
|
||||
: 'Cannot delete the last administrator'
|
||||
}
|
||||
onClick={() => void handleDeleteUser(user.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkConfigTab() {
|
||||
const [draft, setDraft] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
|
||||
const [rulesText, setRulesText] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [saveSucceeded, setSaveSucceeded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadNetwork() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const response = await fetchNetwork()
|
||||
if (!isCancelled) {
|
||||
setDraft(response.network)
|
||||
setRulesText(response.network.rules.join('\n'))
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to load network settings',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadNetwork()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
setSubmitError(null)
|
||||
setSaveSucceeded(false)
|
||||
|
||||
const listenError = validateListenAddress(draft.listen_address)
|
||||
if (listenError) {
|
||||
setSubmitError(listenError)
|
||||
return
|
||||
}
|
||||
const hostnameError = validateHostname(draft.public_hostname)
|
||||
if (hostnameError) {
|
||||
setSubmitError(hostnameError)
|
||||
return
|
||||
}
|
||||
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
try {
|
||||
const response = await saveNetwork({ ...draft, rules })
|
||||
setDraft(response.network)
|
||||
setRulesText(response.network.rules.join('\n'))
|
||||
setSaveSucceeded(true)
|
||||
} catch (err) {
|
||||
setSubmitError(
|
||||
err instanceof Error ? err.message : 'Failed to save network settings',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="config-empty">Loading…</p>
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<p className="config-empty" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="security-panel network-panel">
|
||||
<p className="config-hint">
|
||||
Listen bind address and IP access control for the admin UI.
|
||||
</p>
|
||||
|
||||
{submitError ? (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
{saveSucceeded ? (
|
||||
<p className="config-hint" role="status">
|
||||
Network settings saved.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-listen">Listen address</label>
|
||||
<input
|
||||
id="network-listen"
|
||||
className="config-input"
|
||||
value={draft.listen_address}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
listen_address: event.target.value,
|
||||
}))
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Default 0.0.0.0 (all interfaces). Applied on next process restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-hostname">Public hostname</label>
|
||||
<input
|
||||
id="network-hostname"
|
||||
className="config-input"
|
||||
placeholder="admin.mydomain.ext"
|
||||
value={draft.public_hostname}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
public_hostname: event.target.value,
|
||||
}))
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Leave empty for local access by IP. When set, the UI must be visited
|
||||
via that exact hostname.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-access-mode">Access mode</label>
|
||||
<select
|
||||
id="network-access-mode"
|
||||
className="config-input"
|
||||
value={draft.access_mode}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
access_mode: event.target.value as AccessMode,
|
||||
}))
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
>
|
||||
<option value="open">Open (no IP filter)</option>
|
||||
<option value="whitelist">Whitelist (deny non-matching)</option>
|
||||
<option value="blacklist">Blacklist (deny matching)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{draft.access_mode !== 'open' ? (
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-rules">IP / CIDR rules (one per line)</label>
|
||||
<textarea
|
||||
id="network-rules"
|
||||
className="config-input wizard-rules"
|
||||
rows={5}
|
||||
value={rulesText}
|
||||
onChange={(event) => {
|
||||
setRulesText(event.target.value)
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
Save network settings
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function validateSecurityDraft(security: SecuritySettings): string | null {
|
||||
if (
|
||||
!Number.isInteger(security.idle_timeout_minutes) ||
|
||||
@@ -672,10 +1014,18 @@ function ConfigTabPanelContent({
|
||||
return <GroupsConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'users') {
|
||||
return <UsersConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'security') {
|
||||
return <SecurityConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'network') {
|
||||
return <NetworkConfigTab />
|
||||
}
|
||||
|
||||
return <p className="config-empty">Coming soon</p>
|
||||
}
|
||||
|
||||
@@ -759,6 +1109,12 @@ function ConfigurationPanel({
|
||||
)
|
||||
}
|
||||
|
||||
type BootState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'setup'; clientIp: string }
|
||||
| { kind: 'login' }
|
||||
| { kind: 'ready'; username: string }
|
||||
|
||||
function App() {
|
||||
const initialLocation = parseLocation(window.location.pathname)
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(
|
||||
@@ -767,9 +1123,11 @@ function App() {
|
||||
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
|
||||
initialLocation.configTab,
|
||||
)
|
||||
// Stub until auth provides the logged-in display name.
|
||||
const [profileDisplayName] = useState<string | null>(null)
|
||||
const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [backendVersion, setBackendVersion] = useState('…')
|
||||
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
||||
const { themePreference, updateThemePreference } = useThemePreference()
|
||||
|
||||
const profileLabel = profileDisplayName ?? 'Profile'
|
||||
@@ -785,6 +1143,58 @@ function App() {
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const setupStatus = await fetchSetupStatus()
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
if (!setupStatus.completed) {
|
||||
setBootState({ kind: 'setup', clientIp: setupStatus.client_ip })
|
||||
navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
|
||||
const me = await fetchMe()
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
if (!me) {
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
return
|
||||
}
|
||||
|
||||
setProfileDisplayName(me.username)
|
||||
setBootState({ kind: 'ready', username: me.username })
|
||||
if (
|
||||
window.location.pathname === '/setup' ||
|
||||
window.location.pathname === '/login'
|
||||
) {
|
||||
navigateTo('/')
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bootState.kind !== 'ready') {
|
||||
return
|
||||
}
|
||||
|
||||
let isCancelled = false
|
||||
|
||||
async function loadBackendVersion() {
|
||||
try {
|
||||
const status = await fetchStatus()
|
||||
@@ -803,9 +1213,12 @@ function App() {
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [bootState.kind])
|
||||
|
||||
function goToSection(section: SectionId) {
|
||||
if (section === 'setup' || section === 'login') {
|
||||
return
|
||||
}
|
||||
const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
|
||||
setActiveSection(section)
|
||||
if (section !== 'configuration') {
|
||||
@@ -820,6 +1233,39 @@ function App() {
|
||||
navigateTo(pathFor('configuration', tab))
|
||||
}
|
||||
|
||||
if (bootState.kind === 'loading') {
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<p className="config-hint">Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bootState.kind === 'setup') {
|
||||
return (
|
||||
<SetupWizard
|
||||
clientIp={bootState.clientIp}
|
||||
onCompleted={() => {
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (bootState.kind === 'login') {
|
||||
return (
|
||||
<LoginPage
|
||||
onLoggedIn={(username) => {
|
||||
setProfileDisplayName(username)
|
||||
setBootState({ kind: 'ready', username })
|
||||
navigateTo('/')
|
||||
setActiveSection('overview')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar" aria-label="Main menu">
|
||||
@@ -830,7 +1276,23 @@ function App() {
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Sections">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
{NAV_ITEMS.slice(0, 1).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
activeSection === item.id
|
||||
? 'nav-item nav-item-active'
|
||||
: 'nav-item'
|
||||
}
|
||||
aria-current={activeSection === item.id ? 'page' : undefined}
|
||||
onClick={() => goToSection(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="nav-section-divider" aria-hidden="true" />
|
||||
{NAV_ITEMS.slice(1).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { login } from './api/client'
|
||||
|
||||
type LoginPageProps = {
|
||||
onLoggedIn: (username: string) => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('Admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setErrorMessage(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const me = await login(username.trim(), password, totpCode.trim())
|
||||
onLoggedIn(me.username)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Login failed')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<p className="wizard-brand">Cluster Canvas</p>
|
||||
<h1 className="wizard-title">Sign in</h1>
|
||||
<p className="config-hint">
|
||||
Use the administrator account created during setup.
|
||||
</p>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="wizard-error" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-username">Login name</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="config-input"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-totp">TOTP code (if enabled)</label>
|
||||
<input
|
||||
id="login-totp"
|
||||
className="config-input"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
beginSetupTOTP,
|
||||
completeSetup,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
previewNetworkAccess,
|
||||
type AccessMode,
|
||||
type NetworkSettings,
|
||||
type SecuritySettings,
|
||||
verifySetupTOTP,
|
||||
} from './api/client'
|
||||
import {
|
||||
validateHostname,
|
||||
validateListenAddress,
|
||||
validatePassword,
|
||||
} from './passwordPolicy'
|
||||
|
||||
type SetupWizardProps = {
|
||||
clientIp: string
|
||||
onCompleted: () => void
|
||||
}
|
||||
|
||||
type WizardStep = 0 | 1 | 2 | 3
|
||||
|
||||
export function SetupWizard({ clientIp, onCompleted }: SetupWizardProps) {
|
||||
const [step, setStep] = useState<WizardStep>(0)
|
||||
const [username, setUsername] = useState('Admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [setupTotp, setSetupTotp] = useState(true)
|
||||
const [totpSecret, setTotpSecret] = useState('')
|
||||
const [totpUrl, setTotpUrl] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [totpConfirmed, setTotpConfirmed] = useState(false)
|
||||
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||
const [network, setNetwork] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
|
||||
const [rulesText, setRulesText] = useState('')
|
||||
const [lockoutWarning, setLockoutWarning] = useState<string | null>(null)
|
||||
const [security, setSecurity] = useState<SecuritySettings>(
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpUrl) {
|
||||
setQrDataUrl('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void import('qrcode').then(async (qrcode) => {
|
||||
const dataUrl = await qrcode.toDataURL(totpUrl, { width: 220, margin: 1 })
|
||||
if (!cancelled) {
|
||||
setQrDataUrl(dataUrl)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [totpUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 2) {
|
||||
return
|
||||
}
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const draft: NetworkSettings = { ...network, rules }
|
||||
let cancelled = false
|
||||
void previewNetworkAccess(draft)
|
||||
.then((preview) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (preview.would_lock_out) {
|
||||
setLockoutWarning(
|
||||
preview.reason ||
|
||||
`These rules may lock out your current IP (${preview.client_ip}).`,
|
||||
)
|
||||
} else {
|
||||
setLockoutWarning(null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setLockoutWarning(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, network, rulesText])
|
||||
|
||||
async function handleAdminNext() {
|
||||
setErrorMessage(null)
|
||||
const passwordError = validatePassword(password, username)
|
||||
if (passwordError) {
|
||||
setErrorMessage(passwordError)
|
||||
return
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setErrorMessage('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (!setupTotp) {
|
||||
setTotpSecret('')
|
||||
setTotpConfirmed(false)
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
if (!totpConfirmed) {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
if (!totpSecret) {
|
||||
const began = await beginSetupTOTP(username.trim() || 'Admin')
|
||||
setTotpSecret(began.secret)
|
||||
setTotpUrl(began.otpauth_url)
|
||||
return
|
||||
}
|
||||
if (!totpCode.trim()) {
|
||||
setErrorMessage('Enter the TOTP code from your authenticator')
|
||||
return
|
||||
}
|
||||
await verifySetupTOTP(totpSecret, totpCode.trim())
|
||||
setTotpConfirmed(true)
|
||||
setSecurity((previous) => ({ ...previous, totp_enabled: true }))
|
||||
setStep(2)
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'TOTP setup failed',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
function handleNetworkNext() {
|
||||
setErrorMessage(null)
|
||||
const listenError = validateListenAddress(network.listen_address)
|
||||
if (listenError) {
|
||||
setErrorMessage(listenError)
|
||||
return
|
||||
}
|
||||
const hostnameError = validateHostname(network.public_hostname)
|
||||
if (hostnameError) {
|
||||
setErrorMessage(hostnameError)
|
||||
return
|
||||
}
|
||||
if (lockoutWarning) {
|
||||
setErrorMessage(
|
||||
`${lockoutWarning} Adjust the rules before continuing.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
setNetwork((previous) => ({ ...previous, rules }))
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
async function handleFinish() {
|
||||
setErrorMessage(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const finalSecurity = totpConfirmed
|
||||
? { ...security, totp_enabled: true }
|
||||
: security
|
||||
await completeSetup({
|
||||
username: username.trim() || 'Admin',
|
||||
password,
|
||||
totp_secret: totpConfirmed ? totpSecret : '',
|
||||
totp_confirmed: totpConfirmed,
|
||||
network: { ...network, rules },
|
||||
security: finalSecurity,
|
||||
})
|
||||
onCompleted()
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Setup failed',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<div className="wizard-card">
|
||||
<p className="wizard-brand">Cluster Canvas</p>
|
||||
<h1 className="wizard-title">Setup wizard</h1>
|
||||
<p className="wizard-step-indicator">Step {step + 1} of 4</p>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="wizard-error" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 0 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Welcome</h2>
|
||||
<p>
|
||||
Before you can use Cluster Canvas, a few settings need to be
|
||||
filled in: an administrator account, network access rules, and
|
||||
server security policy.
|
||||
</p>
|
||||
<p>
|
||||
This wizard walks through those steps once. After it finishes you
|
||||
will sign in with the admin account you create.
|
||||
</p>
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Administrator account</h2>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-username">Login name</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
className="config-input"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-password">Password</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
At least 14 characters, or a passphrase of at least 5 words.
|
||||
Maximum 256 characters.
|
||||
</p>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-password-confirm">Confirm password</label>
|
||||
<input
|
||||
id="setup-password-confirm"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label className="permission-option" htmlFor="setup-totp">
|
||||
<input
|
||||
id="setup-totp"
|
||||
type="checkbox"
|
||||
checked={setupTotp}
|
||||
onChange={(event) => {
|
||||
setSetupTotp(event.target.checked)
|
||||
setTotpConfirmed(false)
|
||||
setTotpSecret('')
|
||||
setTotpUrl('')
|
||||
setTotpCode('')
|
||||
}}
|
||||
/>
|
||||
Set up TOTP now (recommended)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{setupTotp && totpSecret ? (
|
||||
<div className="totp-setup">
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
className="totp-qr"
|
||||
src={qrDataUrl}
|
||||
alt="TOTP QR code"
|
||||
width={220}
|
||||
height={220}
|
||||
/>
|
||||
) : (
|
||||
<p className="config-hint">Generating QR code…</p>
|
||||
)}
|
||||
<p className="config-hint">
|
||||
Or copy this secret into your authenticator:
|
||||
</p>
|
||||
<code className="totp-secret">{totpSecret}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => void navigator.clipboard.writeText(totpUrl || totpSecret)}
|
||||
>
|
||||
Copy setup URI
|
||||
</button>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-totp-code">Verification code</label>
|
||||
<input
|
||||
id="setup-totp-code"
|
||||
className="config-input"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => setStep(0)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleAdminNext()}
|
||||
>
|
||||
{setupTotp && !totpSecret
|
||||
? 'Continue to TOTP'
|
||||
: setupTotp && !totpConfirmed
|
||||
? 'Verify TOTP'
|
||||
: 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Network setup</h2>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-listen">Listen address</label>
|
||||
<input
|
||||
id="setup-listen"
|
||||
className="config-input"
|
||||
value={network.listen_address}
|
||||
onChange={(event) =>
|
||||
setNetwork((previous) => ({
|
||||
...previous,
|
||||
listen_address: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Default 0.0.0.0 (all interfaces). Applied on next process
|
||||
restart.
|
||||
</p>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-hostname">Public hostname</label>
|
||||
<input
|
||||
id="setup-hostname"
|
||||
className="config-input"
|
||||
placeholder="admin.mydomain.ext"
|
||||
value={network.public_hostname}
|
||||
onChange={(event) =>
|
||||
setNetwork((previous) => ({
|
||||
...previous,
|
||||
public_hostname: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Recommended for production behind a reverse proxy (HTTPS). Leave
|
||||
empty for local/remotedev access by IP address — filling this in
|
||||
requires visiting the UI via that exact hostname.
|
||||
</p>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-access-mode">Access mode</label>
|
||||
<select
|
||||
id="setup-access-mode"
|
||||
className="config-input"
|
||||
value={network.access_mode}
|
||||
onChange={(event) =>
|
||||
setNetwork((previous) => ({
|
||||
...previous,
|
||||
access_mode: event.target.value as AccessMode,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="open">Open (no IP filter)</option>
|
||||
<option value="whitelist">Whitelist (deny non-matching)</option>
|
||||
<option value="blacklist">Blacklist (deny matching)</option>
|
||||
</select>
|
||||
</div>
|
||||
{network.access_mode !== 'open' ? (
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-rules">
|
||||
IP / CIDR rules (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
id="setup-rules"
|
||||
className="config-input wizard-rules"
|
||||
rows={5}
|
||||
value={rulesText}
|
||||
onChange={(event) => setRulesText(event.target.value)}
|
||||
/>
|
||||
<p className="config-hint">Your current IP: {clientIp || 'unknown'}</p>
|
||||
{lockoutWarning ? (
|
||||
<p className="wizard-error" role="status">
|
||||
{lockoutWarning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
onClick={handleNetworkNext}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Server settings</h2>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-idle">Idle timeout (minutes)</label>
|
||||
<input
|
||||
id="setup-idle"
|
||||
className="config-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={240}
|
||||
value={security.idle_timeout_minutes}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
idle_timeout_minutes: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-lifetime">Session lifetime (hours)</label>
|
||||
<input
|
||||
id="setup-lifetime"
|
||||
className="config-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={security.session_lifetime_hours}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
session_lifetime_hours: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label className="permission-option" htmlFor="setup-totp-server">
|
||||
<input
|
||||
id="setup-totp-server"
|
||||
type="checkbox"
|
||||
checked={security.totp_enabled}
|
||||
disabled={totpConfirmed}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
totp_enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Enable TOTP
|
||||
</label>
|
||||
{totpConfirmed ? (
|
||||
<p className="config-hint">
|
||||
Locked on because the admin account enrolled TOTP.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label className="permission-option" htmlFor="setup-reauth">
|
||||
<input
|
||||
id="setup-reauth"
|
||||
type="checkbox"
|
||||
checked={security.reauth_sensitive_actions}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
reauth_sensitive_actions: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Re-auth for sensitive actions
|
||||
</label>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-reauth-grace">
|
||||
Re-auth grace period (minutes)
|
||||
</label>
|
||||
<input
|
||||
id="setup-reauth-grace"
|
||||
className="config-input"
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={!security.reauth_sensitive_actions}
|
||||
value={security.reauth_grace_minutes}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
reauth_grace_minutes: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleFinish()}
|
||||
>
|
||||
Finish setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+179
-18
@@ -1,11 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteUser,
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchNetwork,
|
||||
fetchSecurity,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
getApiBaseUrl,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} from './client'
|
||||
@@ -14,6 +18,15 @@ describe('getApiBaseUrl', () => {
|
||||
it('defaults to localhost:8080 when unset', () => {
|
||||
expect(getApiBaseUrl()).toBe('http://localhost:8080')
|
||||
})
|
||||
|
||||
it('keeps an empty string for same-origin requests', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE_URL', '')
|
||||
const clientModule = await import('./client')
|
||||
expect(clientModule.getApiBaseUrl()).toBe('')
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchHealth', () => {
|
||||
@@ -29,7 +42,10 @@ describe('fetchHealth', () => {
|
||||
|
||||
const health = await fetchHealth(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/health')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/health',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(health).toEqual({ status: 'ok' })
|
||||
})
|
||||
|
||||
@@ -61,6 +77,7 @@ describe('fetchStatus', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/status',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' })
|
||||
})
|
||||
@@ -100,7 +117,10 @@ describe('fetchGroups', () => {
|
||||
|
||||
const payload = await fetchGroups(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.groups).toEqual([
|
||||
{
|
||||
name: 'Admins',
|
||||
@@ -145,18 +165,21 @@ describe('upsertGroup', () => {
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
group: {
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
group: {
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
@@ -195,7 +218,7 @@ describe('deleteGroup', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups?name=Admins',
|
||||
{ method: 'DELETE' },
|
||||
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -207,7 +230,87 @@ describe('deleteGroup', () => {
|
||||
})
|
||||
|
||||
await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow(
|
||||
'groups delete failed: 404',
|
||||
'request failed: 404',
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces API error messages', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
error: 'Administrators group cannot be deleted',
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(deleteGroup('Administrators', fetchMock)).rejects.toThrow(
|
||||
'Administrators group cannot be deleted',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchUsers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed users payload', async () => {
|
||||
const users = [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'Admin',
|
||||
enabled: true,
|
||||
totp_confirmed: false,
|
||||
group_names: ['Administrators'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
password_changed_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: true,
|
||||
can_delete: false,
|
||||
},
|
||||
]
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ users }),
|
||||
})
|
||||
|
||||
const payload = await fetchUsers(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/users',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.users).toEqual(users)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUser', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends DELETE by id and returns users', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ users: [] }),
|
||||
})
|
||||
|
||||
await deleteUser('u2', fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/users?id=u2',
|
||||
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces last-admin errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ error: 'cannot delete the last administrator' }),
|
||||
})
|
||||
|
||||
await expect(deleteUser('u1', fetchMock)).rejects.toThrow(
|
||||
'cannot delete the last administrator',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -234,6 +337,7 @@ describe('fetchSecurity', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/security',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.security).toEqual(security)
|
||||
})
|
||||
@@ -273,11 +377,11 @@ describe('saveSecurity', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/security',
|
||||
{
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ security }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -302,3 +406,60 @@ describe('saveSecurity', () => {
|
||||
).rejects.toThrow('security save failed: 400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNetwork', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed network payload', async () => {
|
||||
const network = {
|
||||
listen_address: '0.0.0.0',
|
||||
public_hostname: '',
|
||||
access_mode: 'open' as const,
|
||||
rules: [],
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ network }),
|
||||
})
|
||||
|
||||
const payload = await fetchNetwork(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/network',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.network).toEqual(network)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveNetwork', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends JSON body and returns parsed network', async () => {
|
||||
const network = {
|
||||
listen_address: '127.0.0.1',
|
||||
public_hostname: 'admin.example.com',
|
||||
access_mode: 'whitelist' as const,
|
||||
rules: ['10.0.0.0/8'],
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ network }),
|
||||
})
|
||||
|
||||
await saveNetwork(network, fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/network',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ network }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+309
-18
@@ -20,6 +20,22 @@ export type GroupsResponse = {
|
||||
groups: ReadonlyArray<Group>
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
enabled: boolean
|
||||
totp_confirmed: boolean
|
||||
group_names: ReadonlyArray<string>
|
||||
created_at: string
|
||||
password_changed_at: string
|
||||
is_admin: boolean
|
||||
can_delete: boolean
|
||||
}
|
||||
|
||||
export type UsersResponse = {
|
||||
users: ReadonlyArray<User>
|
||||
}
|
||||
|
||||
export type SecuritySettings = {
|
||||
idle_timeout_minutes: number
|
||||
session_lifetime_hours: number
|
||||
@@ -32,6 +48,50 @@ export type SecurityResponse = {
|
||||
security: SecuritySettings
|
||||
}
|
||||
|
||||
export type NetworkResponse = {
|
||||
network: NetworkSettings
|
||||
}
|
||||
|
||||
export type AccessMode = 'open' | 'whitelist' | 'blacklist'
|
||||
|
||||
export type NetworkSettings = {
|
||||
listen_address: string
|
||||
public_hostname: string
|
||||
access_mode: AccessMode
|
||||
rules: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type SetupStatusResponse = {
|
||||
completed: boolean
|
||||
client_ip: string
|
||||
}
|
||||
|
||||
export type SetupTOTPBeginResponse = {
|
||||
secret: string
|
||||
otpauth_url: string
|
||||
}
|
||||
|
||||
export type SetupCompleteRequest = {
|
||||
username: string
|
||||
password: string
|
||||
totp_secret: string
|
||||
totp_confirmed: boolean
|
||||
network: NetworkSettings
|
||||
security: SecuritySettings
|
||||
}
|
||||
|
||||
export type MeResponse = {
|
||||
user_id: string
|
||||
username: string
|
||||
groups: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type NetworkPreviewResponse = {
|
||||
would_lock_out: boolean
|
||||
reason?: string
|
||||
client_ip: string
|
||||
}
|
||||
|
||||
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
||||
idle_timeout_minutes: 30,
|
||||
session_lifetime_hours: 24,
|
||||
@@ -40,14 +100,54 @@ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
||||
totp_enabled: false,
|
||||
}
|
||||
|
||||
export const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
|
||||
listen_address: '0.0.0.0',
|
||||
public_hostname: '',
|
||||
access_mode: 'open',
|
||||
rules: [],
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||
// Empty string means same-origin (used by remotedev / reverse-proxied builds).
|
||||
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
if (configuredBaseUrl === undefined) {
|
||||
return 'http://localhost:8080'
|
||||
}
|
||||
return configuredBaseUrl
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string }
|
||||
if (payload.error) {
|
||||
return payload.error
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `request failed: ${response.status}`
|
||||
}
|
||||
|
||||
async function apiFetch(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
return fetchImpl(`${getApiBaseUrl()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchHealth(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<HealthResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/health`)
|
||||
const response = await apiFetch('/health', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`health check failed: ${response.status}`)
|
||||
}
|
||||
@@ -57,7 +157,7 @@ export async function fetchHealth(
|
||||
export async function fetchStatus(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<StatusResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/status`)
|
||||
const response = await apiFetch('/api/v1/status', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`status check failed: ${response.status}`)
|
||||
}
|
||||
@@ -67,7 +167,7 @@ export async function fetchStatus(
|
||||
export async function fetchGroups(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`)
|
||||
const response = await apiFetch('/api/v1/groups', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups fetch failed: ${response.status}`)
|
||||
}
|
||||
@@ -78,11 +178,14 @@ export async function upsertGroup(
|
||||
group: Group,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group }),
|
||||
})
|
||||
const response = await apiFetch(
|
||||
'/api/v1/groups',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ group }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups upsert failed: ${response.status}`)
|
||||
@@ -95,22 +198,48 @@ export async function deleteGroup(
|
||||
name: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await fetchImpl(
|
||||
`${getApiBaseUrl()}/api/v1/groups?name=${encodeURIComponent(name)}`,
|
||||
const response = await apiFetch(
|
||||
`/api/v1/groups?name=${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups delete failed: ${response.status}`)
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
|
||||
return (await response.json()) as GroupsResponse
|
||||
}
|
||||
|
||||
export async function fetchUsers(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch('/api/v1/users', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/users?id=${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function fetchSecurity(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`)
|
||||
const response = await apiFetch('/api/v1/security', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`security fetch failed: ${response.status}`)
|
||||
}
|
||||
@@ -121,11 +250,14 @@ export async function saveSecurity(
|
||||
security: SecuritySettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ security }),
|
||||
})
|
||||
const response = await apiFetch(
|
||||
'/api/v1/security',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ security }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`security save failed: ${response.status}`)
|
||||
@@ -133,3 +265,162 @@ export async function saveSecurity(
|
||||
|
||||
return (await response.json()) as SecurityResponse
|
||||
}
|
||||
|
||||
export async function fetchNetwork(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NetworkResponse> {
|
||||
const response = await apiFetch('/api/v1/network', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NetworkResponse
|
||||
}
|
||||
|
||||
export async function saveNetwork(
|
||||
network: NetworkSettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NetworkResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/network',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ network }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NetworkResponse
|
||||
}
|
||||
|
||||
export async function fetchSetupStatus(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SetupStatusResponse> {
|
||||
const response = await apiFetch('/api/v1/setup/status', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as SetupStatusResponse
|
||||
}
|
||||
|
||||
export async function beginSetupTOTP(
|
||||
username: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SetupTOTPBeginResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/totp/begin',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as SetupTOTPBeginResponse
|
||||
}
|
||||
|
||||
export async function verifySetupTOTP(
|
||||
secret: string,
|
||||
code: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/totp/verify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ secret, code }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewNetworkAccess(
|
||||
network: NetworkSettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NetworkPreviewResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/network/preview',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ network }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NetworkPreviewResponse
|
||||
}
|
||||
|
||||
export async function completeSetup(
|
||||
payload: SetupCompleteRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/complete',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
totpCode = '',
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<MeResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
totp_code: totpCode,
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as MeResponse
|
||||
}
|
||||
|
||||
export async function logout(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/auth/logout',
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMe(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<MeResponse | null> {
|
||||
const response = await apiFetch('/api/v1/auth/me', {}, fetchImpl)
|
||||
if (response.status === 401) {
|
||||
return null
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as MeResponse
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ describe('pathFor', () => {
|
||||
it('maps sections and config tabs to paths', () => {
|
||||
expect(pathFor('overview')).toBe('/')
|
||||
expect(pathFor('vms')).toBe('/vms')
|
||||
expect(pathFor('docker')).toBe('/docker')
|
||||
expect(pathFor('containers')).toBe('/containers')
|
||||
expect(pathFor('profile')).toBe('/profile')
|
||||
expect(pathFor('setup')).toBe('/setup')
|
||||
expect(pathFor('login')).toBe('/login')
|
||||
expect(pathFor('configuration')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||
@@ -19,8 +22,11 @@ describe('parseLocation', () => {
|
||||
for (const path of [
|
||||
'/',
|
||||
'/vms',
|
||||
'/docker',
|
||||
'/containers',
|
||||
'/profile',
|
||||
'/setup',
|
||||
'/login',
|
||||
'/configuration',
|
||||
'/configuration/users',
|
||||
'/configuration/roles',
|
||||
@@ -48,6 +54,10 @@ describe('parseLocation', () => {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/docker/extra')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats trailing slashes as the same path', () => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export type SectionId =
|
||||
| 'overview'
|
||||
| 'vms'
|
||||
| 'docker'
|
||||
| 'containers'
|
||||
| 'profile'
|
||||
| 'configuration'
|
||||
| 'setup'
|
||||
| 'login'
|
||||
|
||||
export type ConfigTabId =
|
||||
| 'overview'
|
||||
@@ -51,10 +54,22 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
|
||||
const [first, second] = segments
|
||||
|
||||
if (first === 'setup' && segments.length === 1) {
|
||||
return { section: 'setup', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'login' && segments.length === 1) {
|
||||
return { section: 'login', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'vms' && segments.length === 1) {
|
||||
return { section: 'vms', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'docker' && segments.length === 1) {
|
||||
return { section: 'docker', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'containers' && segments.length === 1) {
|
||||
return { section: 'containers', configTab: 'overview' }
|
||||
}
|
||||
@@ -83,9 +98,18 @@ export function pathFor(
|
||||
if (section === 'overview') {
|
||||
return '/'
|
||||
}
|
||||
if (section === 'setup') {
|
||||
return '/setup'
|
||||
}
|
||||
if (section === 'login') {
|
||||
return '/login'
|
||||
}
|
||||
if (section === 'vms') {
|
||||
return '/vms'
|
||||
}
|
||||
if (section === 'docker') {
|
||||
return '/docker'
|
||||
}
|
||||
if (section === 'containers') {
|
||||
return '/containers'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateHostname, validatePassword } from './passwordPolicy'
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('accepts long passwords and passphrases', () => {
|
||||
expect(validatePassword('Tr0ub4dor&3-extra!', 'Admin')).toBeNull()
|
||||
expect(
|
||||
validatePassword('apple banana cherry date elderberry', 'Admin'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects weak values', () => {
|
||||
expect(validatePassword('short', 'Admin')).not.toBeNull()
|
||||
expect(validatePassword('Admin', 'Admin')).not.toBeNull()
|
||||
expect(validatePassword('password', 'Admin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHostname', () => {
|
||||
it('accepts DNS names and empty', () => {
|
||||
expect(validateHostname('')).toBeNull()
|
||||
expect(validateHostname('admin.mydomain.ext')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects schemes and paths', () => {
|
||||
expect(validateHostname('https://admin.example.com')).not.toBeNull()
|
||||
expect(validateHostname('admin.example.com/path')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password',
|
||||
'password123',
|
||||
'password1234',
|
||||
'12345678901234',
|
||||
'qwertyuiopasdf',
|
||||
'admin',
|
||||
'adminadmin',
|
||||
'letmein',
|
||||
'welcome',
|
||||
'welcome123',
|
||||
'changeme',
|
||||
'changeme123',
|
||||
'cluster canvas',
|
||||
'clustercanvas',
|
||||
'correct horse battery staple',
|
||||
])
|
||||
|
||||
const HOSTNAME_PATTERN =
|
||||
/^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}|localhost)$/i
|
||||
|
||||
export function validatePassword(
|
||||
password: string,
|
||||
username: string,
|
||||
): string | null {
|
||||
if (password.length > 256) {
|
||||
return 'Password must be at most 256 characters'
|
||||
}
|
||||
for (const character of password) {
|
||||
const code = character.charCodeAt(0)
|
||||
if (code < 32 || code === 127) {
|
||||
return 'Password may only contain printable characters'
|
||||
}
|
||||
}
|
||||
if (password.trim().toLowerCase() === username.trim().toLowerCase()) {
|
||||
return 'Password cannot match the login name'
|
||||
}
|
||||
if (COMMON_PASSWORDS.has(password.trim().toLowerCase())) {
|
||||
return 'Password is too common'
|
||||
}
|
||||
const wordCount = password.trim().split(/\s+/).filter(Boolean).length
|
||||
if (password.length >= 14 || wordCount >= 5) {
|
||||
return null
|
||||
}
|
||||
return 'Password must be at least 14 characters or at least 5 words'
|
||||
}
|
||||
|
||||
export function validateHostname(hostname: string): string | null {
|
||||
const trimmed = hostname.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
if (trimmed.includes('/') || trimmed.includes(':') || trimmed.includes(' ')) {
|
||||
return 'Hostname must be a bare DNS name without scheme, port, or path'
|
||||
}
|
||||
if (!HOSTNAME_PATTERN.test(trimmed)) {
|
||||
return 'Hostname is not a valid DNS name'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateListenAddress(address: string): string | null {
|
||||
const trimmed = address.trim()
|
||||
if (!trimmed) {
|
||||
return 'Listen address is required'
|
||||
}
|
||||
if (
|
||||
trimmed === '0.0.0.0' ||
|
||||
trimmed === '::' ||
|
||||
trimmed === 'localhost' ||
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return 'Listen address must be an IP address or 0.0.0.0'
|
||||
}
|
||||
Reference in New Issue
Block a user