diff --git a/.env.example b/.env.example index 6072087..31f1caf 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ HTTP_PORT=80 HTTPS_PORT=443 TZ=UTC +# Browser origins allowed to call the API (comma-separated). Required in production. +# Docker Compose defaults this for local https://localhost access; override for your domain. +# CORS_ORIGIN=https://manager.example.com,http://localhost:3000 + # Database DB_PASSWORD=mikrotik_secure_pw diff --git a/backend/package-lock.json b/backend/package-lock.json index 1f80e0f..9d05b3b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -39,10 +39,12 @@ "@types/nodemailer": "^6.4.14", "@types/pg": "^8.10.9", "@types/ssh2": "^1.11.17", + "@types/supertest": "^7.2.0", "eslint": "^9.39.4", "eslint-plugin-security": "^4.0.0", "jest": "^29.7.0", "nodemon": "^3.0.2", + "supertest": "^7.2.2", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "typescript": "^5.3.3", @@ -1551,6 +1553,29 @@ "win32" ] }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1696,6 +1721,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1811,6 +1843,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1947,6 +1986,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2454,6 +2517,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -2463,6 +2533,13 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3063,6 +3140,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -3143,6 +3243,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -3271,6 +3378,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -3319,6 +3436,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -3509,6 +3637,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3939,6 +4083,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4031,6 +4182,41 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4302,6 +4488,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7203,6 +7405,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/backend/package.json b/backend/package.json index a27031c..57014fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,10 +44,12 @@ "@types/nodemailer": "^6.4.14", "@types/pg": "^8.10.9", "@types/ssh2": "^1.11.17", + "@types/supertest": "^7.2.0", "eslint": "^9.39.4", "eslint-plugin-security": "^4.0.0", "jest": "^29.7.0", "nodemon": "^3.0.2", + "supertest": "^7.2.2", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "typescript": "^5.3.3", diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 7ec11a1..8011cc5 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -287,6 +287,7 @@ CREATE TABLE IF NOT EXISTS credential_presets ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); +ALTER TABLE credential_presets ADD COLUMN IF NOT EXISTS allow_operator_use BOOLEAN NOT NULL DEFAULT TRUE; -- Wireless security profiles (WPA/WPA2/WPA3 config) CREATE TABLE IF NOT EXISTS wireless_security_profiles ( diff --git a/backend/src/index.ts b/backend/src/index.ts index f61a04a..83db2de 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,8 +18,14 @@ import { redis } from './config/redis'; import { runMigrations } from './db/migrate'; import { errorHandler } from './middleware/errorHandler'; import { PollerService } from './services/PollerService'; +import { + setBulkAddPollerService, + startBulkAddWorker, + stopBulkAddWorker, +} from './services/DeviceBulkAddWorker'; import { verifyToken } from './middleware/auth'; import { decrypt } from './utils/crypto'; +import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins'; import authRoutes from './routes/auth'; import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices'; @@ -44,7 +50,7 @@ const PORT = parseInt(process.env.PORT || '3001', 10); // ─── Socket.io ──────────────────────────────────────────────────────────────── const io = new SocketServer(httpServer, { - cors: { origin: '*', methods: ['GET', 'POST'] }, + cors: socketIoCorsOptions(), path: '/socket.io', }); @@ -168,7 +174,7 @@ terminalNs.on('connection', (socket) => { // ─── Middleware ─────────────────────────────────────────────────────────────── app.use(helmet({ contentSecurityPolicy: false })); -app.use(cors({ origin: '*', credentials: true })); +app.use(cors(corsMiddlewareOptions())); app.use(compression()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); @@ -234,9 +240,11 @@ async function start(): Promise { const pollerService = new PollerService(); pollerService.setSocketServer(io); setDevicesPoller(pollerService); + setBulkAddPollerService(pollerService); setTopologyPoller(pollerService); setClientsPoller(pollerService); await pollerService.start(); + await startBulkAddWorker(); // Start HTTP server httpServer.listen(PORT, '0.0.0.0', () => { @@ -246,6 +254,7 @@ async function start(): Promise { // Graceful shutdown const shutdown = async () => { console.log('Shutting down...'); + await stopBulkAddWorker(); await pollerService.stop(); await redis.quit().catch(() => {}); await pool.end(); diff --git a/backend/src/middleware/rateLimitRedis.ts b/backend/src/middleware/rateLimitRedis.ts new file mode 100644 index 0000000..13de4b6 --- /dev/null +++ b/backend/src/middleware/rateLimitRedis.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction } from 'express'; +import { redis } from '../config/redis'; + +/** + * Fixed-window rate limit using Redis INCR (shared across API replicas). + */ +export function rateLimitRedis(options: { + windowSec: number; + max: number; + keyPrefix: string; +}): (req: Request, res: Response, next: NextFunction) => void { + const { windowSec, max, keyPrefix } = options; + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + next(); + return; + } + const uid = req.user?.userId; + const key = `rl:${keyPrefix}:${uid != null ? `u:${uid}` : `ip:${req.ip || 'unknown'}`}`; + try { + const n = await redis.incr(key); + if (n === 1) { + await redis.expire(key, windowSec); + } + if (n > max) { + const ttl = await redis.ttl(key); + const retryAfterSec = Math.max(1, ttl > 0 ? ttl : windowSec); + res.setHeader('Retry-After', String(retryAfterSec)); + res.status(429).json({ error: 'Too many requests. Please try again later.' }); + return; + } + } catch (e) { + console.warn('[rateLimitRedis] Redis error, allowing request:', (e as Error).message); + } + next(); + }; +} diff --git a/backend/src/routes/__tests__/credentialPresets.test.ts b/backend/src/routes/__tests__/credentialPresets.test.ts new file mode 100644 index 0000000..8edc60e --- /dev/null +++ b/backend/src/routes/__tests__/credentialPresets.test.ts @@ -0,0 +1,129 @@ +jest.mock('../../config/database'); +jest.mock('../../config/redis', () => ({ + redis: { + incr: jest.fn().mockResolvedValue(1), + expire: jest.fn().mockResolvedValue(1), + ttl: jest.fn().mockResolvedValue(60), + }, +})); +jest.mock('../../middleware/auth', () => ({ + requireAuth: (_req: unknown, _res: unknown, next: () => void) => next(), + requireAdmin: ( + req: { user?: { role: string } }, + res: { status: (c: number) => { json: (b: unknown) => void } }, + next: () => void + ) => { + if (req.user?.role !== 'admin') { + res.status(403).json({ error: 'Admin access required' }); + return; + } + next(); + }, +})); + +jest.mock('../../utils/crypto', () => ({ + encrypt: (s: string) => `enc:${s}`, +})); + +import request from 'supertest'; +import express from 'express'; +import credentialPresetsRoutes from '../credentialPresets'; +import { query, queryOne } from '../../config/database'; + +const mockedQuery = jest.mocked(query); +const mockedQueryOne = jest.mocked(queryOne); + +function makeApp(role: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as express.Request & { user?: { userId: number; username: string; role: string } }).user = { + userId: 1, + username: 'test', + role, + }; + next(); + }); + app.use('/', credentialPresetsRoutes); + return app; +} + +describe('credentialPresets routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('GET / returns public list', async () => { + mockedQuery.mockResolvedValueOnce([ + { + id: 1, + name: 'Default', + api_username: 'admin', + api_password_encrypted: 'x', + api_port: 8728, + ssh_username: null, + ssh_password_encrypted: null, + ssh_port: null, + notes: null, + allow_operator_use: true, + created_at: 't', + updated_at: 't', + }, + ]); + const app = makeApp('viewer'); + const res = await request(app).get('/'); + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ + id: 1, + name: 'Default', + has_api_password: true, + has_ssh_password: false, + }); + expect(res.body[0].api_password_encrypted).toBeUndefined(); + }); + + it('POST / rejects non-admin', async () => { + const app = makeApp('operator'); + const res = await request(app).post('/').send({ + name: 'x', + api_username: 'a', + api_password: 'p', + }); + expect(res.status).toBe(403); + }); + + it('POST / requires name, api_username, api_password', async () => { + const app = makeApp('admin'); + const res = await request(app).post('/').send({ name: 'only' }); + expect(res.status).toBe(400); + expect(String(res.body.error)).toMatch(/required/); + }); + + it('POST / creates preset when admin', async () => { + mockedQueryOne.mockResolvedValueOnce(null); + mockedQuery.mockResolvedValueOnce([ + { + id: 2, + name: 'Lab', + api_username: 'admin', + api_password_encrypted: 'enc:p', + api_port: 8728, + ssh_username: null, + ssh_password_encrypted: null, + ssh_port: null, + notes: null, + allow_operator_use: true, + created_at: 't', + updated_at: 't', + }, + ]); + const app = makeApp('admin'); + const res = await request(app).post('/').send({ + name: 'Lab', + api_username: 'admin', + api_password: 'secret', + }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: 2, name: 'Lab', has_api_password: true }); + }); +}); diff --git a/backend/src/routes/credentialPresets.ts b/backend/src/routes/credentialPresets.ts index 7aebe0e..f73fd9f 100644 --- a/backend/src/routes/credentialPresets.ts +++ b/backend/src/routes/credentialPresets.ts @@ -2,10 +2,17 @@ import { Router, Request, Response } from 'express'; import { query, queryOne } from '../config/database'; import { requireAuth, requireAdmin } from '../middleware/auth'; import { encrypt } from '../utils/crypto'; +import { rateLimitRedis } from '../middleware/rateLimitRedis'; const router = Router(); router.use(requireAuth); +const presetMutationLimiter = rateLimitRedis({ + windowSec: 60, + max: 30, + keyPrefix: 'credential-preset', +}); + export interface CredentialPresetRow { id: number; name: string; @@ -16,6 +23,8 @@ export interface CredentialPresetRow { ssh_password_encrypted: string | null; ssh_port: number | null; notes: string | null; + /** When false, only admins may use this preset when adding/updating devices. */ + allow_operator_use: boolean; created_at: string; updated_at: string; } @@ -30,6 +39,7 @@ interface CredentialPresetPublic { ssh_username: string | null; ssh_port: number | null; notes: string | null; + allow_operator_use: boolean; has_api_password: boolean; has_ssh_password: boolean; created_at: string; @@ -45,6 +55,7 @@ function toPublic(row: CredentialPresetRow): CredentialPresetPublic { ssh_username: row.ssh_username, ssh_port: row.ssh_port, notes: row.notes, + allow_operator_use: row.allow_operator_use !== false, has_api_password: !!row.api_password_encrypted, has_ssh_password: !!row.ssh_password_encrypted, created_at: row.created_at, @@ -54,15 +65,20 @@ function toPublic(row: CredentialPresetRow): CredentialPresetPublic { // GET /api/credential-presets — any authenticated user can list (the Add // Device modal needs this for its picker), but secrets are never exposed. -router.get('/', async (_req: Request, res: Response) => { +router.get('/', async (req: Request, res: Response) => { + const role = req.user?.role; const rows = await query( `SELECT * FROM credential_presets ORDER BY name ASC` ); - res.json(rows.map(toPublic)); + const filtered = + role === 'admin' + ? rows + : rows.filter((r) => (r as CredentialPresetRow).allow_operator_use !== false); + res.json(filtered.map(toPublic)); }); // POST /api/credential-presets — admin only -router.post('/', requireAdmin, async (req: Request, res: Response) => { +router.post('/', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => { const { name, api_username, @@ -72,6 +88,7 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => { ssh_password, ssh_port, notes, + allow_operator_use, } = req.body as { name?: string; api_username?: string; @@ -81,6 +98,7 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => { ssh_password?: string | null; ssh_port?: number | null; notes?: string | null; + allow_operator_use?: boolean; }; if (!name || !api_username || !api_password) { @@ -100,11 +118,13 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => { const encApi = encrypt(api_password); const encSsh = ssh_password ? encrypt(ssh_password) : null; + const allowOp = allow_operator_use !== false; + const rows = await query( `INSERT INTO credential_presets (name, api_username, api_password_encrypted, api_port, - ssh_username, ssh_password_encrypted, ssh_port, notes) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ssh_username, ssh_password_encrypted, ssh_port, notes, allow_operator_use) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [ name, @@ -115,13 +135,14 @@ router.post('/', requireAdmin, async (req: Request, res: Response) => { encSsh, ssh_port ?? null, notes || null, + allowOp, ] ); return res.status(201).json(toPublic(rows[0])); }); // PUT /api/credential-presets/:id — admin only -router.put('/:id', requireAdmin, async (req: Request, res: Response) => { +router.put('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => { const id = parseInt(req.params.id, 10); if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); @@ -141,6 +162,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => { ssh_port, notes, clear_ssh_password, + allow_operator_use, } = req.body as { name?: string; api_username?: string; @@ -152,6 +174,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => { notes?: string | null; // Explicit flag to remove a previously-saved SSH password clear_ssh_password?: boolean; + allow_operator_use?: boolean; }; if (typeof name === 'string' && name && name !== existing.name) { @@ -177,8 +200,9 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => { ssh_password_encrypted = $6, ssh_port = $7, notes = $8, + allow_operator_use = COALESCE($9, allow_operator_use), updated_at = NOW() - WHERE id = $9`, + WHERE id = $10`, [ name ?? null, api_username ?? null, @@ -188,6 +212,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => { newSshPass, ssh_port === undefined ? existing.ssh_port : ssh_port, notes === undefined ? existing.notes : (notes || null), + allow_operator_use === undefined ? null : allow_operator_use, id, ] ); @@ -200,7 +225,7 @@ router.put('/:id', requireAdmin, async (req: Request, res: Response) => { }); // DELETE /api/credential-presets/:id — admin only -router.delete('/:id', requireAdmin, async (req: Request, res: Response) => { +router.delete('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => { const id = parseInt(req.params.id, 10); if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }); const result = await query( diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 87ebae8..9573c2d 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -1,4 +1,5 @@ import { Router, Request, Response } from 'express'; +import { randomUUID } from 'crypto'; import { query, queryOne } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { encrypt, decrypt } from '../utils/crypto'; @@ -6,11 +7,17 @@ import { RouterOSClient } from '../services/mikrotik/RouterOSClient'; import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; import { PollerService } from '../services/PollerService'; import type { CredentialPresetRow } from './credentialPresets'; +import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext } from '../services/deviceCreation'; +import { parsePort } from '../utils/parsePort'; +import { safeConnectionError } from '../utils/safeClientError'; +import { redis } from '../config/redis'; +import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker'; // Resolve a credential preset id into decrypted credentials. Returns null if // no id was provided; throws a readable error if the id is invalid. async function loadCredentialPreset( - id: number | null | undefined + id: number | null | undefined, + ctx?: CreateDeviceContext ): Promise<{ api_username: string; api_password: string; @@ -25,6 +32,12 @@ async function loadCredentialPreset( [id] ); if (!preset) throw new Error(`Credential preset ${id} not found`); + const allowOp = preset.allow_operator_use !== false; + if (ctx?.requestingUserRole === 'operator' && !allowOp) { + const err = new Error('This credential preset is restricted to administrators'); + (err as Error & { statusCode?: number }).statusCode = 403; + throw err; + } return { api_username: preset.api_username, api_password: decrypt(preset.api_password_encrypted), @@ -165,165 +178,71 @@ router.get('/discovered', async (_req: Request, res: Response) => { // POST /api/devices router.post('/', requireWrite, async (req: Request, res: Response) => { - const { - name, ip_address, device_type = 'router', notes, - credential_preset_id, - } = req.body as { name?: string; ip_address?: string; device_type?: string; notes?: string; credential_preset_id?: number | null }; + const result = await createDeviceFromBody(req.body, pollerService, { + requestingUserRole: req.user?.role, + }); + return res.status(result.status).json(result.body); +}); - // If a preset is supplied, its credentials win over anything else in the - // body — the client just needs to say "use preset 3". Anything the body - // also sends (like ssh_username) is ignored in favor of preset values, so - // "deploying" a preset always produces a consistent device record. - let preset: Awaited> = null; - try { - preset = await loadCredentialPreset(credential_preset_id ?? null); - } catch (err) { - return res.status(400).json({ error: (err as Error).message }); +const BULK_ADD_META_TTL_SEC = 86400; + +// POST /api/devices/bulk-add/jobs — enqueue Try-All style adds (survives tab close) +router.post('/bulk-add/jobs', requireWrite, async (req: Request, res: Response) => { + const { items } = req.body as { items?: unknown }; + if (!Array.isArray(items) || items.length === 0) { + return res.status(400).json({ error: 'items must be a non-empty array' }); } - - const api_username: string | undefined = preset?.api_username ?? req.body.api_username; - const api_password: string | undefined = preset?.api_password ?? req.body.api_password; - const parsePort = (v: unknown, fallback: number): number => { - if (v == null || v === '') return fallback; - const n = typeof v === 'number' ? v : parseInt(String(v), 10); - return Number.isFinite(n) ? n : fallback; - }; - const api_port: number = preset?.api_port ?? parsePort(req.body.api_port, 8728); - const ssh_username: string | null = preset ? preset.ssh_username : (req.body.ssh_username ?? null); - const ssh_password: string | null = preset ? preset.ssh_password : (req.body.ssh_password ?? null); - const ssh_port: number = preset?.ssh_port ?? parsePort(req.body.ssh_port, 22); - const combineWithDeviceId = - typeof req.body.combine_with_device_id === 'number' ? req.body.combine_with_device_id : null; - const forceReplaceBySerial = req.body.force_replace_existing_by_serial === true; - - if (!name || !ip_address || !api_username || !api_password) { - return res.status(400).json({ error: 'name, ip_address, api_username, api_password are required' }); + if (items.length > 500) { + return res.status(400).json({ error: 'items array exceeds maximum of 500' }); } - - // Test connection and detect hardware serial before saving - const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000); - let detectedSerial: string | null = null; - try { - await testClient.connect(); - const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record[]); - detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null; - } catch (err) { - return res.status(422).json({ - error: `Cannot connect to device: ${(err as Error).message}`, - }); - } finally { - testClient.disconnect(); - } - - if (detectedSerial) { - const existingBySerial = await queryOne<{ - id: number; - name: string; - ip_address: string; - serial_number: string; - }>( - `SELECT id, name, ip_address, serial_number - FROM devices - WHERE serial_number = $1`, - [detectedSerial] - ); - - if (existingBySerial) { - const shouldCombine = - combineWithDeviceId != null && combineWithDeviceId === existingBySerial.id; - if (!shouldCombine && !forceReplaceBySerial) { - return res.status(409).json({ - error: 'duplicate_serial', - code: 'duplicate_serial', - existing_device: existingBySerial, - candidate: { - serial_number: detectedSerial, - identity: name, - ip_address, - }, - }); - } - - const encryptedPass = encrypt(api_password); - const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null; - await query( - `UPDATE devices SET - name=COALESCE($1,name), - ip_address=$2, - api_port=$3, - api_username=$4, - api_password_encrypted=$5, - ssh_port=$6, - ssh_username=COALESCE($7,ssh_username), - ssh_password_encrypted=COALESCE($8,ssh_password_encrypted), - device_type=COALESCE($9,device_type), - notes=COALESCE($10,notes), - updated_at=NOW() - WHERE id = $11`, - [ - name, - ip_address, - api_port, - api_username, - encryptedPass, - ssh_port, - ssh_username, - encryptedSshPass, - device_type, - notes || null, - existingBySerial.id, - ] - ); - - if (pollerService) { - await pollerService.scheduleDeviceSync(existingBySerial.id, 'full'); - } - - const updatedExisting = await queryOne( - `SELECT id, name, ip_address, api_port, api_username, model, serial_number, - firmware_version, ros_version, device_type, status, last_seen, notes, created_at - FROM devices WHERE id = $1`, - [existingBySerial.id] - ); - if (!updatedExisting) { - return res.status(500).json({ - error: 'Duplicate merge succeeded but device row could not be reloaded', - device_id: existingBySerial.id, - }); - } - return res.status(200).json({ - ...updatedExisting, - merged_from_duplicate: true, - }); - } - } - - const encryptedPass = encrypt(api_password); - const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null; - - const rows = await query<{ id: number }>( - `INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted, - ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`, - [name, ip_address, api_port, api_username, encryptedPass, - ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null] + const jobId = randomUUID(); + const ownerUserId = req.user!.userId; + await redis.set( + `device-bulk-add:${jobId}:meta`, + JSON.stringify({ + status: 'queued', + total: items.length, + processed: 0, + owner_user_id: ownerUserId, + created_at: new Date().toISOString(), + }), + 'EX', + BULK_ADD_META_TTL_SEC ); + await redis.set(`device-bulk-add:${jobId}:results`, '[]', 'EX', BULK_ADD_META_TTL_SEC); + await enqueueBulkAddJob(jobId, items as CreateDeviceInput[]); + return res.status(202).json({ job_id: jobId, total: items.length }); +}); - const newId = rows[0].id; - - // Trigger full sync in background - if (pollerService) { - await pollerService.scheduleDeviceSync(newId, 'full'); +// POST /api/devices/bulk-add/jobs/:jobId/cancel — request cooperative cancel +router.post('/bulk-add/jobs/:jobId/cancel', requireWrite, async (req: Request, res: Response) => { + const state = await getBulkAddJobState(req.params.jobId); + if (!state.found) { + return res.status(404).json({ error: 'Job not found or expired' }); } + const ownerId = state.meta.owner_user_id; + if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) { + return res.status(403).json({ error: 'Forbidden' }); + } + await redis.set(`device-bulk-add:${req.params.jobId}:cancel`, '1', 'EX', BULK_ADD_META_TTL_SEC); + return res.json({ message: 'Cancel requested' }); +}); - const device = await queryOne( - `SELECT id, name, ip_address, api_port, api_username, model, serial_number, - firmware_version, ros_version, device_type, status, last_seen, notes, created_at - FROM devices WHERE id = $1`, - [newId] - ); - - return res.status(201).json(device); +// GET /api/devices/bulk-add/jobs/:jobId — poll job status and incremental results +router.get('/bulk-add/jobs/:jobId', requireWrite, async (req: Request, res: Response) => { + const state = await getBulkAddJobState(req.params.jobId); + if (!state.found) { + return res.status(404).json({ error: 'Job not found or expired' }); + } + const ownerId = state.meta.owner_user_id; + if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) { + return res.status(403).json({ error: 'Forbidden' }); + } + return res.json({ + job_id: req.params.jobId, + ...state.meta, + results: state.results, + }); }); // GET /api/devices/:id @@ -392,35 +311,39 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => { credential_preset_id?: number | null; }; - let preset: Awaited> = null; - try { - preset = await loadCredentialPreset(credential_preset_id ?? null); - } catch (err) { - return res.status(400).json({ error: (err as Error).message }); - } - - // When a preset is applied, its values take precedence over anything else - // in the body so "apply preset" has a single unambiguous meaning. - const api_port = preset?.api_port ?? req.body.api_port; - const api_username = preset?.api_username ?? req.body.api_username; - const api_password = preset?.api_password ?? req.body.api_password; - const ssh_port = preset?.ssh_port ?? req.body.ssh_port; - const ssh_username = preset ? preset.ssh_username : req.body.ssh_username; - const ssh_password = preset ? preset.ssh_password : req.body.ssh_password; - const existing = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string; + ssh_port: number | null; }>( - `SELECT id, ip_address, api_port, api_username, api_password_encrypted + `SELECT id, ip_address, api_port, api_username, api_password_encrypted, ssh_port FROM devices WHERE id = $1`, [req.params.id] ); if (!existing) return res.status(404).json({ error: 'Device not found' }); + let preset: Awaited> = null; + try { + preset = await loadCredentialPreset(credential_preset_id ?? null, { + requestingUserRole: req.user?.role, + }); + } catch (err) { + const status = (err as Error & { statusCode?: number }).statusCode ?? 400; + return res.status(status).json({ error: (err as Error).message }); + } + + // When a preset is applied, its values take precedence over anything else + // in the body so "apply preset" has a single unambiguous meaning. + const api_port = preset?.api_port ?? parsePort(req.body.api_port, existing.api_port); + const api_username = preset?.api_username ?? req.body.api_username; + const api_password = preset?.api_password ?? req.body.api_password; + const ssh_port = preset?.ssh_port ?? parsePort(req.body.ssh_port, existing.ssh_port ?? 22); + const ssh_username = preset ? preset.ssh_username : req.body.ssh_username; + const ssh_password = preset ? preset.ssh_password : req.body.ssh_password; + // If the user is changing the IP (or port / username / password), verify the // RouterOS API is still reachable with the new values before persisting — // otherwise we could silently brick the device record. @@ -430,7 +353,7 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => { const presetReplacesApiCreds = !!preset; if (ipChanged || portChanged || userChanged || api_password || presetReplacesApiCreds) { const testIp = ip_address ?? existing.ip_address; - const testPort = (typeof api_port === 'number' ? api_port : undefined) ?? existing.api_port; + const testPort = api_port; const testUser = api_username ?? existing.api_username; const testPass = api_password ? api_password : decrypt(existing.api_password_encrypted); const testClient = new RouterOSClient(testIp, testPort, testUser, testPass, 10_000); @@ -439,7 +362,7 @@ router.put('/:id', requireWrite, async (req: Request, res: Response) => { testClient.disconnect(); } catch (err) { return res.status(422).json({ - error: `Cannot connect to device with new settings: ${(err as Error).message}`, + error: safeConnectionError('PUT /devices/:id', err), }); } } diff --git a/backend/src/services/DeviceBulkAddWorker.ts b/backend/src/services/DeviceBulkAddWorker.ts new file mode 100644 index 0000000..0605efc --- /dev/null +++ b/backend/src/services/DeviceBulkAddWorker.ts @@ -0,0 +1,185 @@ +import { Queue, Worker, Job } from 'bullmq'; +import { createRedisConnection } from '../config/redis'; +import { redis } from '../config/redis'; +import { createDeviceFromBody, type CreateDeviceInput } from './deviceCreation'; +import type { PollerService } from './PollerService'; + +const QUEUE_NAME = 'bulk-add-devices'; +const JOB_TTL_SEC = 86400; + +let pollerService: PollerService | null = null; +let queue: Queue | null = null; +let worker: Worker | null = null; + +export function setBulkAddPollerService(p: PollerService): void { + pollerService = p; +} + +function metaKey(jobId: string): string { + return `device-bulk-add:${jobId}:meta`; +} + +function resultsKey(jobId: string): string { + return `device-bulk-add:${jobId}:results`; +} + +export type BulkAddResultRow = { + ip: string; + identity: string; + ok: boolean; + message: string; +}; + +interface BulkJobPayload { + jobId: string; + items: CreateDeviceInput[]; +} + +async function readMeta(jobId: string): Promise> { + const raw = await redis.get(metaKey(jobId)); + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +async function writeMeta(jobId: string, patch: Record): Promise { + const cur = await readMeta(jobId); + const next = { ...cur, ...patch }; + await redis.set(metaKey(jobId), JSON.stringify(next), 'EX', JOB_TTL_SEC); +} + +async function readResults(jobId: string): Promise { + const raw = await redis.get(resultsKey(jobId)); + if (!raw) return []; + try { + return JSON.parse(raw) as BulkAddResultRow[]; + } catch { + return []; + } +} + +async function appendResults(jobId: string, rows: BulkAddResultRow[]): Promise { + const cur = await readResults(jobId); + const next = [...cur, ...rows]; + await redis.set(resultsKey(jobId), JSON.stringify(next), 'EX', JOB_TTL_SEC); +} + +async function processJob(job: Job): Promise { + const { jobId, items } = job.data; + await writeMeta(jobId, { status: 'active', processed: 0 }); + const poller = pollerService; + + for (let i = 0; i < items.length; i++) { + const cancel = await redis.get(`device-bulk-add:${jobId}:cancel`); + if (cancel) { + await writeMeta(jobId, { + status: 'cancelled', + processed: i, + current_name: null, + cancelled_at: new Date().toISOString(), + }); + return; + } + + const item = items[i]; + const identity = item.name || item.ip_address || ''; + await writeMeta(jobId, { + processed: i, + current_name: identity, + }); + + const result = await createDeviceFromBody( + { + ...item, + device_type: item.device_type || 'router', + }, + poller + ); + + let failMsg = 'Failed'; + if (!result.ok) { + const b = result.body; + if (b.code === 'duplicate_serial') { + failMsg = 'Duplicate serial — review manually before combining'; + } else if (typeof b.error === 'string') { + failMsg = b.error; + } + } + const row: BulkAddResultRow = { + ip: item.ip_address || '', + identity, + ok: result.ok, + message: result.ok + ? (result.body.merged_from_duplicate ? 'Updated (duplicate serial)' : 'Added') + : failMsg, + }; + await appendResults(jobId, [row]); + await writeMeta(jobId, { processed: i + 1 }); + } + + await writeMeta(jobId, { + status: 'completed', + processed: items.length, + current_name: null, + completed_at: new Date().toISOString(), + }); +} + +export async function enqueueBulkAddJob(jobId: string, items: CreateDeviceInput[]): Promise { + if (!queue) { + queue = new Queue(QUEUE_NAME, { connection: createRedisConnection() }); + } + await queue.add('run', { jobId, items } satisfies BulkJobPayload, { + attempts: 1, + removeOnComplete: { age: 3600, count: 100 }, + removeOnFail: { age: 86400 }, + }); +} + +export async function getBulkAddJobState(jobId: string): Promise<{ + found: boolean; + meta: Record; + results: BulkAddResultRow[]; +}> { + const meta = await readMeta(jobId); + if (!meta || Object.keys(meta).length === 0) { + return { found: false, meta: {}, results: [] }; + } + const results = await readResults(jobId); + return { found: true, meta, results }; +} + +export async function startBulkAddWorker(): Promise { + if (worker) return; + worker = new Worker( + QUEUE_NAME, + async (job) => { + try { + await processJob(job); + } catch (err) { + const { jobId } = job.data; + console.error('[BulkAddWorker] job failed:', job?.id, err); + await writeMeta(jobId, { + status: 'failed', + error: 'Batch job failed. Check server logs for details.', + failed_at: new Date().toISOString(), + }); + throw err; + } + }, + { connection: createRedisConnection(), concurrency: 1 } + ); + worker.on('failed', (job, err) => { + console.error('[BulkAddWorker] job failed:', job?.id, err); + }); +} + +export async function stopBulkAddWorker(): Promise { + await worker?.close(); + worker = null; + await queue?.close(); + queue = null; +} diff --git a/backend/src/services/deviceCreation.ts b/backend/src/services/deviceCreation.ts new file mode 100644 index 0000000..267f538 --- /dev/null +++ b/backend/src/services/deviceCreation.ts @@ -0,0 +1,242 @@ +import { query, queryOne } from '../config/database'; +import { encrypt, decrypt } from '../utils/crypto'; +import { parsePort } from '../utils/parsePort'; +import { safeConnectionError } from '../utils/safeClientError'; +import { RouterOSClient } from './mikrotik/RouterOSClient'; +import type { PollerService } from './PollerService'; +import type { CredentialPresetRow } from '../routes/credentialPresets'; + +export type CreateDeviceContext = { + /** JWT role of the caller ('admin' | 'operator' | 'viewer'). Preset use may be restricted for operators. */ + requestingUserRole?: string; +}; + +async function loadCredentialPreset( + id: number | null | undefined, + ctx?: CreateDeviceContext +): Promise<{ + api_username: string; + api_password: string; + api_port: number | null; + ssh_username: string | null; + ssh_password: string | null; + ssh_port: number | null; +} | null> { + if (id === null || id === undefined) return null; + const preset = await queryOne( + `SELECT * FROM credential_presets WHERE id = $1`, + [id] + ); + if (!preset) throw new Error(`Credential preset ${id} not found`); + const allowOp = (preset as CredentialPresetRow & { allow_operator_use?: boolean }).allow_operator_use !== false; + if (ctx?.requestingUserRole === 'operator' && !allowOp) { + const err = new Error('This credential preset is restricted to administrators'); + (err as Error & { statusCode?: number }).statusCode = 403; + throw err; + } + return { + api_username: preset.api_username, + api_password: decrypt(preset.api_password_encrypted), + api_port: preset.api_port, + ssh_username: preset.ssh_username, + ssh_password: preset.ssh_password_encrypted ? decrypt(preset.ssh_password_encrypted) : null, + ssh_port: preset.ssh_port, + }; +} + +export interface CreateDeviceInput { + name?: string; + ip_address?: string; + device_type?: string; + notes?: string; + credential_preset_id?: number | null; + api_username?: string; + api_password?: string; + api_port?: unknown; + ssh_username?: string | null; + ssh_password?: string | null; + ssh_port?: unknown; + combine_with_device_id?: number; + force_replace_existing_by_serial?: boolean; +} + +export type CreateDeviceResult = + | { ok: true; status: 200 | 201; body: Record } + | { ok: false; status: number; body: Record }; + +/** + * Shared device creation logic used by POST /api/devices and bulk-add worker. + */ +export async function createDeviceFromBody( + input: CreateDeviceInput, + pollerService: PollerService | null, + ctx?: CreateDeviceContext +): Promise { + const { + name, + ip_address, + device_type = 'router', + notes, + credential_preset_id, + } = input; + + let preset: Awaited> = null; + try { + preset = await loadCredentialPreset(credential_preset_id ?? null, ctx); + } catch (err) { + const status = (err as Error & { statusCode?: number }).statusCode ?? 400; + return { ok: false, status, body: { error: (err as Error).message } }; + } + + const api_username: string | undefined = preset?.api_username ?? input.api_username; + const api_password: string | undefined = preset?.api_password ?? input.api_password; + const api_port: number = preset?.api_port ?? parsePort(input.api_port, 8728); + const ssh_username: string | null = preset ? preset.ssh_username : (input.ssh_username ?? null); + const ssh_password: string | null = preset ? preset.ssh_password : (input.ssh_password ?? null); + const ssh_port: number = preset?.ssh_port ?? parsePort(input.ssh_port, 22); + const combineWithDeviceId = + typeof input.combine_with_device_id === 'number' ? input.combine_with_device_id : null; + const forceReplaceBySerial = input.force_replace_existing_by_serial === true; + + if (!name || !ip_address || !api_username || !api_password) { + return { + ok: false, + status: 400, + body: { error: 'name, ip_address, api_username, api_password are required' }, + }; + } + + const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000); + let detectedSerial: string | null = null; + try { + await testClient.connect(); + const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record[]); + detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null; + } catch (err) { + return { + ok: false, + status: 422, + body: { error: safeConnectionError('createDeviceFromBody', err) }, + }; + } finally { + testClient.disconnect(); + } + + if (detectedSerial) { + const existingBySerial = await queryOne<{ + id: number; + name: string; + ip_address: string; + serial_number: string; + }>( + `SELECT id, name, ip_address, serial_number + FROM devices + WHERE serial_number = $1`, + [detectedSerial] + ); + + if (existingBySerial) { + const shouldCombine = + combineWithDeviceId != null && combineWithDeviceId === existingBySerial.id; + if (!shouldCombine && !forceReplaceBySerial) { + return { + ok: false, + status: 409, + body: { + error: 'duplicate_serial', + code: 'duplicate_serial', + existing_device: existingBySerial, + candidate: { + serial_number: detectedSerial, + identity: name, + ip_address, + }, + }, + }; + } + + const encryptedPass = encrypt(api_password); + const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null; + await query( + `UPDATE devices SET + name=COALESCE($1,name), + ip_address=$2, + api_port=$3, + api_username=$4, + api_password_encrypted=$5, + ssh_port=$6, + ssh_username=COALESCE($7,ssh_username), + ssh_password_encrypted=COALESCE($8,ssh_password_encrypted), + device_type=COALESCE($9,device_type), + notes=COALESCE($10,notes), + updated_at=NOW() + WHERE id = $11`, + [ + name, + ip_address, + api_port, + api_username, + encryptedPass, + ssh_port, + ssh_username, + encryptedSshPass, + device_type, + notes || null, + existingBySerial.id, + ] + ); + + if (pollerService) { + await pollerService.scheduleDeviceSync(existingBySerial.id, 'full'); + } + + const updatedExisting = await queryOne( + `SELECT id, name, ip_address, api_port, api_username, model, serial_number, + firmware_version, ros_version, device_type, status, last_seen, notes, created_at + FROM devices WHERE id = $1`, + [existingBySerial.id] + ); + if (!updatedExisting) { + return { + ok: false, + status: 500, + body: { + error: 'Duplicate merge succeeded but device row could not be reloaded', + device_id: existingBySerial.id, + }, + }; + } + return { + ok: true, + status: 200, + body: { ...updatedExisting, merged_from_duplicate: true }, + }; + } + } + + const encryptedPass = encrypt(api_password); + const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null; + + const rows = await query<{ id: number }>( + `INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted, + ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`, + [name, ip_address, api_port, api_username, encryptedPass, + ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null] + ); + + const newId = rows[0].id; + + if (pollerService) { + await pollerService.scheduleDeviceSync(newId, 'full'); + } + + const device = await queryOne( + `SELECT id, name, ip_address, api_port, api_username, model, serial_number, + firmware_version, ros_version, device_type, status, last_seen, notes, created_at + FROM devices WHERE id = $1`, + [newId] + ); + + return { ok: true, status: 201, body: device as Record }; +} diff --git a/backend/src/utils/__tests__/corsOrigins.test.ts b/backend/src/utils/__tests__/corsOrigins.test.ts new file mode 100644 index 0000000..df659ee --- /dev/null +++ b/backend/src/utils/__tests__/corsOrigins.test.ts @@ -0,0 +1,56 @@ +import { + corsMiddlewareOptions, + resetCorsResolvedCacheForTests, + socketIoCorsOptions, +} from '../corsOrigins'; + +describe('corsOrigins', () => { + const prev = { ...process.env }; + + afterEach(() => { + process.env = { ...prev }; + resetCorsResolvedCacheForTests(); + }); + + it('uses wildcard in test when CORS_ORIGIN is unset', () => { + process.env.NODE_ENV = 'test'; + delete process.env.CORS_ORIGIN; + expect(corsMiddlewareOptions()).toMatchObject({ + origin: '*', + credentials: false, + }); + expect(socketIoCorsOptions()).toMatchObject({ + origin: '*', + credentials: false, + }); + }); + + it('parses comma-separated CORS_ORIGIN in production', () => { + process.env.NODE_ENV = 'production'; + process.env.CORS_ORIGIN = 'https://a.example.com, https://b.example.com '; + const cors = corsMiddlewareOptions(); + expect(cors.credentials).toBe(true); + expect(typeof cors.origin).toBe('function'); + const originFn = cors.origin as ( + o: string | undefined, + fn: (e: Error | null, ok?: boolean) => void, + ) => void; + originFn(undefined, (err, ok) => { + expect(err).toBeNull(); + expect(ok).toBe(true); + }); + originFn('https://a.example.com', (err, ok) => { + expect(err).toBeNull(); + expect(ok).toBe(true); + }); + originFn('https://evil.com', (err, ok) => { + expect(err).toBeNull(); + expect(ok).toBe(false); + }); + expect(socketIoCorsOptions()).toEqual({ + origin: ['https://a.example.com', 'https://b.example.com'], + methods: ['GET', 'POST'], + credentials: true, + }); + }); +}); diff --git a/backend/src/utils/__tests__/parsePort.test.ts b/backend/src/utils/__tests__/parsePort.test.ts new file mode 100644 index 0000000..c5c065f --- /dev/null +++ b/backend/src/utils/__tests__/parsePort.test.ts @@ -0,0 +1,20 @@ +import { parsePort } from '../parsePort'; + +describe('parsePort', () => { + it('returns fallback for null, undefined, empty string', () => { + expect(parsePort(null, 8728)).toBe(8728); + expect(parsePort(undefined, 22)).toBe(22); + expect(parsePort('', 8728)).toBe(8728); + }); + + it('parses numbers and numeric strings', () => { + expect(parsePort(8729, 8728)).toBe(8729); + expect(parsePort('8729', 8728)).toBe(8729); + expect(parsePort('22', 22)).toBe(22); + }); + + it('returns fallback for NaN and non-numeric strings', () => { + expect(parsePort('abc', 8728)).toBe(8728); + expect(parsePort(NaN, 22)).toBe(22); + }); +}); diff --git a/backend/src/utils/corsOrigins.ts b/backend/src/utils/corsOrigins.ts new file mode 100644 index 0000000..efd6d1a --- /dev/null +++ b/backend/src/utils/corsOrigins.ts @@ -0,0 +1,103 @@ +import type { CorsOptions } from 'cors'; + +/** Browser dev servers and typical local access through nginx. */ +const DEFAULT_DEV_ORIGINS = [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://localhost', + 'http://127.0.0.1', + 'https://localhost', + 'https://127.0.0.1', +]; + +type Resolved = + | { kind: 'wildcard' } + | { kind: 'list'; allowed: string[] }; + +function parseList(raw: string): string[] { + return raw.split(',').map((s) => s.trim()).filter(Boolean); +} + +function resolveCors(): Resolved { + const nodeEnv = process.env.NODE_ENV || 'development'; + const raw = process.env.CORS_ORIGIN?.trim(); + + if (raw === '*') { + if (nodeEnv === 'production') { + console.error( + 'CORS_ORIGIN=* is not allowed in production. Set a comma-separated list of browser origins (e.g. https://manager.example.com).', + ); + process.exit(1); + } + return { kind: 'wildcard' }; + } + + if (!raw) { + if (nodeEnv === 'production') { + console.error( + 'CORS_ORIGIN is required in production (comma-separated origins, e.g. https://manager.example.com).', + ); + process.exit(1); + } + if (nodeEnv === 'test') { + return { kind: 'wildcard' }; + } + return { kind: 'list', allowed: DEFAULT_DEV_ORIGINS }; + } + + return { kind: 'list', allowed: parseList(raw) }; +} + +let resolvedCache: Resolved | null = null; + +function getResolved(): Resolved { + if (!resolvedCache) resolvedCache = resolveCors(); + return resolvedCache; +} + +/** Clears memoized CORS resolution (for Jest when env changes between cases). */ +export function resetCorsResolvedCacheForTests(): void { + resolvedCache = null; +} + +/** Express `cors` middleware options (no `origin: *` with `credentials: true`). */ +export function corsMiddlewareOptions(): CorsOptions { + const resolved = getResolved(); + if (resolved.kind === 'wildcard') { + return { origin: '*', credentials: false }; + } + const allowed = resolved.allowed; + return { + origin(origin, callback) { + if (!origin) { + callback(null, true); + return; + } + if (allowed.includes(origin)) { + callback(null, true); + return; + } + callback(null, false); + }, + credentials: true, + }; +} + +/** Socket.IO server `cors` field — must align with HTTP CORS. */ +export function socketIoCorsOptions(): { + origin: string | string[]; + methods: string[]; + credentials: boolean; +} { + const resolved = getResolved(); + if (resolved.kind === 'wildcard') { + return { origin: '*', methods: ['GET', 'POST'], credentials: false }; + } + return { + origin: resolved.allowed, + methods: ['GET', 'POST'], + credentials: true, + }; +} diff --git a/backend/src/utils/parsePort.ts b/backend/src/utils/parsePort.ts new file mode 100644 index 0000000..10ebe96 --- /dev/null +++ b/backend/src/utils/parsePort.ts @@ -0,0 +1,9 @@ +/** + * Parse API/SSH port from request bodies (string or number). Invalid values + * fall back to the provided default (matches existing device routes behavior). + */ +export function parsePort(value: unknown, fallback: number): number { + if (value == null || value === '') return fallback; + const n = typeof value === 'number' ? value : parseInt(String(value), 10); + return Number.isFinite(n) ? n : fallback; +} diff --git a/backend/src/utils/safeClientError.ts b/backend/src/utils/safeClientError.ts new file mode 100644 index 0000000..492223d --- /dev/null +++ b/backend/src/utils/safeClientError.ts @@ -0,0 +1,8 @@ +/** + * Return a short, stable message for API clients; log full detail server-side only. + */ +export function safeConnectionError(context: string, err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + console.warn(`[${context}]`, raw); + return 'Cannot connect to device. Check IP, port, credentials, and that the API service is reachable.'; +} diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index 6b14a31..4de8a5a 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -31,6 +31,7 @@ services: INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics} JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey} + CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1} PORT: 3001 depends_on: postgres: diff --git a/docker-compose.yml b/docker-compose.yml index 712f7fe..fc1d21c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics} JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey} + CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1} PORT: 3001 depends_on: postgres: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44aad49..bc55467 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,8 @@ "tailwindcss": "^3.4.1", "typescript": "^5.9.3", "typescript-eslint": "^8.58.2", - "vite": "^5.0.11" + "vite": "^5.0.11", + "vitest": "^3.0.5" } }, "node_modules/@alloc/quick-lru": { @@ -1465,6 +1466,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1562,6 +1574,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1935,6 +1954,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -2211,6 +2345,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2382,6 +2526,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2472,6 +2626,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2489,6 +2660,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2906,6 +3087,16 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3153,6 +3344,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3510,6 +3708,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3526,6 +3734,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4700,6 +4918,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4719,6 +4944,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5091,6 +5326,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5927,6 +6179,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -5965,6 +6224,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6090,6 +6363,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -6216,6 +6509,20 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6264,6 +6571,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6584,6 +6921,115 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6689,6 +7135,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index b8f79b6..2e83765 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint src" + "lint": "eslint src", + "test": "vitest run" }, "dependencies": { "@tanstack/react-query": "^5.17.1", @@ -42,6 +43,7 @@ "tailwindcss": "^3.4.1", "typescript": "^5.9.3", "typescript-eslint": "^8.58.2", - "vite": "^5.0.11" + "vite": "^5.0.11", + "vitest": "^3.0.5" } } diff --git a/frontend/src/components/devices/AddDeviceModal.tsx b/frontend/src/components/devices/AddDeviceModal.tsx index b5f9c8f..d35d0ae 100644 --- a/frontend/src/components/devices/AddDeviceModal.tsx +++ b/frontend/src/components/devices/AddDeviceModal.tsx @@ -7,6 +7,7 @@ import { type DuplicateSerialError, } from '../../services/api'; import ConfirmDuplicateModal from './ConfirmDuplicateModal'; +import { parsePort } from '../../utils/parsePort'; interface Props { onClose: () => void; @@ -62,10 +63,10 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) { return { name: form.name, ip_address: form.ip_address, - api_port: parseInt(form.api_port, 10), + api_port: parsePort(form.api_port, 8728), api_username: form.api_username, api_password: form.api_password, - ssh_port: parseInt(form.ssh_port, 10), + ssh_port: parsePort(form.ssh_port, 22), ssh_username: form.ssh_username || undefined, ssh_password: form.ssh_password || undefined, device_type: form.device_type as import('../../types').DeviceType, diff --git a/frontend/src/components/devices/EditDeviceModal.tsx b/frontend/src/components/devices/EditDeviceModal.tsx index 5052e83..41b6fd9 100644 --- a/frontend/src/components/devices/EditDeviceModal.tsx +++ b/frontend/src/components/devices/EditDeviceModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, FormEvent } from 'react'; import { useQuery } from '@tanstack/react-query'; import { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react'; import { devicesApi } from '../../services/api'; +import { parsePort } from '../../utils/parsePort'; import type { Device, DeviceType, IpAddress } from '../../types'; interface Props { @@ -90,9 +91,9 @@ export default function EditDeviceModal({ device, onClose, onSuccess }: Props) { const payload: Record = { name: form.name, ip_address: form.ip_address, - api_port: parseInt(form.api_port, 10) || 8728, + api_port: parsePort(form.api_port, 8728), api_username: form.api_username, - ssh_port: parseInt(form.ssh_port, 10) || 22, + ssh_port: parsePort(form.ssh_port, 22), ssh_username: form.ssh_username || null, device_type: form.device_type as DeviceType, notes: form.notes, diff --git a/frontend/src/components/devices/TryAllDiscoveredModal.tsx b/frontend/src/components/devices/TryAllDiscoveredModal.tsx index dcff178..10198c9 100644 --- a/frontend/src/components/devices/TryAllDiscoveredModal.tsx +++ b/frontend/src/components/devices/TryAllDiscoveredModal.tsx @@ -1,13 +1,14 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { X, Loader2, CheckCircle, AlertCircle, KeyRound } from 'lucide-react'; import { credentialPresetsApi, devicesApi, + type BulkAddDeviceItem, type DiscoveredDevice, - type DuplicateSerialError, } from '../../services/api'; import type { DeviceType } from '../../types'; +import { parsePort } from '../../utils/parsePort'; interface Props { discoveredDevices: DiscoveredDevice[]; @@ -17,11 +18,6 @@ interface Props { type ResultItem = { ip: string; identity: string; ok: boolean; message: string }; -function isDuplicateSerialError(v: unknown): v is DuplicateSerialError { - if (!v || typeof v !== 'object') return false; - const obj = v as { error?: unknown; code?: unknown; existing_device?: unknown }; - return obj.error === 'duplicate_serial' && obj.code === 'duplicate_serial' && !!obj.existing_device; -} export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSuccess }: Props) { const [mode, setMode] = useState<'preset' | 'manual'>('preset'); const [presetId, setPresetId] = useState(null); @@ -37,13 +33,14 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu const [running, setRunning] = useState(false); const [results, setResults] = useState(null); const [cancelled, setCancelled] = useState(false); + const [jobId, setJobId] = useState(null); const [progress, setProgress] = useState<{ current: number; total: number; currentName: string }>({ current: 0, total: 0, currentName: '', }); const [error, setError] = useState(''); - const cancelRef = useRef(false); + const pollRef = useRef | null>(null); const { data: presets = [] } = useQuery({ queryKey: ['credential-presets'], @@ -56,11 +53,75 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu [discoveredDevices] ); + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + const buildItems = (): BulkAddDeviceItem[] => { + return targets.map((d) => { + const name = d.identity || d.address; + if (mode === 'preset') { + return { + name, + ip_address: d.address, + credential_preset_id: presetId!, + }; + } + return { + name, + ip_address: d.address, + api_username: manual.api_username, + api_password: manual.api_password, + api_port: parsePort(manual.api_port, 8728), + ssh_username: manual.ssh_username || undefined, + ssh_password: manual.ssh_password || undefined, + ssh_port: parsePort(manual.ssh_port, 22), + device_type: manual.device_type, + }; + }); + }; + + const pollOnce = async (id: string) => { + try { + const { data } = await devicesApi.bulkAddStatus(id); + const total = typeof data.total === 'number' ? data.total : 0; + const processed = typeof data.processed === 'number' ? data.processed : 0; + const currentName = typeof data.current_name === 'string' ? data.current_name : ''; + setProgress({ + current: Math.min(processed, total || 1), + total: total || targets.length, + currentName, + }); + if (Array.isArray(data.results)) { + setResults(data.results as ResultItem[]); + } + const st = String(data.status || ''); + if (st === 'completed' || st === 'failed' || st === 'cancelled') { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + setRunning(false); + setJobId(null); + if (st === 'cancelled') setCancelled(true); + if (st === 'failed' && data.error) { + setError(String(data.error)); + } + const ok = (data.results as ResultItem[] | undefined)?.filter((r) => r.ok).length ?? 0; + if (ok > 0) onSuccess(); + } + } catch { + // keep polling unless job missing + } + }; + const handleRun = async () => { setError(''); setCancelled(false); - cancelRef.current = false; setResults(null); + setJobId(null); if (!targets.length) { setError('No discovered devices with a usable IP address.'); return; @@ -76,52 +137,36 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu setRunning(true); setProgress({ current: 0, total: targets.length, currentName: '' }); - const out: ResultItem[] = []; - for (let i = 0; i < targets.length; i++) { - if (cancelRef.current) break; - const d = targets[i]; - const name = d.identity || d.address; - setProgress({ current: i + 1, total: targets.length, currentName: name }); - const payload = - mode === 'preset' - ? { - name, - ip_address: d.address, - credential_preset_id: presetId!, - } - : { - name, - ip_address: d.address, - api_username: manual.api_username, - api_password: manual.api_password, - api_port: parseInt(manual.api_port, 10) || 8728, - ssh_username: manual.ssh_username || undefined, - ssh_password: manual.ssh_password || undefined, - ssh_port: parseInt(manual.ssh_port, 10) || 22, - device_type: manual.device_type, - }; - try { - await devicesApi.create(payload); - out.push({ ip: d.address, identity: name, ok: true, message: 'Added' }); - setResults([...out]); - } catch (err: unknown) { - const data = (err as { response?: { data?: unknown } })?.response?.data; - const msg = isDuplicateSerialError(data) - ? `Duplicate serial mismatch: candidate ${data.candidate.serial_number}, existing ${data.existing_device.serial_number}. Review manually before combining.` - : ((data as { message?: string; error?: string } | undefined)?.message || - (typeof (data as { error?: unknown } | undefined)?.error === 'string' - ? String((data as { error?: string }).error) - : '') || - 'Failed'); - out.push({ ip: d.address, identity: name, ok: false, message: msg }); - setResults([...out]); + try { + const items = buildItems(); + const { data } = await devicesApi.bulkAddEnqueue(items); + const id = data.job_id; + setJobId(id); + setProgress({ current: 0, total: data.total, currentName: '' }); + await pollOnce(id); + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(() => { + void pollOnce(id); + }, 1200); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + 'Failed to start bulk add job'; + setError(msg); + setRunning(false); + } + }; + + const handleCancel = async () => { + if (jobId) { + try { + await devicesApi.bulkAddCancel(jobId); + } catch { + // ignore } } - setResults(out); - setRunning(false); - setProgress((p) => ({ ...p, currentName: '' })); - if (cancelRef.current) setCancelled(true); + setCancelled(true); }; const okCount = results?.filter((r) => r.ok).length ?? 0; @@ -140,12 +185,14 @@ export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSu

Attempt to add {targets.length} discovered device(s) in one run. + Progress runs on the server — you can leave this page and reopen later (job id is shown while running).