diff --git a/README.md b/README.md index a32fba7..316c1c5 100644 --- a/README.md +++ b/README.md @@ -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. -![Version](https://img.shields.io/badge/version-0.11.3_Beta-blue) +![Version](https://img.shields.io/badge/version-0.11.4_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package-lock.json b/backend/package-lock.json index b74f9eb..0f85b14 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 2c392cd..b65e6a1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index ec5a1eb..3057491 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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, diff --git a/backend/src/index.ts b/backend/src/index.ts index 04df643..d82e37b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index b189e2a..36703a3 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 }); }); diff --git a/backend/src/routes/configTemplates.ts b/backend/src/routes/configTemplates.ts new file mode 100644 index 0000000..51935a1 --- /dev/null +++ b/backend/src/routes/configTemplates.ts @@ -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(`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(`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; + if (!name?.trim()) { res.status(400).json({ error: 'name is required' }); return; } + const rows = await query( + `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; + const rows = await query( + `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( + `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[]).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; diff --git a/frontend/package.json b/frontend/package.json index 1106cd5..4194eb8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.11.3-beta", + "version": "0.11.4-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 8d7a3c2..041c8c7 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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(null); + const [totpCode, setTotpCode] = useState(''); + const totpInputRef = useRef(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() {
-
- - setUsername(e.target.value)} - placeholder="admin" - autoComplete="username" - autoFocus - /> -
- -
- -
+ {totpToken ? ( + /* Step 2: TOTP code */ +
+
+ +

+ Enter the 6-digit code from your authenticator app. +

+
+ 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" />
-
+ ) : ( + /* Step 1: username + password */ + <> +
+ + setUsername(e.target.value)} + placeholder="admin" + autoComplete="username" + autoFocus + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + /> + +
+
+ + )} {error && (
{loading ? ( <> - Signing in... + {totpToken ? 'Verifying...' : 'Signing in...'} ) : ( - 'Sign in' + totpToken ? 'Verify Code' : 'Sign in' )} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 854bd31..c995266 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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(null); + const [tplApplyDeviceIds, setTplApplyDeviceIds] = useState([]); + 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' && ( -
+

Change My Password

@@ -839,6 +957,106 @@ export default function SettingsPage() {
+ + {/* ── TOTP / 2FA ── */} +
+

+ + Two-Factor Authentication +

+

+ Add an extra layer of security with a TOTP authenticator app (Google Authenticator, Authy, etc.) +

+ + {totpMsg && ( +
+ {totpMsg} +
+ )} + {totpError && ( +
+ {totpError} +
+ )} + + {totpStatus?.totp_enabled ? ( + /* Already enabled — show disable form */ +
+
+ 2FA is currently enabled +
+
+ + setTotpDisablePassword(e.target.value)} + autoComplete="current-password" + /> +
+ +
+ ) : totpSetupData ? ( + /* Setup in progress — show QR + confirm step */ +
+

+ Scan this QR code with your authenticator app, then enter the 6-digit code to confirm. +

+
+ TOTP QR code +
+
+ Can't scan? Enter secret manually + {totpSetupData.secret} +
+
+ + setTotpConfirmCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + autoComplete="one-time-code" + /> +
+
+ + +
+
+ ) : ( + /* Not enabled, not in setup */ + + )} +
)} @@ -1484,6 +1702,159 @@ export default function SettingsPage() {
)} + {/* ── Config Templates ── */} + {activeTab === 'templates' && ( +
+
+
+
+

Configuration Templates

+

+ Define reusable config sets (DNS, NTP, Syslog) and push them to multiple devices at once. +

+
+ +
+ + {tplError && ( +
+ {tplError} +
+ )} + + {/* Create form */} + {tplFormOpen && ( +
+

New Template

+
+
+ + setTplForm((f) => ({ ...f, name: e.target.value }))} placeholder="Standard Office Config" /> +
+
+ + +
+
+
+ + setTplForm((f) => ({ ...f, description: e.target.value }))} placeholder="Standard DNS and NTP for main office" /> +
+
+
+ + setTplForm((f) => ({ ...f, dns_servers: e.target.value }))} placeholder="8.8.8.8, 1.1.1.1" /> +
+
+ + setTplForm((f) => ({ ...f, ntp_servers: e.target.value }))} placeholder="pool.ntp.org" /> +
+
+
+ + setTplForm((f) => ({ ...f, syslog_host: e.target.value }))} placeholder="192.168.1.100" /> +
+
+ + +
+
+ )} + + {/* Template list */} + {templates.length === 0 ? ( +

No templates yet. Create one to get started.

+ ) : ( +
+ {templates.map((tpl) => ( +
+
+
+
+ {tpl.name} + {tpl.applies_to_type && ( + + {tpl.applies_to_type} + + )} +
+ {tpl.description &&

{tpl.description}

} +
+ {tpl.template_json.dns_servers?.length ? DNS: {tpl.template_json.dns_servers.join(', ')} : null} + {tpl.template_json.ntp_servers?.length ? NTP: {tpl.template_json.ntp_servers.join(', ')} : null} + {tpl.template_json.syslog_host ? Syslog: {tpl.template_json.syslog_host} : null} +
+
+
+ + +
+
+ + {/* Apply panel */} + {tplApplyId === tpl.id && ( +
+

Select devices to apply this template to:

+
+ {allDevicesList.map((d) => ( + + ))} +
+ {tplApplyResults && ( +
+ {tplApplyResults.map((r) => ( +
+ {r.ok ? : } + {r.device_name}: {r.ok ? 'Applied successfully' : r.error} +
+ ))} +
+ )} +
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+ )} + {/* ── Tags ── */} {activeTab === 'tags' && (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c16ff0b..91c2dae 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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('/config-templates'), + get: (id: number) => api.get(`/config-templates/${id}`), + create: (data: Partial) => api.post('/config-templates', data), + update: (id: number, data: Partial) => api.put(`/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; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 1452005..7dafdf7 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.11.3 Beta'; +export const APP_VERSION = 'v0.11.4 Beta';