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:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+281 -4
View File
@@ -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"
}
}
}
}
+2
View File
@@ -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",
+131
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+89
View File
@@ -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>
)
}
+567
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+10
View File
@@ -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', () => {
+24
View File
@@ -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'
}
+29
View File
@@ -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()
})
})
+76
View File
@@ -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'
}