feat: 2FA/TOTP, config templates (v0.11.4 Beta)
- 2FA: TOTP setup wizard in Settings, QR code enrollment, login second-step, password-confirmed disable - Config templates: reusable DNS/NTP/syslog config sets pushed to selected devices via RouterOS API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
49ab1b002e
commit
a1ca4d0a44
@@ -2,7 +2,7 @@
|
||||
|
||||
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Generated
+216
-15
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.10.6-beta",
|
||||
"version": "0.11.3-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.10.6-beta",
|
||||
"version": "0.11.3-beta",
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -21,7 +21,9 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otpauth": "^9.5.1",
|
||||
"pg": "^8.11.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io": "^4.6.1",
|
||||
"ssh2": "^1.14.0",
|
||||
"zod": "^3.22.4"
|
||||
@@ -38,6 +40,7 @@
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ssh2": "^1.11.17",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
@@ -1905,6 +1908,16 @@
|
||||
"pg-types": "^2.2.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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
@@ -2461,7 +2474,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2471,7 +2483,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2940,7 +2951,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -3124,7 +3134,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3137,7 +3146,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -3346,6 +3354,15 @@
|
||||
"ms": "2.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==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -3467,6 +3484,12 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
@@ -3532,7 +3555,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -4280,7 +4302,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -4762,7 +4783,6 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6280,6 +6300,30 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otpauth": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
||||
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/hectorm/otpauth?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/otpauth/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -6316,7 +6360,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -6367,7 +6410,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6594,6 +6636,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -6732,6 +6783,141 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/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==",
|
||||
"license": "MIT",
|
||||
"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/qrcode/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==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -6826,12 +7012,17 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -6990,6 +7181,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -7348,7 +7545,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7363,7 +7559,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -8037,6 +8232,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.11.3-beta",
|
||||
"version": "0.11.4-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -26,7 +26,9 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otpauth": "^9.5.1",
|
||||
"pg": "^8.11.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io": "^4.6.1",
|
||||
"ssh2": "^1.14.0",
|
||||
"zod": "^3.22.4"
|
||||
@@ -43,6 +45,7 @@
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ssh2": "^1.11.17",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
|
||||
@@ -347,6 +347,21 @@ CREATE TABLE IF NOT EXISTS device_availability (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_availability_device ON device_availability(device_id, went_offline_at DESC);
|
||||
|
||||
-- Configuration templates (reusable config sets pushed to devices or groups)
|
||||
CREATE TABLE IF NOT EXISTS config_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
applies_to_type VARCHAR(20),
|
||||
template_json JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- TOTP two-factor authentication
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Wireless security profiles (WPA/WPA2/WPA3 config)
|
||||
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -43,6 +43,7 @@ import searchRoutes from './routes/search';
|
||||
import switchesRoutes from './routes/switches';
|
||||
import routersRoutes from './routes/routers';
|
||||
import alertsRoutes from './routes/alerts';
|
||||
import configTemplatesRoutes from './routes/configTemplates';
|
||||
import wirelessRoutes from './routes/wireless';
|
||||
import networkServicesRoutes from './routes/networkServices';
|
||||
import credentialPresetsRoutes from './routes/credentialPresets';
|
||||
@@ -215,6 +216,7 @@ app.use('/api/credential-presets', credentialPresetsRoutes);
|
||||
app.use('/api/audit-log', auditLogRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
|
||||
app.use('/api/config-templates', configTemplatesRoutes);
|
||||
|
||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
+108
-1
@@ -1,9 +1,14 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as qrcode from 'qrcode';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { signToken, requireAuth } from '../middleware/auth';
|
||||
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEFAULT_JWT_SECRET = 'changeme';
|
||||
@@ -47,16 +52,118 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
}>(`SELECT id, username, password_hash, role FROM users WHERE username = $1`, [username]);
|
||||
totp_enabled: boolean;
|
||||
}>(`SELECT id, username, password_hash, role, totp_enabled FROM users WHERE username = $1`, [username]);
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
if (user.totp_enabled) {
|
||||
const totpToken = jwt.sign({ userId: user.id, partial: true }, JWT_SECRET, { expiresIn: '5m' });
|
||||
return res.json({ requires_totp: true, totp_token: totpToken });
|
||||
}
|
||||
|
||||
const token = signToken({ userId: user.id, username: user.username, role: user.role });
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// Exchange partial TOTP token + code for a full session token
|
||||
router.post('/totp/verify', async (req: Request, res: Response) => {
|
||||
const { totp_token, code } = req.body as { totp_token?: string; code?: string };
|
||||
if (!totp_token || !code) {
|
||||
return res.status(400).json({ error: 'totp_token and code are required' });
|
||||
}
|
||||
let payload: { userId: number; partial: boolean };
|
||||
try {
|
||||
payload = jwt.verify(totp_token, JWT_SECRET) as typeof payload;
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired TOTP token' });
|
||||
}
|
||||
if (!payload.partial) {
|
||||
return res.status(401).json({ error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ id: number; username: string; role: string; totp_secret: string | null; totp_enabled: boolean }>(
|
||||
`SELECT id, username, role, totp_secret, totp_enabled FROM users WHERE id = $1`,
|
||||
[payload.userId]
|
||||
);
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'TOTP not configured' });
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(user.totp_secret), digits: 6, period: 30 });
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) {
|
||||
return res.status(401).json({ error: 'Invalid TOTP code' });
|
||||
}
|
||||
|
||||
const token = signToken({ userId: user.id, username: user.username, role: user.role });
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// Generate a new TOTP secret + QR code for the current user (does not enable yet)
|
||||
router.post('/totp/setup', requireAuth, async (req: Request, res: Response) => {
|
||||
const secret = new OTPAuth.Secret({ size: 20 });
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'MikroTik Manager',
|
||||
label: req.user!.username,
|
||||
secret,
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
const uri = totp.toString();
|
||||
const qrDataUrl = await qrcode.toDataURL(uri);
|
||||
// Store the pending secret temporarily on the user row (not yet enabled)
|
||||
await query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret.base32, req.user!.userId]);
|
||||
return res.json({ secret: secret.base32, uri, qr: qrDataUrl });
|
||||
});
|
||||
|
||||
// Confirm the TOTP setup by validating a code — enables TOTP
|
||||
router.post('/totp/confirm', requireAuth, async (req: Request, res: Response) => {
|
||||
const { code } = req.body as { code?: string };
|
||||
if (!code) return res.status(400).json({ error: 'code is required' });
|
||||
|
||||
const user = await queryOne<{ totp_secret: string | null }>(
|
||||
`SELECT totp_secret FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
if (!user?.totp_secret) return res.status(400).json({ error: 'Run /totp/setup first' });
|
||||
|
||||
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(user.totp_secret), digits: 6, period: 30 });
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) return res.status(401).json({ error: 'Invalid code' });
|
||||
|
||||
await query(`UPDATE users SET totp_enabled = true WHERE id = $1`, [req.user!.userId]);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Disable TOTP — requires password confirmation
|
||||
router.post('/totp/disable', requireAuth, async (req: Request, res: Response) => {
|
||||
const { password } = req.body as { password?: string };
|
||||
if (!password) return res.status(400).json({ error: 'password is required' });
|
||||
|
||||
const user = await queryOne<{ password_hash: string }>(
|
||||
`SELECT password_hash FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
}
|
||||
|
||||
await query(`UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = $1`, [req.user!.userId]);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Return current TOTP status for the authenticated user
|
||||
router.get('/totp/status', requireAuth, async (req: Request, res: Response) => {
|
||||
const user = await queryOne<{ totp_enabled: boolean }>(
|
||||
`SELECT totp_enabled FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
return res.json({ totp_enabled: user?.totp_enabled ?? false });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req: Request, res: Response) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
|
||||
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
interface ConfigTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
applies_to_type: string | null;
|
||||
template_json: {
|
||||
dns_servers?: string[];
|
||||
ntp_servers?: string[];
|
||||
syslog_host?: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// GET /api/config-templates
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const rows = await query<ConfigTemplate>(`SELECT * FROM config_templates ORDER BY name`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// GET /api/config-templates/:id
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const row = await queryOne<ConfigTemplate>(`SELECT * FROM config_templates WHERE id = $1`, [req.params.id]);
|
||||
if (!row) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// POST /api/config-templates (admin)
|
||||
router.post('/', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { name, description, applies_to_type, template_json } = req.body as Partial<ConfigTemplate>;
|
||||
if (!name?.trim()) { res.status(400).json({ error: 'name is required' }); return; }
|
||||
const rows = await query<ConfigTemplate>(
|
||||
`INSERT INTO config_templates (name, description, applies_to_type, template_json)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name.trim(), description ?? null, applies_to_type ?? null, JSON.stringify(template_json ?? {})]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
});
|
||||
|
||||
// PUT /api/config-templates/:id (admin)
|
||||
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { name, description, applies_to_type, template_json } = req.body as Partial<ConfigTemplate>;
|
||||
const rows = await query<ConfigTemplate>(
|
||||
`UPDATE config_templates SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
applies_to_type = COALESCE($3, applies_to_type),
|
||||
template_json = COALESCE($4, template_json),
|
||||
updated_at = NOW()
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[name, description, applies_to_type, template_json ? JSON.stringify(template_json) : null, req.params.id]
|
||||
);
|
||||
if (!rows[0]) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// DELETE /api/config-templates/:id (admin)
|
||||
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
await query(`DELETE FROM config_templates WHERE id = $1`, [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/config-templates/:id/apply — push template to one or more devices (operator+)
|
||||
router.post('/:id/apply', requireWrite, async (req: Request, res: Response) => {
|
||||
const template = await queryOne<ConfigTemplate>(
|
||||
`SELECT * FROM config_templates WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!template) { res.status(404).json({ error: 'Template not found' }); return; }
|
||||
|
||||
const { device_ids } = req.body as { device_ids?: number[] };
|
||||
if (!Array.isArray(device_ids) || device_ids.length === 0) {
|
||||
res.status(400).json({ error: 'device_ids array required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceRows = await query<{
|
||||
id: number; name: string; ip_address: string; api_port: number;
|
||||
api_username: string; api_password_encrypted: string;
|
||||
}>(
|
||||
`SELECT id, name, ip_address, api_port, api_username, api_password_encrypted
|
||||
FROM devices WHERE id = ANY($1::int[])`,
|
||||
[device_ids]
|
||||
);
|
||||
|
||||
const tpl = template.template_json;
|
||||
const results: { device_id: number; device_name: string; ok: boolean; error?: string }[] = [];
|
||||
|
||||
await Promise.allSettled(
|
||||
deviceRows.map(async (dev) => {
|
||||
const client = new RouterOSClient(
|
||||
dev.ip_address,
|
||||
dev.api_port,
|
||||
dev.api_username,
|
||||
decrypt(dev.api_password_encrypted),
|
||||
15_000
|
||||
);
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
if (tpl.dns_servers && tpl.dns_servers.length > 0) {
|
||||
await client.execute('/ip/dns/set', { servers: tpl.dns_servers.join(',') });
|
||||
}
|
||||
|
||||
if (tpl.ntp_servers && tpl.ntp_servers.length > 0) {
|
||||
await client.execute('/system/ntp/client/set', {
|
||||
enabled: 'yes',
|
||||
servers: tpl.ntp_servers.join(','),
|
||||
}).catch(() =>
|
||||
// RouterOS 6 uses different NTP path
|
||||
client.execute('/system/ntp/client/set', {
|
||||
enabled: 'yes',
|
||||
'primary-ntp': tpl.ntp_servers![0] ?? '',
|
||||
'secondary-ntp': tpl.ntp_servers![1] ?? '',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (tpl.syslog_host) {
|
||||
// Find existing remote logging action and update it
|
||||
const actions = await client.execute('/system/logging/action/print');
|
||||
const remoteAction = (actions as Record<string, string>[]).find((a) => a.target === 'remote');
|
||||
if (remoteAction) {
|
||||
await client.execute('/system/logging/action/set', {
|
||||
'.id': remoteAction['.id'],
|
||||
'remote': tpl.syslog_host,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ device_id: dev.id, device_name: dev.name, ok: true });
|
||||
} catch (err) {
|
||||
results.push({ device_id: dev.id, device_name: dev.name, ok: false, error: (err as Error).message });
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.11.3-beta",
|
||||
"version": "0.11.4-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, FormEvent, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Network, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { Network, Eye, EyeOff, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import { authApi } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -12,6 +12,9 @@ export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totpToken, setTotpToken] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const navigate = useNavigate();
|
||||
@@ -19,14 +22,40 @@ export default function LoginPage() {
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
|
||||
if (totpToken) {
|
||||
// Step 2: verify TOTP code
|
||||
if (totpCode.replace(/\s/g, '').length !== 6) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await authApi.totpVerify(totpToken, totpCode);
|
||||
setAuth(data.token, data.user!);
|
||||
navigate('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Invalid code. Please try again.');
|
||||
setTotpCode('');
|
||||
setTimeout(() => totpInputRef.current?.focus(), 50);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: username + password
|
||||
if (!username || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await authApi.login(username, password);
|
||||
setAuth(data.token, data.user);
|
||||
navigate('/dashboard');
|
||||
if (data.requires_totp && data.totp_token) {
|
||||
setTotpToken(data.totp_token);
|
||||
setTimeout(() => totpInputRef.current?.focus(), 50);
|
||||
} else {
|
||||
setAuth(data.token!, data.user!);
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Login failed. Please check your credentials.');
|
||||
@@ -83,61 +112,102 @@ export default function LoginPage() {
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="username"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
{totpToken ? (
|
||||
/* Step 2: TOTP code */
|
||||
<div>
|
||||
<div className={`flex items-center gap-2 mb-4 p-3 rounded-lg ${isDark ? 'bg-blue-900/30 border border-blue-700/50' : 'bg-blue-50 border border-blue-200'}`}>
|
||||
<ShieldCheck className={`w-4 h-4 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
|
||||
<p className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
Enter the 6-digit code from your authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
<label className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`} htmlFor="totp-code">
|
||||
Authenticator Code
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`w-full px-3 py-2 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
id="totp-code"
|
||||
ref={totpInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
className={`w-full px-3 py-2 rounded-lg text-center text-xl tracking-[0.4em] font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
onClick={() => { setTotpToken(null); setTotpCode(''); setError(''); }}
|
||||
className={`mt-2 text-sm ${isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Step 1: username + password */
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="username"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`w-full px-3 py-2 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg ${
|
||||
@@ -152,16 +222,16 @@ export default function LoginPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
disabled={loading || (totpToken ? totpCode.length !== 6 : !username || !password)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Signing in...
|
||||
{totpToken ? 'Verifying...' : 'Signing in...'}
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
totpToken ? 'Verify Code' : 'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList,
|
||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText,
|
||||
} from 'lucide-react';
|
||||
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi } from '../services/api';
|
||||
import type { MaintenanceWindow } from '../services/api';
|
||||
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
||||
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
||||
import type { CertInfo, AlertRule, AlertChannel } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -43,7 +43,7 @@ export default function SettingsPage() {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const canWrite = user?.role !== 'viewer';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates'>('general');
|
||||
const [auditSearch, setAuditSearch] = useState('');
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
@@ -128,6 +128,59 @@ export default function SettingsPage() {
|
||||
const [pwError, setPwError] = useState('');
|
||||
const [pwSuccess, setPwSuccess] = useState('');
|
||||
|
||||
// ─── TOTP / 2FA ────────────────────────────────────────────────────────────
|
||||
const [totpSetupData, setTotpSetupData] = useState<{ secret: string; uri: string; qr: string } | null>(null);
|
||||
const [totpConfirmCode, setTotpConfirmCode] = useState('');
|
||||
const [totpDisablePassword, setTotpDisablePassword] = useState('');
|
||||
const [totpMsg, setTotpMsg] = useState('');
|
||||
const [totpError, setTotpError] = useState('');
|
||||
|
||||
const { data: totpStatus, refetch: refetchTotpStatus } = useQuery({
|
||||
queryKey: ['totp-status'],
|
||||
queryFn: () => authApi.totpStatus().then((r) => r.data),
|
||||
enabled: activeTab === 'security',
|
||||
});
|
||||
|
||||
const startTotpSetupMutation = useMutation({
|
||||
mutationFn: () => authApi.totpSetup(),
|
||||
onSuccess: (res) => { setTotpSetupData(res.data); setTotpConfirmCode(''); setTotpError(''); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setTotpError(msg || 'Failed to start TOTP setup');
|
||||
},
|
||||
});
|
||||
|
||||
const confirmTotpMutation = useMutation({
|
||||
mutationFn: () => authApi.totpConfirm(totpConfirmCode),
|
||||
onSuccess: () => {
|
||||
setTotpSetupData(null);
|
||||
setTotpConfirmCode('');
|
||||
setTotpMsg('Two-factor authentication enabled.');
|
||||
setTotpError('');
|
||||
refetchTotpStatus();
|
||||
setTimeout(() => setTotpMsg(''), 4000);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setTotpError(msg || 'Invalid code');
|
||||
},
|
||||
});
|
||||
|
||||
const disableTotpMutation = useMutation({
|
||||
mutationFn: () => authApi.totpDisable(totpDisablePassword),
|
||||
onSuccess: () => {
|
||||
setTotpDisablePassword('');
|
||||
setTotpMsg('Two-factor authentication disabled.');
|
||||
setTotpError('');
|
||||
refetchTotpStatus();
|
||||
setTimeout(() => setTotpMsg(''), 4000);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setTotpError(msg || 'Failed to disable TOTP');
|
||||
},
|
||||
});
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: () => authApi.changePassword(pwForm.current, pwForm.next),
|
||||
onSuccess: () => {
|
||||
@@ -192,6 +245,70 @@ export default function SettingsPage() {
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// ─── Config Templates ──────────────────────────────────────────────────────
|
||||
const [tplFormOpen, setTplFormOpen] = useState(false);
|
||||
const [tplForm, setTplForm] = useState<{
|
||||
name: string; description: string; applies_to_type: string;
|
||||
dns_servers: string; ntp_servers: string; syslog_host: string;
|
||||
}>({ name: '', description: '', applies_to_type: '', dns_servers: '', ntp_servers: '', syslog_host: '' });
|
||||
const [tplApplyId, setTplApplyId] = useState<number | null>(null);
|
||||
const [tplApplyDeviceIds, setTplApplyDeviceIds] = useState<number[]>([]);
|
||||
const [tplApplyResults, setTplApplyResults] = useState<{ device_id: number; device_name: string; ok: boolean; error?: string }[] | null>(null);
|
||||
const [tplError, setTplError] = useState('');
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['config-templates'],
|
||||
queryFn: () => configTemplatesApi.list().then((r) => r.data),
|
||||
enabled: activeTab === 'templates',
|
||||
});
|
||||
|
||||
const { data: allDevicesList = [] } = useQuery({
|
||||
queryKey: ['devices-list-for-templates'],
|
||||
queryFn: () => import('../services/api').then(m => m.default.get<{ id: number; name: string }[]>('/devices').then(r => r.data)),
|
||||
enabled: tplApplyId != null,
|
||||
});
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const tj: ConfigTemplate['template_json'] = {};
|
||||
if (tplForm.dns_servers.trim()) tj.dns_servers = tplForm.dns_servers.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (tplForm.ntp_servers.trim()) tj.ntp_servers = tplForm.ntp_servers.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (tplForm.syslog_host.trim()) tj.syslog_host = tplForm.syslog_host.trim();
|
||||
return configTemplatesApi.create({
|
||||
name: tplForm.name.trim(),
|
||||
description: tplForm.description.trim() || null,
|
||||
applies_to_type: tplForm.applies_to_type || null,
|
||||
template_json: tj,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['config-templates'] });
|
||||
setTplFormOpen(false);
|
||||
setTplForm({ name: '', description: '', applies_to_type: '', dns_servers: '', ntp_servers: '', syslog_host: '' });
|
||||
setTplError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setTplError(msg || 'Failed to create template');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: (id: number) => configTemplatesApi.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-templates'] }),
|
||||
});
|
||||
|
||||
const applyTemplateMutation = useMutation({
|
||||
mutationFn: () => configTemplatesApi.apply(tplApplyId!, tplApplyDeviceIds),
|
||||
onSuccess: (res) => {
|
||||
setTplApplyResults(res.data.results);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setTplError(msg || 'Failed to apply template');
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Tags ─────────────────────────────────────────────────────────────────
|
||||
const { data: tags = [] } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
@@ -350,6 +467,7 @@ export default function SettingsPage() {
|
||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
|
||||
...(isAdmin ? [
|
||||
{ key: 'templates' as const, label: 'Config Templates', icon: FileText },
|
||||
{ key: 'tags' as const, label: 'Tags', icon: ShieldCheck },
|
||||
{ key: 'maintenance' as const, label: 'Maintenance', icon: ShieldAlert },
|
||||
{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList },
|
||||
@@ -776,7 +894,7 @@ export default function SettingsPage() {
|
||||
|
||||
{/* ── My Password ── */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="max-w-md">
|
||||
<div className="max-w-md space-y-4">
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-4">Change My Password</h3>
|
||||
<div className="space-y-3">
|
||||
@@ -839,6 +957,106 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TOTP / 2FA ── */}
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-500" />
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Add an extra layer of security with a TOTP authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
|
||||
{totpMsg && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-500 mb-3">
|
||||
<CheckCircle className="w-4 h-4" /> {totpMsg}
|
||||
</div>
|
||||
)}
|
||||
{totpError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
|
||||
<AlertCircle className="w-4 h-4" /> {totpError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpStatus?.totp_enabled ? (
|
||||
/* Already enabled — show disable form */
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 font-medium">
|
||||
<ShieldCheck className="w-4 h-4" /> 2FA is currently enabled
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Enter your password to disable 2FA</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={totpDisablePassword}
|
||||
onChange={(e) => setTotpDisablePassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => disableTotpMutation.mutate()}
|
||||
disabled={!totpDisablePassword || disableTotpMutation.isPending}
|
||||
className="btn-danger"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
) : totpSetupData ? (
|
||||
/* Setup in progress — show QR + confirm step */
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<img src={totpSetupData.qr} alt="TOTP QR code" className="w-44 h-44 rounded-lg border border-gray-200 dark:border-slate-700" />
|
||||
</div>
|
||||
<details className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary className="cursor-pointer select-none">Can't scan? Enter secret manually</summary>
|
||||
<code className="mt-1 block font-mono break-all">{totpSetupData.secret}</code>
|
||||
</details>
|
||||
<div>
|
||||
<label className="label">6-digit verification code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
className="input text-center tracking-widest font-mono text-lg"
|
||||
value={totpConfirmCode}
|
||||
onChange={(e) => setTotpConfirmCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => confirmTotpMutation.mutate()}
|
||||
disabled={totpConfirmCode.length !== 6 || confirmTotpMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
Enable 2FA
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTotpSetupData(null); setTotpConfirmCode(''); setTotpError(''); }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Not enabled, not in setup */
|
||||
<button
|
||||
onClick={() => startTotpSetupMutation.mutate()}
|
||||
disabled={startTotpSetupMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Set up 2FA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1484,6 +1702,159 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* ── Config Templates ── */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Configuration Templates</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Define reusable config sets (DNS, NTP, Syslog) and push them to multiple devices at once.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => { setTplFormOpen(true); setTplError(''); }} className="btn-primary text-sm">
|
||||
<Plus className="w-4 h-4" /> New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tplError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
|
||||
<AlertCircle className="w-4 h-4" /> {tplError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{tplFormOpen && (
|
||||
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4 space-y-3 bg-blue-50/30 dark:bg-blue-900/10">
|
||||
<h4 className="text-sm font-semibold text-gray-800 dark:text-white">New Template</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Name</label>
|
||||
<input className="input" value={tplForm.name} onChange={(e) => setTplForm((f) => ({ ...f, name: e.target.value }))} placeholder="Standard Office Config" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Device Type (optional)</label>
|
||||
<select className="input" value={tplForm.applies_to_type} onChange={(e) => setTplForm((f) => ({ ...f, applies_to_type: e.target.value }))}>
|
||||
<option value="">All devices</option>
|
||||
<option value="router">Router</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="wireless">Wireless AP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Description (optional)</label>
|
||||
<input className="input" value={tplForm.description} onChange={(e) => setTplForm((f) => ({ ...f, description: e.target.value }))} placeholder="Standard DNS and NTP for main office" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">DNS Servers (comma-separated)</label>
|
||||
<input className="input" value={tplForm.dns_servers} onChange={(e) => setTplForm((f) => ({ ...f, dns_servers: e.target.value }))} placeholder="8.8.8.8, 1.1.1.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">NTP Servers (comma-separated)</label>
|
||||
<input className="input" value={tplForm.ntp_servers} onChange={(e) => setTplForm((f) => ({ ...f, ntp_servers: e.target.value }))} placeholder="pool.ntp.org" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Syslog Host (optional)</label>
|
||||
<input className="input" value={tplForm.syslog_host} onChange={(e) => setTplForm((f) => ({ ...f, syslog_host: e.target.value }))} placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createTemplateMutation.mutate()} disabled={!tplForm.name.trim() || createTemplateMutation.isPending} className="btn-primary text-sm">
|
||||
Create Template
|
||||
</button>
|
||||
<button onClick={() => { setTplFormOpen(false); setTplError(''); }} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list */}
|
||||
{templates.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">No templates yet. Create one to get started.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.map((tpl) => (
|
||||
<div key={tpl.id} className="border border-gray-200 dark:border-slate-700 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">{tpl.name}</span>
|
||||
{tpl.applies_to_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
|
||||
{tpl.applies_to_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tpl.description && <p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">{tpl.description}</p>}
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{tpl.template_json.dns_servers?.length ? <span>DNS: {tpl.template_json.dns_servers.join(', ')}</span> : null}
|
||||
{tpl.template_json.ntp_servers?.length ? <span>NTP: {tpl.template_json.ntp_servers.join(', ')}</span> : null}
|
||||
{tpl.template_json.syslog_host ? <span>Syslog: {tpl.template_json.syslog_host}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setTplApplyId(tpl.id); setTplApplyDeviceIds([]); setTplApplyResults(null); setTplError(''); }}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-500 text-white transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Send className="w-3 h-3" /> Apply
|
||||
</button>
|
||||
<button onClick={() => deleteTemplateMutation.mutate(tpl.id)} className="text-gray-400 hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply panel */}
|
||||
{tplApplyId === tpl.id && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-slate-700 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-slate-300">Select devices to apply this template to:</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{allDevicesList.map((d) => (
|
||||
<label key={d.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tplApplyDeviceIds.includes(d.id)}
|
||||
onChange={(e) => setTplApplyDeviceIds((ids) =>
|
||||
e.target.checked ? [...ids, d.id] : ids.filter((x) => x !== d.id)
|
||||
)}
|
||||
/>
|
||||
<span className="text-gray-800 dark:text-slate-200">{d.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{tplApplyResults && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{tplApplyResults.map((r) => (
|
||||
<div key={r.device_id} className={`text-xs flex items-center gap-1.5 ${r.ok ? 'text-green-600 dark:text-green-400' : 'text-red-500'}`}>
|
||||
{r.ok ? <CheckCircle className="w-3.5 h-3.5" /> : <AlertCircle className="w-3.5 h-3.5" />}
|
||||
{r.device_name}: {r.ok ? 'Applied successfully' : r.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => applyTemplateMutation.mutate()}
|
||||
disabled={tplApplyDeviceIds.length === 0 || applyTemplateMutation.isPending}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{applyTemplateMutation.isPending ? 'Applying...' : `Apply to ${tplApplyDeviceIds.length} device${tplApplyDeviceIds.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
<button onClick={() => { setTplApplyId(null); setTplApplyResults(null); }} className="btn-secondary text-xs">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tags ── */}
|
||||
{activeTab === 'tags' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -43,10 +43,20 @@ api.interceptors.response.use(
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ token: string; user: import('../types').User }>(
|
||||
api.post<{ token?: string; user?: import('../types').User; requires_totp?: boolean; totp_token?: string }>(
|
||||
'/auth/login',
|
||||
{ username, password }
|
||||
),
|
||||
totpVerify: (totp_token: string, code: string) =>
|
||||
api.post<{ token: string; user: import('../types').User }>('/auth/totp/verify', { totp_token, code }),
|
||||
totpSetup: () =>
|
||||
api.post<{ secret: string; uri: string; qr: string }>('/auth/totp/setup'),
|
||||
totpConfirm: (code: string) =>
|
||||
api.post<{ ok: boolean }>('/auth/totp/confirm', { code }),
|
||||
totpDisable: (password: string) =>
|
||||
api.post<{ ok: boolean }>('/auth/totp/disable', { password }),
|
||||
totpStatus: () =>
|
||||
api.get<{ totp_enabled: boolean }>('/auth/totp/status'),
|
||||
me: () => api.get('/auth/me'),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api.put('/auth/password', { currentPassword, newPassword }),
|
||||
@@ -776,4 +786,31 @@ export const auditLogApi = {
|
||||
}>('/audit-log', { params }),
|
||||
};
|
||||
|
||||
export interface ConfigTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
applies_to_type: string | null;
|
||||
template_json: {
|
||||
dns_servers?: string[];
|
||||
ntp_servers?: string[];
|
||||
syslog_host?: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const configTemplatesApi = {
|
||||
list: () => api.get<ConfigTemplate[]>('/config-templates'),
|
||||
get: (id: number) => api.get<ConfigTemplate>(`/config-templates/${id}`),
|
||||
create: (data: Partial<ConfigTemplate>) => api.post<ConfigTemplate>('/config-templates', data),
|
||||
update: (id: number, data: Partial<ConfigTemplate>) => api.put<ConfigTemplate>(`/config-templates/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/config-templates/${id}`),
|
||||
apply: (id: number, device_ids: number[]) =>
|
||||
api.post<{ results: { device_id: number; device_name: string; ok: boolean; error?: string }[] }>(
|
||||
`/config-templates/${id}/apply`,
|
||||
{ device_ids }
|
||||
),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.11.3 Beta';
|
||||
export const APP_VERSION = 'v0.11.4 Beta';
|
||||
|
||||
Reference in New Issue
Block a user