diff --git a/package-lock.json b/package-lock.json index a571e85f73..b36467e3c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "globals": "^17.6.0", "i18next-cli": "^1.50.3", "jsdom": "^26.1.0", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.3", "typescript": "^5.9.3", "typescript-eslint": "^8.59.2", @@ -227,6 +228,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1856,6 +1858,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1879,6 +1882,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3392,7 +3396,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3419,6 +3422,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -3454,6 +3458,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -3512,6 +3517,82 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.11", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz", + "integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -4561,8 +4642,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4714,6 +4794,7 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4733,6 +4814,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4744,6 +4826,7 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4786,6 +4869,7 @@ "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", @@ -4825,6 +4909,7 @@ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", @@ -5279,6 +5364,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5322,12 +5408,21 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -5676,6 +5771,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5711,8 +5807,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/call-bind": { "version": "1.0.8", @@ -5836,6 +5931,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -5925,6 +6027,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -6309,7 +6418,8 @@ "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -6411,8 +6521,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6728,6 +6837,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6788,6 +6898,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7826,6 +7937,7 @@ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7954,6 +8066,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inquirer": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.3.2.tgz", @@ -8499,6 +8624,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8523,6 +8658,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8757,7 +8893,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9175,6 +9310,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9455,6 +9642,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9483,7 +9671,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9498,7 +9685,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9510,8 +9696,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -9578,6 +9763,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9589,6 +9775,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9661,6 +9848,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9863,7 +10051,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-logger": { "version": "3.0.6", @@ -9989,6 +10178,16 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -10050,6 +10249,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10397,7 +10597,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -10408,7 +10607,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10784,8 +10982,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/tiny-case": { "version": "1.0.3", @@ -11060,6 +11257,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11215,6 +11413,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/urlcat": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/urlcat/-/urlcat-3.1.0.tgz", @@ -11339,6 +11544,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11511,6 +11717,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11858,6 +12065,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index f9f332dad6..9f023f3969 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "git", "url": "https://github.com/greenbone/gsa/" }, - "author": "Bj\u00f6rn Ricks ", + "author": "Björn Ricks ", "license": "AGPL-3.0+", "type": "module", "scripts": { @@ -30,7 +30,8 @@ "i18n-extract": "i18next-cli extract", "type-check": "tsc --noEmit", "type-check:watch": "tsc --noEmit --watch", - "clear:node_modules": "rm -rf node_modules && npm install" + "clear:node_modules": "rm -rf node_modules && npm install", + "generate:feed-key-types": "node scripts/generate-feed-key-types.ts" }, "engines": { "node": ">=22.0" @@ -111,6 +112,7 @@ "globals": "^17.6.0", "i18next-cli": "^1.50.3", "jsdom": "^26.1.0", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.3", "typescript": "^5.9.3", "typescript-eslint": "^8.59.2", @@ -119,4 +121,4 @@ "vite-plugin-svgr": "^5.2.0", "vitest": "^4.0.17" } -} \ No newline at end of file +} diff --git a/public/locales/gsa-de.json b/public/locales/gsa-de.json index 21cb776465..f0016d5b97 100644 --- a/public/locales/gsa-de.json +++ b/public/locales/gsa-de.json @@ -242,6 +242,7 @@ "Are you certain you want to permanently remove the agent \"{{name}}\"?\nThis operation is irreversible.\nAfter removal, reinstalling the agent will be necessary.": "Sind Sie sicher, dass Sie den Agenten „{{name}}“ dauerhaft entfernen möchten?\nDieser Vorgang ist unwiderruflich.\nNach der Entfernung muss der Agent erneut installiert werden.", "Are you sure you want to authorize all selected items?": "Sind Sie sicher, dass Sie alle ausgewählten Elemente autorisieren möchten?", "Are you sure you want to delete all items on this page?\nThis action cannot be undone.": "Sind Sie sicher, dass Sie alle Objekte auf dieser Seite löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden.", + "Are you sure you want to delete the feed key? This action cannot be undone.": "Sind Sie sicher, dass Sie den Feed-Schlüssel löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "Are you sure you want to empty the trash?": "Sind Sie sicher, dass Sie den Pap,ierkorb leeren möchten?", "Are you sure you want to move all items on this page to the trashcan?": "Sind Sie sicher, dass Sie alle Objekte auf dieser Seite in den Papierkorb verschieben möchten?", "Are you sure you want to revoke all selected items?": "Sind Sie sicher, dass Sie alle ausgewählten Elemente widerrufen möchten?", @@ -654,7 +655,9 @@ "Delete": "Löschen", "Delete {{entity}}": "{{entity}} löschen", "Delete all filtered": "Gesamte Filterauswahl löschen", + "Delete Feed Key": "Feed-Schlüssel löschen", "Delete Identifier": "Identifikator löschen", + "Delete Key": "Schlüssel löschen", "Delete page contents": "Seiteninhalt löschen", "Delete Port Range": "Portbereich löschen", "Delete Report": "Bericht löschen", @@ -911,16 +914,21 @@ "Failed to copy checksum for agent installer {{name}} {{version}} to clipboard.": "Agent-Installationsprogramm-Prüfsumme für {{name}} {{version}} konnte nicht in die Zwischenablage kopiert werden.", "Failed to create a new Target because the default Port List is not available.": "Ein neues Ziel konnte nicht erstellt werden, da die Standard-Portliste nicht verfügbar ist.", "Failed to create a new Task because the default Scan Config is not available.": "Ein neue Aufgabe konnte nicht erstellt werden, da die Standard-Scan-Konfiguration nicht verfügbar ist.", + "Failed to delete key: {{error}}": "Fehler beim Löschen des Schlüssels: {{error}}", "Failed to load {{type}} from trashcan": "{{type}} konnte nicht aus dem Papierkorb geladen werden", "Failed to run alert.": "Ausführung der Benachrichtigung ist fehlgeschlagen.", "Failed to save filter setting": "Fehler beim Speichern der Filtereinstellung", + "Failed to upload key: {{error}}": "Fehler beim Hochladen des Schlüssels: {{error}}", "False Pos.": "Falsch-Positiv", "False Positive": "Falsch-Positiv", "Families: Total": "Familien: Gesamt", "Families: Trend": "Familien: Trend", "Family": "Familie", + "Feed Configuration": "Feed-Konfiguration", "Feed is currently syncing.": "Der Feed wird derzeit synchronisiert.", "Feed is currently syncing. Please try again later.": "Der Feed wird derzeit synchronisiert. Bitte versuchen Sie es später erneut.", + "Feed Key": "Feed-Schlüssel", + "Feed Key Active": "Feed-Schlüssel aktiv", "Feed Status": "Feed-Status", "Feeds": "Feeds", "File path": "Dateipfad", @@ -1009,7 +1017,7 @@ "Help: CVSS Base Score Calculator": "Hilfe: CVSS-Basis-Score-Rechner", "Help: Dashboards": "Hilfe: Dashboards", "Help: DFN-CERT Advisories": "Hilfe: DFN-CERT-Advisories", - "Help: Feed Status": "Hilfe: Feed-Status", + "Help: Feed Configuration": "Hilfe: Feed-Konfiguration", "Help: Filters": "Hilfe: Filter", "Help: Groups": "Hilfe: Gruppen", "Help: Hosts": "Hilfe: Hosts", @@ -1124,6 +1132,7 @@ "Interrupted": "Unterbrochen", "Interval (seconds)": "Intervall (Sekunden)", "Invalid kdc value(s)": "Ungültiger KDC-Wert(e)", + "Invalid key file": "Ungültige Schlüsseldatei", "Invalid or no date available": "Ungültiges oder kein Datum verfügbar", "IP": "IP", "IP Address": "IP-Adresse", @@ -1139,8 +1148,11 @@ "Issuer DN": "Aussteller DN", "Items": "Objekte", "Just wait for results to arrive.": "Bitte warten Sie auf Ergebnisse.", + "Key deleted successfully": "Schlüssel erfolgreich gelöscht", "Key Distribution Center": "Key Distribution Center", + "Key File": "Schlüsseldatei", "Key Type": "Schlüsseltyp", + "Key uploaded successfully": "Schlüssel erfolgreich hochgeladen", "Known Hosts": "Bekannte Hosts", "Last": "Letzte", "Last Contact": "Letzter Kontakt", @@ -1339,6 +1351,7 @@ "No DFN-CERT Advisories available": "Keine DFN-CERT-Advisories vorhanden", "No Displays": "Keine Anzeigen", "No Errors available": "Keine Fehler vorhanden", + "No Feed Key Configured": "Kein Feed-Schlüssel konfiguriert", "No filters available": "Keine Filter vorhanden", "No first result available.": "Kein erstes Ergebnis verfügbar.", "No Groups available": "Keine Gruppen vorhanden", @@ -1547,7 +1560,9 @@ "Please note that assigning a tag to {{count}} items may take several minutes.": "Bitte beachten Sie, dass es einige Minuten dauern kann, einen Tag {{count}} Objekten zuzuordnen.", "Please note: You are about to change your own personal user data as Super Admin! It is not possible to change the login name. If you have modified the login name, neither the login name nor any other changes made will be saved. If you have made any modifications other than the login name, the data will be saved when clicking OK, and you will be logged out immediately.": "Bitte beachten Sie: Sie sind dabei Ihre eigenen persönlichen Nutzerdaten als Super Administrator zu ändern! Es ist nicht möglich, den Loginnamen zu ändern. Wenn Sie den Loginnamen modifiziert haben, werden weder der Loginname noch jedwede andere Änderungen gespeichert. Wenn Sie andere Änderungen vorgenommen haben, werden die Daten beim Klick auf OK gespeichert und Sie werden umgehend ausgeloggt.", "Please note: You are about to create a user without a role. This user will not have any permissions and as a result will not be able to login.": "Bitte beachten Sie: Sie sind dabei einen Benutzer ohne Rolle zu erstellen. Dieser Benutzer wird keine Berechtigungen haben und deshalb nicht in der Lage sein, sich einzuloggen.", + "Please select a key file to upload": "Bitte wählen Sie eine Schlüsseldatei zum Hochladen aus", "Please try again.": "Bitte versuchen Sie es erneut.", + "Please upload your feed key file provided by Greenbone. The file should be in .pem or .key format.": "Bitte laden Sie Ihre von Greenbone bereitgestellte Feed-Schlüsseldatei hoch. Die Datei sollte im .pem- oder .key-Format vorliegen.", "Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the": "Bitte warten Sie, während der Feed synchronisiert wird. Scans sind während dieser Zeit nicht verfügbar. Für weitere Informationen besuchen Sie die", "POC": "POC", "Policies": "Richtlinien", @@ -1824,6 +1839,7 @@ "Select all NVTs": "Alle NVTs auswählen", "Select and edit NVT details": "NVT-Details auswählen und bearbeiten", "Select Filter": "Filter auswählen", + "Select key file (.pem or .key)": "Schlüsseldatei auswählen (.pem oder .key)", "Select Report for delta comparison": "Bericht für Delta-Vergleich auswählen", "Select Resource": "Resource auswählen", "Selected": "Ausgewählt", @@ -2217,9 +2233,13 @@ "Updated": "Aktualisiert", "Updated {{secinfo_type}} arrived": "Aktualisierte {{secinfo_type}} eingetroffen", "Updated: ": "Aktualisiert: ", + "Upload": "Hochladen", + "Upload a feed key to enable Enterprise Feed features and content updates.": "", "Upload CA Certificate": "CA-Zertifikat hochladen", "Upload client certificate (.pem, .crt)": "Client-Zertifikat hochladen (.pem, .crt)", + "Upload Feed Key": "Feed-Schlüssel hochladen", "Upload file": "Datei hochladen", + "Upload Key": "Schlüssel hochladen", "Upload PKCS#12 file (.p12, .pfx) ": "PKCS#12-Datei hochladen (.p12, .pfx) ", "Upload private key (.pem, .key)": "Privaten Schlüssel hochladen (.pem, .key)", "Upload report": "Bericht hochladen", @@ -2333,6 +2353,7 @@ "You have been inactive, your session will expire in 3 minutes": "Sie waren inaktiv, Ihre Sitzung wird in 3 Minuten ablaufen", "You should change the Alive Test Method of the target for the next scan. However, if the target hosts are indeed dead, the scan duration might increase significantly.": "Sie sollten die Methode des Erreichbarkeitstests des Ziels für den nächsten Scan ändern. Sollten die Ziel-Hosts tatsächlich unerreichbar sein, könnte sich die Scan-Dauer erheblich verlängern.", "Your changes have been saved.": "Ihre Änderungen wurden gespeichert.", + "Your feed key is properly configured and active. Enterprise Feed features are available.": "Ihr Feed-Schlüssel ist korrekt konfiguriert und aktiv. Enterprise-Feed-Funktionen sind verfügbar.", "Your filter settings may be too refined.": "Ihre Filtereinstellungen könnten zu präzise sein.", "Your filter settings may be too unrefined.": "Ihre Filtereinstellungen könnten zu unpräzise sein.", "Your Greenbone Enterprise License has expired {{days}} days ago!": "Ihre Greenbone Enterprise License ist vor {{days}} Tagen abgelaufen!", diff --git a/public/locales/gsa-en.json b/public/locales/gsa-en.json index 85dc68b277..5354d530a2 100644 --- a/public/locales/gsa-en.json +++ b/public/locales/gsa-en.json @@ -242,6 +242,7 @@ "Are you certain you want to permanently remove the agent \"{{name}}\"?\nThis operation is irreversible.\nAfter removal, reinstalling the agent will be necessary.": "Are you certain you want to permanently remove the agent \"{{name}}\"?\nThis operation is irreversible.\nAfter removal, reinstalling the agent will be necessary.", "Are you sure you want to authorize all selected items?": "Are you sure you want to authorize all selected items?", "Are you sure you want to delete all items on this page?\nThis action cannot be undone.": "Are you sure you want to delete all items on this page?\nThis action cannot be undone.", + "Are you sure you want to delete the feed key? This action cannot be undone.": "", "Are you sure you want to empty the trash?": "Are you sure you want to empty the trash?", "Are you sure you want to move all items on this page to the trashcan?": "Are you sure you want to move all items on this page to the trashcan?", "Are you sure you want to revoke all selected items?": "Are you sure you want to revoke all selected items?", @@ -654,7 +655,9 @@ "Delete": "Delete", "Delete {{entity}}": "Delete {{entity}}", "Delete all filtered": "Delete all filtered", + "Delete Feed Key": "", "Delete Identifier": "Delete Identifier", + "Delete Key": "", "Delete page contents": "Delete page contents", "Delete Port Range": "Delete Port Range", "Delete Report": "Delete Report", @@ -911,16 +914,21 @@ "Failed to copy checksum for agent installer {{name}} {{version}} to clipboard.": "Failed to copy checksum for agent installer {{name}} {{version}} to clipboard.", "Failed to create a new Target because the default Port List is not available.": "Failed to create a new Target because the default Port List is not available.", "Failed to create a new Task because the default Scan Config is not available.": "Failed to create a new Task because the default Scan Config is not available.", + "Failed to delete key: {{error}}": "", "Failed to load {{type}} from trashcan": "Failed to load {{type}} from trashcan", "Failed to run alert.": "Failed to run alert.", "Failed to save filter setting": "Failed to save filter setting", + "Failed to upload key: {{error}}": "", "False Pos.": "False Pos.", "False Positive": "False Positive", "Families: Total": "Families: Total", "Families: Trend": "Families: Trend", "Family": "Family", + "Feed Configuration": "", "Feed is currently syncing.": "Feed is currently syncing.", "Feed is currently syncing. Please try again later.": "Feed is currently syncing. Please try again later.", + "Feed Key": "", + "Feed Key Active": "", "Feed Status": "Feed Status", "Feeds": "Feeds", "File path": "File path", @@ -1009,7 +1017,7 @@ "Help: CVSS Base Score Calculator": "Help: CVSS Base Score Calculator", "Help: Dashboards": "Help: Dashboards", "Help: DFN-CERT Advisories": "Help: DFN-CERT Advisories", - "Help: Feed Status": "Help: Feed Status", + "Help: Feed Configuration": "", "Help: Filters": "Help: Filters", "Help: Groups": "Help: Groups", "Help: Hosts": "Help: Hosts", @@ -1124,6 +1132,7 @@ "Interrupted": "Interrupted", "Interval (seconds)": "", "Invalid kdc value(s)": "Invalid kdc value(s)", + "Invalid key file": "", "Invalid or no date available": "Invalid or no date available", "IP": "IP", "IP Address": "IP Address", @@ -1139,8 +1148,11 @@ "Issuer DN": "Issuer DN", "Items": "Items", "Just wait for results to arrive.": "Just wait for results to arrive.", + "Key deleted successfully": "", "Key Distribution Center": "Key Distribution Center", + "Key File": "", "Key Type": "Key Type", + "Key uploaded successfully": "", "Known Hosts": "Known Hosts", "Last": "Last", "Last Contact": "Last Contact", @@ -1339,6 +1351,7 @@ "No DFN-CERT Advisories available": "No DFN-CERT Advisories available", "No Displays": "No Displays", "No Errors available": "No Errors available", + "No Feed Key Configured": "", "No filters available": "No filters available", "No first result available.": "No first result available.", "No Groups available": "No Groups available", @@ -1547,7 +1560,9 @@ "Please note that assigning a tag to {{count}} items may take several minutes.": "Please note that assigning a tag to {{count}} items may take several minutes.", "Please note: You are about to change your own personal user data as Super Admin! It is not possible to change the login name. If you have modified the login name, neither the login name nor any other changes made will be saved. If you have made any modifications other than the login name, the data will be saved when clicking OK, and you will be logged out immediately.": "Please note: You are about to change your own personal user data as Super Admin! It is not possible to change the login name. If you have modified the login name, neither the login name nor any other changes made will be saved. If you have made any modifications other than the login name, the data will be saved when clicking OK, and you will be logged out immediately.", "Please note: You are about to create a user without a role. This user will not have any permissions and as a result will not be able to login.": "Please note: You are about to create a user without a role. This user will not have any permissions and as a result will not be able to login.", + "Please select a key file to upload": "", "Please try again.": "Please try again.", + "Please upload your feed key file provided by Greenbone. The file should be in .pem or .key format.": "", "Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the": "Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the", "POC": "POC", "Policies": "Policies", @@ -1824,6 +1839,7 @@ "Select all NVTs": "Select all NVTs", "Select and edit NVT details": "Select and edit NVT details", "Select Filter": "Select Filter", + "Select key file (.pem or .key)": "", "Select Report for delta comparison": "Select Report for delta comparison", "Select Resource": "Select Resource", "Selected": "Selected", @@ -2217,9 +2233,13 @@ "Updated": "Updated", "Updated {{secinfo_type}} arrived": "Updated {{secinfo_type}} arrived", "Updated: ": "Updated: ", + "Upload": "", + "Upload a feed key to enable Enterprise Feed features and content updates.": "", "Upload CA Certificate": "Upload CA Certificate", "Upload client certificate (.pem, .crt)": "Upload client certificate (.pem, .crt)", + "Upload Feed Key": "", "Upload file": "Upload file", + "Upload Key": "", "Upload PKCS#12 file (.p12, .pfx) ": "Upload PKCS#12 file (.p12, .pfx) ", "Upload private key (.pem, .key)": "Upload private key (.pem, .key)", "Upload report": "Upload report", @@ -2333,6 +2353,7 @@ "You have been inactive, your session will expire in 3 minutes": "You have been inactive, your session will expire in 3 minutes", "You should change the Alive Test Method of the target for the next scan. However, if the target hosts are indeed dead, the scan duration might increase significantly.": "You should change the Alive Test Method of the target for the next scan. However, if the target hosts are indeed dead, the scan duration might increase significantly.", "Your changes have been saved.": "Your changes have been saved.", + "Your feed key is properly configured and active. Enterprise Feed features are available.": "", "Your filter settings may be too refined.": "Your filter settings may be too refined.", "Your filter settings may be too unrefined.": "Your filter settings may be too unrefined.", "Your Greenbone Enterprise License has expired {{days}} days ago!": "Your Greenbone Enterprise License has expired {{days}} days ago!", diff --git a/public/locales/gsa-zh_CN.json b/public/locales/gsa-zh_CN.json index 8cee8cbcd4..092bb146e9 100644 --- a/public/locales/gsa-zh_CN.json +++ b/public/locales/gsa-zh_CN.json @@ -242,6 +242,7 @@ "Are you certain you want to permanently remove the agent \"{{name}}\"?\nThis operation is irreversible.\nAfter removal, reinstalling the agent will be necessary.": "您确定要永久删除代理“{{name}}”吗?此操作不可逆。删除后需要重新安装该代理。", "Are you sure you want to authorize all selected items?": "您确定要授权所有选中的项目吗?", "Are you sure you want to delete all items on this page?\nThis action cannot be undone.": "您确定要删除此页面上的所有项目吗?\n此操作无法撤销。", + "Are you sure you want to delete the feed key? This action cannot be undone.": "", "Are you sure you want to empty the trash?": "您确定要清空回收站吗?", "Are you sure you want to move all items on this page to the trashcan?": "您确定要将此页面上的所有项目移至回收站吗?", "Are you sure you want to revoke all selected items?": "您确定要吊销所有选中的项目吗?", @@ -654,7 +655,9 @@ "Delete": "删除", "Delete {{entity}}": "删除{{entity}}", "Delete all filtered": "删除所有筛选项目", + "Delete Feed Key": "", "Delete Identifier": "删除标识符", + "Delete Key": "", "Delete page contents": "删除当前页", "Delete Port Range": "删除端口范围", "Delete Report": "删除报告", @@ -911,16 +914,21 @@ "Failed to copy checksum for agent installer {{name}} {{version}} to clipboard.": "无法复制代理安装程序 {{name}} {{version}} 的校验和到剪贴板。", "Failed to create a new Target because the default Port List is not available.": "无法创建新目标,因为默认端口列表不可用。", "Failed to create a new Task because the default Scan Config is not available.": "无法创建新任务,因为默认扫描配置不可用。", + "Failed to delete key: {{error}}": "", "Failed to load {{type}} from trashcan": "无法从回收站加载{{type}}", "Failed to run alert.": "运行告警失败.", "Failed to save filter setting": "保存筛选设置失败", + "Failed to upload key: {{error}}": "", "False Pos.": "误报", "False Positive": "误报", "Families: Total": "系列: 总数", "Families: Trend": "系列: 趋势", "Family": "系列", + "Feed Configuration": "", "Feed is currently syncing.": "信息源当前正在同步。", "Feed is currently syncing. Please try again later.": "信息源当前正在同步。请稍后重试。", + "Feed Key": "", + "Feed Key Active": "", "Feed Status": "特征库状态", "Feeds": "Feeds", "File path": "文件路径", @@ -1009,7 +1017,7 @@ "Help: CVSS Base Score Calculator": "帮助: CVSS 评分计算", "Help: Dashboards": "帮助: 仪表盘", "Help: DFN-CERT Advisories": "帮助: DFN-CERT 公告", - "Help: Feed Status": "帮助: 特征库状态", + "Help: Feed Configuration": "", "Help: Filters": "帮助: 筛选器", "Help: Groups": "帮助: 主机组", "Help: Hosts": "帮助: 主机", @@ -1124,6 +1132,7 @@ "Interrupted": "中断", "Interval (seconds)": "", "Invalid kdc value(s)": "无效的 KDC 值", + "Invalid key file": "", "Invalid or no date available": "日期无效或不可用", "IP": "IP", "IP Address": "IP地址", @@ -1139,8 +1148,11 @@ "Issuer DN": "颁发者DN", "Items": "项目", "Just wait for results to arrive.": "只需等待结果到来.", + "Key deleted successfully": "", "Key Distribution Center": "密钥分发中心", + "Key File": "", "Key Type": "密钥类型", + "Key uploaded successfully": "", "Known Hosts": "已知主机", "Last": "尾页", "Last Contact": "上次联系", @@ -1339,6 +1351,7 @@ "No DFN-CERT Advisories available": "没有可用的DFN-CERT Advisories", "No Displays": "无显示", "No Errors available": "没有可用的错误消息", + "No Feed Key Configured": "", "No filters available": "没有可用的筛选", "No first result available.": "没有首选结果可用.", "No Groups available": "没有可用的群组", @@ -1547,7 +1560,9 @@ "Please note that assigning a tag to {{count}} items may take several minutes.": "请注意,为{{count}}个项目分配标签可能需要几分钟。", "Please note: You are about to change your own personal user data as Super Admin! It is not possible to change the login name. If you have modified the login name, neither the login name nor any other changes made will be saved. If you have made any modifications other than the login name, the data will be saved when clicking OK, and you will be logged out immediately.": "请注意: 您将以超级管理员身份更改自己的个人用户数据!无法更改登录名.如果您修改了登录名,则不会保存登录名或任何其他更改.如果您进行了登录名以外的任何修改,则单击确定时将保存数据,并且您将立即注销.", "Please note: You are about to create a user without a role. This user will not have any permissions and as a result will not be able to login.": "请注意: 您将要创建一个没有角色的用户.此用户将没有任何权限,因此将无法登录.", + "Please select a key file to upload": "", "Please try again.": "请重试.", + "Please upload your feed key file provided by Greenbone. The file should be in .pem or .key format.": "", "Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the": "请等待信息源同步。在此期间扫描不可用。更多信息,请访问:", "POC": "概念验证", "Policies": "策略", @@ -1824,6 +1839,7 @@ "Select all NVTs": "选择所有NVTs", "Select and edit NVT details": "选择并编辑NVT详情", "Select Filter": "选择筛选", + "Select key file (.pem or .key)": "", "Select Report for delta comparison": "对比其他报告", "Select Resource": "选择资源", "Selected": "选择", @@ -2217,9 +2233,13 @@ "Updated": "已更新", "Updated {{secinfo_type}} arrived": "{{secinfo_type}}有更新", "Updated: ": "更新: ", + "Upload": "", + "Upload a feed key to enable Enterprise Feed features and content updates.": "", "Upload CA Certificate": "上传CA证书", "Upload client certificate (.pem, .crt)": "上传客户端证书(.pem, .crt)", + "Upload Feed Key": "", "Upload file": "上传文件", + "Upload Key": "", "Upload PKCS#12 file (.p12, .pfx) ": "上传PKCS#12文件(.p12, .pfx)", "Upload private key (.pem, .key)": "上传私钥(.pem, .key)", "Upload report": "上传报告", @@ -2333,6 +2353,7 @@ "You have been inactive, your session will expire in 3 minutes": "您已长时间未操作,会话将在3分钟后过期", "You should change the Alive Test Method of the target for the next scan. However, if the target hosts are indeed dead, the scan duration might increase significantly.": "您应该为下一次扫描更改目标的\"存活检测\".但是,如果目标主机确实已关闭,则扫描持续时间可能会显著增加.", "Your changes have been saved.": "您的更改已保存。", + "Your feed key is properly configured and active. Enterprise Feed features are available.": "", "Your filter settings may be too refined.": "您的筛选器设置可能过于精细.", "Your filter settings may be too unrefined.": "您的筛选器设置可能过于不完善.", "Your Greenbone Enterprise License has expired {{days}} days ago!": "您的Greenbone企业许可证已过期 {{days}} 天前!", diff --git a/public/locales/gsa-zh_TW.json b/public/locales/gsa-zh_TW.json index 3088580f01..a13e8fdb14 100644 --- a/public/locales/gsa-zh_TW.json +++ b/public/locales/gsa-zh_TW.json @@ -242,6 +242,7 @@ "Are you certain you want to permanently remove the agent \"{{name}}\"?\nThis operation is irreversible.\nAfter removal, reinstalling the agent will be necessary.": "", "Are you sure you want to authorize all selected items?": "", "Are you sure you want to delete all items on this page?\nThis action cannot be undone.": "", + "Are you sure you want to delete the feed key? This action cannot be undone.": "", "Are you sure you want to empty the trash?": "", "Are you sure you want to move all items on this page to the trashcan?": "", "Are you sure you want to revoke all selected items?": "", @@ -654,7 +655,9 @@ "Delete": "刪除", "Delete {{entity}}": "", "Delete all filtered": "", + "Delete Feed Key": "", "Delete Identifier": "", + "Delete Key": "", "Delete page contents": "", "Delete Port Range": "", "Delete Report": "刪除報告", @@ -911,16 +914,21 @@ "Failed to copy checksum for agent installer {{name}} {{version}} to clipboard.": "", "Failed to create a new Target because the default Port List is not available.": "", "Failed to create a new Task because the default Scan Config is not available.": "", + "Failed to delete key: {{error}}": "", "Failed to load {{type}} from trashcan": "", "Failed to run alert.": "", "Failed to save filter setting": "", + "Failed to upload key: {{error}}": "", "False Pos.": "", "False Positive": "", "Families: Total": "", "Families: Trend": "", "Family": "", + "Feed Configuration": "", "Feed is currently syncing.": "", "Feed is currently syncing. Please try again later.": "", + "Feed Key": "", + "Feed Key Active": "", "Feed Status": "摘要狀態", "Feeds": "摘要", "File path": "檔案路徑", @@ -1009,7 +1017,7 @@ "Help: CVSS Base Score Calculator": "", "Help: Dashboards": "資訊看板", "Help: DFN-CERT Advisories": "", - "Help: Feed Status": "", + "Help: Feed Configuration": "", "Help: Filters": "說明:篩選器", "Help: Groups": "說明:群組", "Help: Hosts": "說明:主機", @@ -1124,6 +1132,7 @@ "Interrupted": "", "Interval (seconds)": "", "Invalid kdc value(s)": "", + "Invalid key file": "", "Invalid or no date available": "", "IP": "IP", "IP Address": "IP 位址", @@ -1139,8 +1148,11 @@ "Issuer DN": "簽發者", "Items": "項目", "Just wait for results to arrive.": "", + "Key deleted successfully": "", "Key Distribution Center": "", + "Key File": "", "Key Type": "", + "Key uploaded successfully": "", "Known Hosts": "已知主機", "Last": "", "Last Contact": "", @@ -1339,6 +1351,7 @@ "No DFN-CERT Advisories available": "", "No Displays": "", "No Errors available": "沒有可用的錯誤", + "No Feed Key Configured": "", "No filters available": "沒有可用的篩選器", "No first result available.": "", "No Groups available": "沒有可用的群組", @@ -1547,7 +1560,9 @@ "Please note that assigning a tag to {{count}} items may take several minutes.": "", "Please note: You are about to change your own personal user data as Super Admin! It is not possible to change the login name. If you have modified the login name, neither the login name nor any other changes made will be saved. If you have made any modifications other than the login name, the data will be saved when clicking OK, and you will be logged out immediately.": "", "Please note: You are about to create a user without a role. This user will not have any permissions and as a result will not be able to login.": "", + "Please select a key file to upload": "", "Please try again.": "", + "Please upload your feed key file provided by Greenbone. The file should be in .pem or .key format.": "", "Please wait while the feed is syncing. Scans are not available during this time. For more information, visit the": "", "POC": "", "Policies": "", @@ -1824,6 +1839,7 @@ "Select all NVTs": "", "Select and edit NVT details": "", "Select Filter": "", + "Select key file (.pem or .key)": "", "Select Report for delta comparison": "", "Select Resource": "", "Selected": "已選擇", @@ -2217,9 +2233,13 @@ "Updated": "已更新", "Updated {{secinfo_type}} arrived": "", "Updated: ": "已更新:", + "Upload": "", + "Upload a feed key to enable Enterprise Feed features and content updates.": "", "Upload CA Certificate": "", "Upload client certificate (.pem, .crt)": "", + "Upload Feed Key": "", "Upload file": "", + "Upload Key": "", "Upload PKCS#12 file (.p12, .pfx) ": "", "Upload private key (.pem, .key)": "", "Upload report": "上傳報告", @@ -2333,6 +2353,7 @@ "You have been inactive, your session will expire in 3 minutes": "", "You should change the Alive Test Method of the target for the next scan. However, if the target hosts are indeed dead, the scan duration might increase significantly.": "", "Your changes have been saved.": "", + "Your feed key is properly configured and active. Enterprise Feed features are available.": "", "Your filter settings may be too refined.": "", "Your filter settings may be too unrefined.": "", "Your Greenbone Enterprise License has expired {{days}} days ago!": "", diff --git a/scripts/generate-feed-key-types.ts b/scripts/generate-feed-key-types.ts new file mode 100755 index 0000000000..8960c4234a --- /dev/null +++ b/scripts/generate-feed-key-types.ts @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {execFileSync} from 'node:child_process'; +import {readFileSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const __filename = fileURLToPath(import.meta.url); +// eslint-disable-next-line @typescript-eslint/naming-convention +const __dirname = dirname(__filename); + +const OPENAPI_URL = + 'http://127.0.0.1:9392/service/feed-key/api/v1/openapi.json'; +const OUTPUT_FILE = join( + __dirname, + '..', + 'src', + 'gmp', + 'commands', + 'feed-key-types.ts', +); + +const YEAR = new Date().getFullYear(); + +const CUSTOM_HEADER = `/* SPDX-FileCopyrightText: ${YEAR} Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ +`; + +try { + console.info('Generating TypeScript types from OpenAPI spec...'); + + // Generate the types + execFileSync( + 'node_modules/.bin/openapi-typescript', + [OPENAPI_URL, '-o', OUTPUT_FILE], + { + stdio: 'inherit', + }, + ); + + // Read the generated file + let content = readFileSync(OUTPUT_FILE, 'utf8'); + + // Remove the default header (first comment block) + content = content.replace(/^\/\*\*[\s\S]*?\*\/\s*\n/, ''); + + // Transform all exported types to PascalCase for code conventions + // This general approach works regardless of what types openapi-typescript generates + + /** + * Helper function to convert to PascalCase + */ + const toPascalCase = (str: string): string => { + // Remove $ prefix if present (e.g., $defs -> Defs) + str = str.replace(/^\$/, ''); + // Handle already PascalCase names + if (/^[A-Z]/.test(str)) { + return str; + } + // Convert snake_case or kebab-case to PascalCase + return str + .split(/[_-]/) + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + }; + + // Collect all exported type/interface names and their PascalCase versions + const typeMap: Map = new Map(); + + // Find all "export interface name {" and "export type name =" + const exportPattern = /export\s+(interface|type)\s+(\$?[a-z_$]\w*)\s*[={]/g; + let match: RegExpExecArray | null; + + while ((match = exportPattern.exec(content)) !== null) { + const originalName = match[2]; + const pascalName = toPascalCase(originalName); + if (originalName !== pascalName) { + typeMap.set(originalName, pascalName); + } + } + + // Replace all exported declarations + for (const [original, pascal] of typeMap.entries()) { + // Escape special regex characters in original name + const escapedOriginal = original.replaceAll('$', String.raw`\$`); + // Replace export declarations + content = content.replaceAll( + new RegExp( + String.raw`export\s+(interface|type)\s+${escapedOriginal}\s*([={])`, + 'g', + ), + `export $1 ${pascal} $2`, + ); + } + + // Replace all references to renamed types + for (const [original, pascal] of typeMap.entries()) { + const escapedOriginal = original.replaceAll('$', String.raw`\$`); + // Replace references like: originalName["something"] + content = content.replaceAll( + new RegExp(String.raw`\b${escapedOriginal}\["`, 'g'), + `${pascal}["`, + ); + // Replace references like: : originalName[ + content = content.replaceAll( + new RegExp(String.raw`:\s*${escapedOriginal}\[`, 'g'), + `: ${pascal}[`, + ); + } + + // Prepend our custom header + content = CUSTOM_HEADER + '\n' + content; + + // Write the file back + writeFileSync(OUTPUT_FILE, content, 'utf8'); + + // Run prettier to format the file according to project conventions + console.info('Running prettier to format the generated file...'); + execFileSync('node_modules/.bin/prettier', ['--write', OUTPUT_FILE], { + stdio: 'inherit', + }); + + console.info('✅ Types generated successfully with custom header!'); +} catch (error) { + console.error( + '❌ Error generating types:', + error instanceof Error ? error.message : String(error), + ); + process.exit(1); +} diff --git a/src/gmp/capabilities/features.ts b/src/gmp/capabilities/features.ts index 208128da15..f0d01d39e3 100644 --- a/src/gmp/capabilities/features.ts +++ b/src/gmp/capabilities/features.ts @@ -12,7 +12,8 @@ export type Feature = | 'ENABLE_AGENTS' | 'ENABLE_CONTAINER_SCANNING' | 'ENABLE_CREDENTIAL_STORES' - | 'ENABLE_SECURITY_INTELLIGENCE_EXPORT'; + | 'ENABLE_SECURITY_INTELLIGENCE_EXPORT' + | 'ENABLE_FEED_KEY_SERVICE'; class Features { private readonly _features: Set; diff --git a/src/gmp/commands/__tests__/feed-key.test.ts b/src/gmp/commands/__tests__/feed-key.test.ts new file mode 100644 index 0000000000..df33b334a9 --- /dev/null +++ b/src/gmp/commands/__tests__/feed-key.test.ts @@ -0,0 +1,198 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + describe, + test, + expect, + testing, + beforeEach, + afterEach, +} from '@gsa/testing'; +import FeedKeyCommand from 'gmp/commands/feed-key'; +import {createHttp} from 'gmp/commands/testing'; +import type Settings from 'gmp/settings'; + +const mockSettings = {} as Settings; +const createGetJwt = (jwt?: string) => () => jwt; + +describe('FeedKeyCommand', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('get', () => { + test('should return key data when key exists', async () => { + const mockResponse = {status: 'success', message: 'Key found'}; + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + const result = await cmd.get(); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/service/feed-key/api/v1/key', + { + headers: {Authorization: 'Bearer test-jwt-token'}, + }, + ); + }); + + test('should throw error when request fails', async () => { + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + await expect(cmd.get()).rejects.toThrow('Request failed: 500'); + }); + + test('should throw error when not authenticated', async () => { + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt()); + + await expect(cmd.get()).rejects.toThrow('Not authenticated'); + }); + }); + + describe('delete', () => { + test('should delete the feed key', async () => { + const mockResponse = { + message: 'Key deleted successfully', + status: 'success', + }; + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + const result = await cmd.delete(); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/service/feed-key/api/v1/key', + { + method: 'DELETE', + headers: {Authorization: 'Bearer test-jwt-token'}, + }, + ); + }); + + test('should throw error with server message on failure', async () => { + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({message: 'Permission denied'}), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + await expect(cmd.delete()).rejects.toThrow('Permission denied'); + }); + + test('should throw default message when server provides none', async () => { + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({}), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + await expect(cmd.delete()).rejects.toThrow('Key deletion failed'); + }); + + test('should throw error when not authenticated', async () => { + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt()); + + await expect(cmd.delete()).rejects.toThrow('Not authenticated'); + }); + }); + + describe('save', () => { + test('should upload a feed key file', async () => { + const mockResponse = { + message: 'Key uploaded successfully', + status: 'success', + }; + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + const file = new File(['key-content'], 'feed.pem', { + type: 'application/x-pem-file', + }); + const result = await cmd.save(file); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/service/feed-key/api/v1/key', + expect.objectContaining({ + method: 'POST', + headers: {Authorization: 'Bearer test-jwt-token'}, + }), + ); + }); + + test('should throw error with server message on failure', async () => { + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({message: 'Invalid key data'}), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + const file = new File(['bad-content'], 'feed.pem'); + + await expect(cmd.save(file)).rejects.toThrow('Invalid key data'); + }); + + test('should throw default message when server provides none', async () => { + globalThis.fetch = testing.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({}), + }); + + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt('test-jwt-token')); + + const file = new File(['bad-content'], 'feed.pem'); + + await expect(cmd.save(file)).rejects.toThrow('Key save failed'); + }); + + test('should throw error when not authenticated', async () => { + const fakeHttp = createHttp({}); + const cmd = new FeedKeyCommand(fakeHttp, mockSettings, createGetJwt()); + + const file = new File(['content'], 'feed.pem'); + + await expect(cmd.save(file)).rejects.toThrow('Not authenticated'); + }); + }); +}); diff --git a/src/gmp/commands/__tests__/user.test.ts b/src/gmp/commands/__tests__/user.test.ts index a4c4a16404..c72ba9b7c2 100644 --- a/src/gmp/commands/__tests__/user.test.ts +++ b/src/gmp/commands/__tests__/user.test.ts @@ -15,6 +15,7 @@ import UserCommand, { saveDefaultFilterSettingId, transformSettingName, } from 'gmp/commands/user'; +import Response from 'gmp/http/response'; describe('UserCommand tests', () => { test('should parse auth settings in currentAuthSettings', async () => { @@ -121,6 +122,38 @@ describe('UserCommand tests', () => { }, }); }); + + test('should renew session and extract JWT from meta', async () => { + const envelopeData = { + action_result: { + message: '1234567890', + }, + }; + // Create a raw XMLstring that includes JWT in the envelope + const xmlResponse = `1test-jwt-token${Object.entries( + envelopeData, + ) + .map(([key, value]) => { + if (typeof value === 'object') { + return `<${key}>${Object.entries(value) + .map(([k, v]) => `<${k}>${v}`) + .join('')}`; + } + return `<${key}>${value}`; + }) + .join('')}`; + const response = new Response(xmlResponse); + const fakeHttp = createHttp(response); + const cmd = new UserCommand(fakeHttp); + const result = await cmd.renewSession(); + expect(fakeHttp.request).toHaveBeenCalledWith('post', { + data: { + cmd: 'renew_session', + }, + }); + expect(result.data.jwt).toEqual('test-jwt-token'); + expect(result.data.timeout).toBeDefined(); + }); }); describe('UserCommand transformSettingName() function tests', () => { diff --git a/src/gmp/commands/feed-key-types.ts b/src/gmp/commands/feed-key-types.ts new file mode 100644 index 0000000000..087165193f --- /dev/null +++ b/src/gmp/commands/feed-key-types.ts @@ -0,0 +1,413 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface Paths { + '/api/v1/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check the health status of the service. */ + get: Operations['health_check']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/key': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download the current feed key file. */ + get: Operations['download_key']; + /** Upload a new feed key file. */ + put: Operations['upload_key']; + /** Upload a new feed key as a multipart/form-data file upload. */ + post: Operations['upload_key_multipart']; + /** Delete the current feed key. */ + delete: Operations['delete_key']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/key/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Status of the current feed key. */ + get: Operations['key_status']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type Webhooks = Record; +export interface Components { + schemas: { + JsonResponse: { + message: string; + status: string; + }; + KeyStatus: { + hasKey: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type Defs = Record; +export interface Operations { + health_check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Health check OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "OK server is healthy", + * "status": "success" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + }; + }; + download_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Key downloaded successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/octet-stream': string; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Unauthorized", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Key not available */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key not available", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + }; + }; + upload_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description The key file to upload */ + requestBody?: { + content: { + 'application/octet-stream': unknown; + }; + }; + responses: { + /** @description Key uploaded successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key uploaded successfully", + * "status": "success" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Bad Request. Failed to validate key. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Bad request: Key upload failed. Failed to validate key. Invalid Key data", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Unauthorized", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Key upload failed. Could not write to file. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key upload failed. Could not write to file.", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + }; + }; + upload_key_multipart: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description File to upload */ + requestBody: { + content: { + 'multipart/form-data': { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Key upload successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key uploaded successfully", + * "status": "success" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Bad Request. Failed to validate key. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Bad request: Key upload failed. Failed to validate key. Invalid Key data", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Unauthorized", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Key upload failed. Could not write to file. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key upload failed. Could not write to file.", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + }; + }; + delete_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Key deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key deleted successfully", + * "status": "success" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Unauthorized", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + /** @description Key deletion failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Key deletion failed", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + }; + }; + key_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status of the current feed key */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "hasKey": true + * } + */ + 'application/json': Components['schemas']['KeyStatus']; + }; + }; + /** @description Getting the Key Status failed because of a File error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "Internal Server Error. File error.", + * "status": "error" + * } + */ + 'application/json': Components['schemas']['JsonResponse']; + }; + }; + }; + }; +} diff --git a/src/gmp/commands/feed-key.ts b/src/gmp/commands/feed-key.ts new file mode 100644 index 0000000000..a4b3343a0a --- /dev/null +++ b/src/gmp/commands/feed-key.ts @@ -0,0 +1,212 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type {Components, Operations} from 'gmp/commands/feed-key-types'; + +import HttpCommand from 'gmp/commands/http'; +import type Http from 'gmp/http/http'; +import {buildServerUrl} from 'gmp/http/utils'; +import logger from 'gmp/log'; +import type Settings from 'gmp/settings'; + +export type KeyResponse = + Operations['download_key']['responses']['200']['content']['application/octet-stream']; + +export type KeyStatusResponse = + Operations['key_status']['responses']['200']['content']['application/json']; + +export type DeleteKeyResponse = + Operations['delete_key']['responses']['200']['content']['application/json']; +export type UploadKeyResponse = + Operations['upload_key_multipart']['responses']['200']['content']['application/json']; + +const log = logger.getLogger('gmp.commands.feedKey'); + +const API_BASE_PATH = 'service/feed-key/api/v1'; + +class FeedKeyCommand extends HttpCommand { + private readonly settings: Settings; + private readonly baseUrl: string; + private readonly getJwt: () => string | undefined; + private readonly renewSessionFn?: () => Promise; + + constructor( + http: Http, + settings: Settings, + getJwt: () => string | undefined, + renewSessionFn?: () => Promise, + ) { + super(http); + this.settings = settings; + this.getJwt = getJwt; + this.renewSessionFn = renewSessionFn; + if (this.settings.apiServer) { + this.baseUrl = buildServerUrl( + this.settings.apiServer, + API_BASE_PATH, + this.settings.apiProtocol, + ); + } else { + this.baseUrl = `/${API_BASE_PATH}`; + } + } + + private async ensureSession(): Promise { + if (this.renewSessionFn) { + await this.renewSessionFn(); + } + } + + private getAuthHeaders(): HeadersInit { + const jwt = this.getJwt(); + if (!jwt) { + throw new Error('Not authenticated, JWT is missing'); + } + + return { + Authorization: `Bearer ${jwt}`, + }; + } + + private async getErrorMessage( + response: Response, + fallback: string, + ): Promise { + if (typeof response.text !== 'function') { + try { + const data = await response.json(); + return data?.message || fallback; + } catch { + return fallback; + } + } + + const text = await response.text(); + + if (!text) { + return fallback; + } + + try { + const data = JSON.parse(text) as Components['schemas']['JsonResponse']; + return data.message || fallback; + } catch { + return text; + } + } + + /** + * Get the feed key status + * @returns Promise resolving to {hasKey: boolean} + * @throws Error if the request fails + */ + async getStatus(): Promise { + await this.ensureSession(); + log.debug('Getting feed key status'); + + const response = await fetch(`${this.baseUrl}/key/status`, { + headers: this.getAuthHeaders(), + }); + + if (response.status === 404) { + return {hasKey: false}; + } + + if (!response.ok) { + throw new Error( + await this.getErrorMessage( + response, + `Request failed: ${response.status}`, + ), + ); + } + + return response.json() as Promise; + } + + /** + * Get the feed key + * @returns Promise resolving to the key response data + * @throws Error if the request fails + */ + async get(): Promise { + await this.ensureSession(); + log.debug('Getting feed key'); + + const response = await fetch(`${this.baseUrl}/key`, { + headers: this.getAuthHeaders(), + }); + + if (response.status === 404) { + return ''; + } + + if (!response.ok) { + throw new Error( + await this.getErrorMessage( + response, + `Request failed: ${response.status}`, + ), + ); + } + + try { + return await response.json(); + } catch { + return response.text(); + } + } + + /** + * Delete the feed key + * @returns Promise resolving to the response with status and message + * @throws Error if the deletion fails + */ + async delete(): Promise { + await this.ensureSession(); + log.debug('Deleting feed key'); + + const response = await fetch(`${this.baseUrl}/key`, { + method: 'DELETE', + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error( + await this.getErrorMessage(response, 'Key deletion failed'), + ); + } + + return response.json(); + } + + /** + * Save (upload) a feed key + * @param file - The file to save + * @returns Promise resolving to the response with status and message + * @throws Error if the save fails + */ + async save(file: File): Promise { + await this.ensureSession(); + log.debug('Saving feed key', {fileName: file.name}); + + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${this.baseUrl}/key`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: formData, + }); + + if (!response.ok) { + throw new Error(await this.getErrorMessage(response, 'Key save failed')); + } + + return response.json(); + } +} + +export default FeedKeyCommand; diff --git a/src/gmp/commands/user.ts b/src/gmp/commands/user.ts index 8832a921cc..c6530955fc 100644 --- a/src/gmp/commands/user.ts +++ b/src/gmp/commands/user.ts @@ -595,10 +595,14 @@ class UserCommand extends EntityCommand { const response = await this.action({ cmd: 'renew_session', }); + const seconds = parseInt(response.data.message); - return response.setData( - isDefined(seconds) ? date.unix(seconds) : undefined, - ); + const timeout = isDefined(seconds) ? date.unix(seconds) : undefined; + + // JWT is now extracted to response.meta by the transformation layer + const jwt: string | undefined = response.meta?.jwt as string | undefined; + + return response.setData({timeout, jwt}); } changePassword(oldPassword: string, newPassword: string) { diff --git a/src/gmp/gmp.ts b/src/gmp/gmp.ts index 17ddc04c9a..62b29518d4 100644 --- a/src/gmp/gmp.ts +++ b/src/gmp/gmp.ts @@ -43,6 +43,7 @@ import CvesCommand from 'gmp/commands/cves'; import DashboardCommand from 'gmp/commands/dashboards'; import DfnCertAdvisoriesCommand from 'gmp/commands/dfn-cert-advisories'; import DfnCertAdvisoryCommand from 'gmp/commands/dfn-cert-advisory'; +import FeedKeyCommand from 'gmp/commands/feed-key'; import FeedStatusCommand from 'gmp/commands/feed-status'; import FilterCommand from 'gmp/commands/filter'; import FiltersCommand from 'gmp/commands/filters'; @@ -126,6 +127,7 @@ class Gmp { public readonly dashboard: DashboardCommand; public readonly dfncert: DfnCertAdvisoryCommand; public readonly dfncerts: DfnCertAdvisoriesCommand; + public readonly feedkey: FeedKeyCommand; public readonly feedstatus: FeedStatusCommand; public readonly filter: FilterCommand; public readonly filters: FiltersCommand; @@ -251,6 +253,14 @@ class Gmp { this.user = new UserCommand(this.http); this.users = new UsersCommand(this.http); this.wizard = new WizardCommand(this.http); + this.feedkey = new FeedKeyCommand( + this.http, + this.settings, + () => this.session.jwt, + async () => { + await this.user.renewSession(); + }, + ); this._initCommands(); } diff --git a/src/web/Routes.tsx b/src/web/Routes.tsx index 082c7cc973..8616d05bd2 100644 --- a/src/web/Routes.tsx +++ b/src/web/Routes.tsx @@ -263,13 +263,21 @@ const loggedInRoutes = [ { path: 'feedstatus', loader: () => { - throw redirect('/feed-status'); + throw redirect('/feed-configuration'); }, }, { path: 'feed-status', + loader: () => { + throw redirect('/feed-configuration'); + }, + }, + { + path: 'feed-configuration', lazy: async () => ({ - Component: (await import('web/pages/extras/FeedStatusPage')).default, + Component: ( + await import('web/pages/feed-configuration/FeedConfigurationPage') + ).default, }), }, diff --git a/src/web/components/menu/Menu.tsx b/src/web/components/menu/Menu.tsx index 8fc9c973f2..dfe3f25f22 100644 --- a/src/web/components/menu/Menu.tsx +++ b/src/web/components/menu/Menu.tsx @@ -197,7 +197,7 @@ const Menu = () => { const isPerformanceActive = Boolean(useMatch('/performance')); const isTrashcanActive = Boolean(useMatch('/trashcan')); - const isFeedStatusActive = Boolean(useMatch('/feed-status')); + const isFeedStatusActive = Boolean(useMatch('/feed-configuration')); const isLdapActive = Boolean(useMatch('/ldap')); const isCredentialStoreActive = Boolean(useMatch('/credential-store')); const isRadiusActive = Boolean(useMatch('/radius')); @@ -585,8 +585,8 @@ const Menu = () => { active: isTrashcanActive, }, capabilities.mayOp('get_feeds') && { - label: _('Feed Status'), - to: '/feed-status', + label: _('Feed Configuration'), + to: '/feed-configuration', isPathMatch: isFeedStatusActive, active: isFeedStatusActive, }, diff --git a/src/web/components/menu/__tests__/Menu.test.tsx b/src/web/components/menu/__tests__/Menu.test.tsx index cf0be2c609..63452adb74 100644 --- a/src/web/components/menu/__tests__/Menu.test.tsx +++ b/src/web/components/menu/__tests__/Menu.test.tsx @@ -68,7 +68,7 @@ describe('Menu rendering', () => { 'CVEs', 'CVSS Calculator', 'DFN-CERT Advisories', - 'Feed Status', + 'Feed Configuration', 'Filters', 'Groups', 'LDAP', diff --git a/src/web/hooks/use-query/__tests__/feed-key.test.ts b/src/web/hooks/use-query/__tests__/feed-key.test.ts new file mode 100644 index 0000000000..abc47a22c5 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/feed-key.test.ts @@ -0,0 +1,314 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import {rendererWith, wait, waitFor} from 'web/testing'; +import { + useGetKey, + useGetKeyStatus, + useDeleteKey, + useUploadKey, +} from 'web/hooks/use-query/feed-key'; + +describe('feed-key hooks', () => { + describe('useGetKey', () => { + test('should fetch key data when jwt is available', async () => { + const get = testing + .fn() + .mockResolvedValue({status: 'success', message: 'Key found'}); + + const gmp = { + feedkey: { + get, + delete: testing.fn(), + save: testing.fn(), + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useGetKey()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual({ + status: 'success', + message: 'Key found', + }); + expect(get).toHaveBeenCalled(); + }); + + test('should not fetch when jwt is not available', async () => { + const get = testing.fn(); + + const gmp = { + feedkey: { + get, + getStatus: testing.fn().mockResolvedValue({hasKey: false}), + delete: testing.fn(), + save: testing.fn(), + }, + settings: { + jwt: undefined, + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useGetKey()); + + await wait(); + + expect(result.current.isFetching).toBe(false); + expect(get).not.toHaveBeenCalled(); + }); + + test('should handle error from get', async () => { + const get = testing.fn().mockRejectedValue(new Error('Server error')); + + const gmp = { + feedkey: { + get, + getStatus: testing.fn().mockResolvedValue({hasKey: false}), + delete: testing.fn(), + save: testing.fn(), + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useGetKey()); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('Server error'); + }); + }); + + describe('useGetKeyStatus', () => { + test('should fetch key status when jwt is available', async () => { + const getStatus = testing.fn().mockResolvedValue({hasKey: true}); + + const gmp = { + feedkey: { + get: testing.fn(), + getStatus, + delete: testing.fn(), + save: testing.fn(), + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useGetKeyStatus()); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual({ + hasKey: true, + }); + expect(getStatus).toHaveBeenCalled(); + }); + + test('should not fetch when jwt is not available', async () => { + const getStatus = testing.fn(); + + const gmp = { + feedkey: { + get: testing.fn(), + getStatus, + delete: testing.fn(), + save: testing.fn(), + }, + settings: { + jwt: undefined, + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useGetKeyStatus()); + + await wait(); + + expect(result.current.isFetching).toBe(false); + expect(getStatus).not.toHaveBeenCalled(); + }); + + test('should handle error from getStatus', async () => { + const getStatus = testing + .fn() + .mockRejectedValue(new Error('Server error')); + + const gmp = { + feedkey: { + get: testing.fn(), + getStatus, + delete: testing.fn(), + save: testing.fn(), + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useGetKeyStatus()); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('Server error'); + }); + }); + + describe('useDeleteKey', () => { + test('should call delete on the feedkey command', async () => { + const deleteFn = testing + .fn() + .mockResolvedValue({status: 'success', message: 'ok'}); + + const gmp = { + feedkey: { + get: testing.fn().mockResolvedValue({status: 'success'}), + delete: deleteFn, + save: testing.fn(), + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useDeleteKey()); + + result.current.mutate(); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(deleteFn).toHaveBeenCalled(); + }); + + test('should handle delete error', async () => { + const deleteFn = testing + .fn() + .mockRejectedValue(new Error('Delete failed')); + + const gmp = { + feedkey: { + get: testing.fn().mockResolvedValue({status: 'success'}), + getStatus: testing.fn().mockResolvedValue({hasKey: true}), + delete: deleteFn, + save: testing.fn(), + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useDeleteKey()); + + result.current.mutate(); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe('Delete failed'); + }); + }); + + describe('useUploadKey', () => { + test('should call renewSession and save with a file', async () => { + const saveFn = testing + .fn() + .mockResolvedValue({status: 'success', message: 'ok'}); + const renewSessionFn = testing + .fn() + .mockResolvedValue({data: {timeout: 123, jwt: 'renewed-jwt'}}); + + const gmp = { + feedkey: { + get: testing.fn().mockResolvedValue(null), + delete: testing.fn(), + save: saveFn, + }, + user: { + renewSession: renewSessionFn, + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useUploadKey()); + + const file = new File(['key-content'], 'feed.pem', { + type: 'application/x-pem-file', + }); + + result.current.mutate(file); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(renewSessionFn).toHaveBeenCalled(); + expect(saveFn.mock.calls[0][0]).toBe(file); + }); + + test('should handle upload error', async () => { + const saveFn = testing.fn().mockRejectedValue(new Error('Upload failed')); + const renewSessionFn = testing + .fn() + .mockResolvedValue({data: {timeout: 123, jwt: 'renewed-jwt'}}); + + const gmp = { + feedkey: { + get: testing.fn().mockResolvedValue(null), + getStatus: testing.fn().mockResolvedValue({hasKey: false}), + delete: testing.fn(), + save: saveFn, + }, + user: { + renewSession: renewSessionFn, + }, + settings: { + jwt: 'test-jwt-token', + }, + }; + + const {renderHook} = rendererWith({gmp}); + const {result} = renderHook(() => useUploadKey()); + + const file = new File(['bad-content'], 'feed.pem'); + + result.current.mutate(file); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe('Upload failed'); + }); + }); +}); diff --git a/src/web/hooks/use-query/feed-key.ts b/src/web/hooks/use-query/feed-key.ts new file mode 100644 index 0000000000..e1a1336ce5 --- /dev/null +++ b/src/web/hooks/use-query/feed-key.ts @@ -0,0 +1,65 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import type { + KeyResponse, + KeyStatusResponse, + DeleteKeyResponse, + UploadKeyResponse, +} from 'gmp/commands/feed-key'; +import useGmp from 'web/hooks/useGmp'; + +export const useGetKey = () => { + const gmp = useGmp(); + const {jwt} = gmp.session; + + return useQuery({ + queryKey: ['get_feed_key', jwt], + enabled: Boolean(jwt), + queryFn: gmp.feedkey.get.bind(gmp.feedkey), + retry: false, + }); +}; + +export const useGetKeyStatus = () => { + const gmp = useGmp(); + const {jwt} = gmp.session; + + return useQuery({ + queryKey: ['get_feed_key_status', jwt], + enabled: Boolean(jwt), + queryFn: gmp.feedkey.getStatus.bind(gmp.feedkey), + refetchInterval: 5000 * 60, // 5 minutes + }); +}; + +export const useDeleteKey = () => { + const queryClient = useQueryClient(); + const gmp = useGmp(); + + return useMutation({ + mutationFn: gmp.feedkey.delete.bind(gmp.feedkey), + onSuccess: () => { + void queryClient.invalidateQueries({queryKey: ['get_feed_key_status']}); + }, + }); +}; + +export const useUploadKey = () => { + const queryClient = useQueryClient(); + const gmp = useGmp(); + + return useMutation({ + mutationFn: async (file: File) => { + // Renew session to ensure JWT is fresh before uploading + await gmp.user.renewSession(); + return gmp.feedkey.save(file); + }, + onSuccess: () => { + void queryClient.invalidateQueries({queryKey: ['get_feed_key_status']}); + }, + }); +}; diff --git a/src/web/hooks/useSessionTimeout.ts b/src/web/hooks/useSessionTimeout.ts index 2ef0db8d52..19b95cd67a 100644 --- a/src/web/hooks/useSessionTimeout.ts +++ b/src/web/hooks/useSessionTimeout.ts @@ -29,7 +29,7 @@ const useSessionTimeout = (): [ const renewSessionAndUpdateTimeout = useCallback(async () => { const response = await gmp.user.renewSession(); - gmp.session.setSessionTimeout(response.data); + gmp.session.setSessionTimeout(response.data.timeout); }, [gmp]); return [sessionTimeout, renewSessionAndUpdateTimeout]; diff --git a/src/web/pages/extras/FeedStatusPage.tsx b/src/web/pages/extras/FeedStatusPage.tsx deleted file mode 100644 index b995df424a..0000000000 --- a/src/web/pages/extras/FeedStatusPage.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import React, {useState} from 'react'; -import { - NVT_FEED, - SCAP_FEED, - CERT_FEED, - GVMD_DATA_FEED, - type Feed, -} from 'gmp/commands/feed-status'; -import {isDefined} from 'gmp/utils/identity'; -import { - CertBundAdvIcon, - CpeLogoIcon, - CveIcon, - DfnCertAdvIcon, - FeedIcon, - NvtIcon, - PolicyIcon, - PortListIcon, - ReportFormatIcon, - ScanConfigIcon, -} from 'web/components/icon'; -import ManualIcon from 'web/components/icon/ManualIcon'; -import Divider from 'web/components/layout/Divider'; -import IconDivider from 'web/components/layout/IconDivider'; -import Layout from 'web/components/layout/Layout'; -import PageTitle from 'web/components/layout/PageTitle'; -import Link from 'web/components/link/Link'; -import Reload, { - USE_DEFAULT_RELOAD_INTERVAL, - USE_DEFAULT_RELOAD_INTERVAL_ACTIVE, -} from 'web/components/loading/Reload'; -import Section from 'web/components/section/Section'; -import Table from 'web/components/table/StripedTable'; -import TableBody from 'web/components/table/TableBody'; -import TableData from 'web/components/table/TableData'; -import TableHead from 'web/components/table/TableHead'; -import TableRow from 'web/components/table/TableRow'; -import useGmp from 'web/hooks/useGmp'; -import useTranslation from 'web/hooks/useTranslation'; - -interface FeedStatusProps { - feeds: Feed[]; -} - -interface FeedCheckProps { - feed: Feed; -} - -interface FeedStatusDisplayProps { - feed: Feed; -} - -const ToolBarIcons = () => { - const [_] = useTranslation(); - - return ( - - ); -}; - -const FeedCheck = ({feed}: FeedCheckProps) => { - const [_] = useTranslation(); - const age = feed.age; - - if (age >= 30 && !feed.currentlySyncing) { - return ( - - {_('Please check the automatic synchronization of your system.')} - - ); - } - - return null; -}; - -const FeedStatusDisplay = ({feed}: FeedStatusDisplayProps) => { - const [_] = useTranslation(); - - if (feed.currentlySyncing) { - return {_('Update in progress...')}; - } - - if (isDefined(feed.syncNotAvailableError)) { - return ( - - {_('Synchronization issue: {{error}}', { - error: feed.syncNotAvailableError, - })} - - ); - } - - const {age} = feed; - if (age >= 30) { - return {_('Too old ({{age}} days)', {age: String(age)})}; - } - - if (age >= 2) { - return {_('{{age}} days old', {age: String(age)})}; - } - - return {_('Current')}; -}; - -const FeedStatus = ({feeds}: FeedStatusProps) => { - const [_] = useTranslation(); - return ( - - - - - {' '} - {/* span prevents Toolbar from growing */} - - -
} title={_('Feed Status')} /> - - - - {_('Type')} - {_('Content')} - {_('Origin')} - {_('Version')} - {_('Status')} - - - {feeds.map(feed => { - return ( - - {feed.feedType} - - {feed.feedType === NVT_FEED && ( - - - - - {_('NVTs')} - - - - )} - {feed.feedType === SCAP_FEED && ( - - - - - {_('CVEs')} - - - - - - {_('CPEs')} - - - - )} - {feed.feedType === CERT_FEED && ( - - - - - {_('CERT-Bund Advisories')} - - - - - - {_('DFN-CERT Advisories')} - - - - )} - {feed.feedType === GVMD_DATA_FEED && ( - - - - - {_('Compliance Policies')} - - - - - - {_('Port Lists')} - - - - - - {_('Report Formats')} - - - - - - {_('Scan Configs')} - - - - )} - - {feed.name} - {feed.version} - - - - - - - - - - - - ); - })} - -
- - - ); -}; - -const FeedStatusWrapper = () => { - const gmp = useGmp(); - const [feeds, setFeeds] = useState([]); - - const loadFeeds = async () => { - const response = await gmp.feedstatus.readFeedInformation(); - setFeeds(response.data); - }; - - const calculateSyncInterval = (feedsArray: Feed[] = []) => { - const isSyncing = feedsArray.some(feed => feed.currentlySyncing === true); - - return isSyncing - ? USE_DEFAULT_RELOAD_INTERVAL_ACTIVE - : USE_DEFAULT_RELOAD_INTERVAL; - }; - - return ( - calculateSyncInterval(feedsArray)} - > - {() => } - - ); -}; - -export default FeedStatusWrapper; diff --git a/src/web/pages/extras/__tests__/FeedStatusPage.test.tsx b/src/web/pages/extras/__tests__/FeedStatusPage.test.tsx deleted file mode 100644 index c7fd2fda9c..0000000000 --- a/src/web/pages/extras/__tests__/FeedStatusPage.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import {describe, test, expect, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; -import {createFeed} from 'gmp/commands/feed-status'; -import Response from 'gmp/http/response'; -import FeedStatus from 'web/pages/extras/FeedStatusPage'; - -testing.setSystemTime(new Date('2020-07-25T07:00:00Z')); - -const nvtFeed = createFeed({ - name: 'Greenbone Community Feed', - type: 'NVT', - description: 'Community Feed', - version: 202007241005, -}); - -const scapFeed = createFeed({ - name: 'Greenbone Community SCAP Feed', - type: 'SCAP', - description: 'Community SCAP Feed', - version: 202007230130, -}); - -const certFeed = createFeed({ - name: 'Greenbone Community CERT Feed', - type: 'CERT', - description: 'Community CERT Feed', - version: 202005231003, -}); - -const gvmdDataFeed = createFeed({ - name: 'Greenbone Community gvmd Data Feed', - type: 'GVMD_DATA', - description: 'Community gvmd Data Feed', - version: 202006221009, - currently_syncing: {timestamp: 'foo'}, -}); - -describe('Feed status page tests', () => { - test('should render', async () => { - const response = new Response([nvtFeed, scapFeed, certFeed, gvmdDataFeed]); - const gmp = { - feedstatus: { - readFeedInformation: testing.fn(() => Promise.resolve(response)), - }, - settings: { - manualUrl: 'http://foo.bar', - }, - }; - - const {render} = rendererWith({gmp, router: true}); - const {element} = render(); - - await waitFor(() => element.querySelectorAll('table')); - - expect(screen.getByTestId('help-icon')).toHaveAttribute( - 'title', - 'Help: Feed Status', - ); - - // Should render all links - const links = element.querySelectorAll('a'); - - expect(links.length).toEqual(10); - - expect(links[0]).toHaveAttribute( - 'href', - 'http://foo.bar/en/web-interface.html#displaying-the-feed-status', - ); - expect(links[1]).toHaveAttribute('href', '/nvts'); - expect(links[2]).toHaveAttribute('href', '/cves'); - expect(links[3]).toHaveAttribute('href', '/cpes'); - expect(links[4]).toHaveAttribute('href', '/certbunds'); - expect(links[5]).toHaveAttribute('href', '/dfncerts'); - expect(links[6]).toHaveAttribute('href', '/policies?filter=predefined%3D1'); - expect(links[7]).toHaveAttribute( - 'href', - '/portlists?filter=predefined%3D1', - ); - expect(links[8]).toHaveAttribute( - 'href', - '/reportformats?filter=predefined%3D1', - ); - expect(links[9]).toHaveAttribute( - 'href', - '/scanconfigs?filter=predefined%3D1', - ); - - // Test headers - const header = element.querySelectorAll('th'); - - expect(header.length).toEqual(5); - - expect(header[0]).toHaveTextContent('Type'); - expect(header[1]).toHaveTextContent('Content'); - expect(header[2]).toHaveTextContent('Origin'); - expect(header[3]).toHaveTextContent('Version'); - expect(header[4]).toHaveTextContent('Status'); - - // Type names - expect(element).toHaveTextContent('NVT'); - expect(element).toHaveTextContent('SCAP'); - expect(element).toHaveTextContent('CERT'); - expect(element).toHaveTextContent('GVMD_DATA'); - - // Feed Origin - expect(element).toHaveTextContent('Greenbone Community Feed'); - expect(element).toHaveTextContent('Greenbone Community SCAP Feed'); - expect(element).toHaveTextContent('Greenbone Community CERT Feed'); - expect(element).toHaveTextContent('Greenbone Community gvmd Data Feed'); - - // Feed versions - expect(element).toHaveTextContent('20200724T1005'); - expect(element).toHaveTextContent('20200723T0130'); - expect(element).toHaveTextContent('20200523T1003'); - expect(element).toHaveTextContent('20200622T1009'); - - // Feed Status - - const ageText = element.querySelectorAll('strong'); - const updateMsgs = screen.getAllByTestId('update-msg'); - - expect(ageText.length).toEqual(4); - expect(updateMsgs.length).toEqual(4); - - // Not too old and not currently syncing - expect(ageText[0]).toHaveTextContent('Current'); - expect(updateMsgs[0]).toHaveTextContent(''); - - expect(ageText[1]).toHaveTextContent('2 days old'); - expect(updateMsgs[1]).toHaveTextContent(''); - - // CERT feed is too old but is not currently syncing - expect(ageText[2]).toHaveTextContent('Too old (62 days)'); - expect(updateMsgs[2]).toHaveTextContent( - 'Please check the automatic synchronization of your system.', - ); - - // GVMD_DATA feed is too old but IS currently syncing - expect(ageText[3]).toHaveTextContent('Update in progress...'); - expect(updateMsgs[3]).toHaveTextContent(''); - }); -}); - -testing.useRealTimers(); diff --git a/src/web/pages/feed-configuration/FeedConfigurationPage.tsx b/src/web/pages/feed-configuration/FeedConfigurationPage.tsx new file mode 100644 index 0000000000..012ef3e04c --- /dev/null +++ b/src/web/pages/feed-configuration/FeedConfigurationPage.tsx @@ -0,0 +1,126 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState} from 'react'; +import {type Feed} from 'gmp/commands/feed-status'; +import {FeedIcon} from 'web/components/icon'; +import ManualIcon from 'web/components/icon/ManualIcon'; +import IconDivider from 'web/components/layout/IconDivider'; +import Layout from 'web/components/layout/Layout'; +import PageTitle from 'web/components/layout/PageTitle'; +import Reload, { + USE_DEFAULT_RELOAD_INTERVAL, + USE_DEFAULT_RELOAD_INTERVAL_ACTIVE, +} from 'web/components/loading/Reload'; +import Section from 'web/components/section/Section'; +import Tab from 'web/components/tab/Tab'; +import TabLayout from 'web/components/tab/TabLayout'; +import TabList from 'web/components/tab/TabList'; +import TabPanel from 'web/components/tab/TabPanel'; +import TabPanels from 'web/components/tab/TabPanels'; +import Tabs from 'web/components/tab/Tabs'; +import TabsContainer from 'web/components/tab/TabsContainer'; +import useFeatures from 'web/hooks/useFeatures'; +import useGmp from 'web/hooks/useGmp'; +import useTranslation from 'web/hooks/useTranslation'; +import FeedKeyTab from 'web/pages/feed-configuration/tabs/FeedKeyTab'; +import FeedStatusTab from 'web/pages/feed-configuration/tabs/FeedStatusTab'; + +interface FeedConfigurationPageContentProps { + feeds: Feed[]; +} + +const FeedConfigurationPageContent = ({ + feeds, +}: FeedConfigurationPageContentProps) => { + const [_] = useTranslation(); + const features = useFeatures(); + + const tabs = [ + { + key: 'status', + label: _('Feed Status'), + panel: , + }, + ...(features.featureEnabled('ENABLE_FEED_KEY_SERVICE') + ? [ + { + key: 'key', + label: _('Feed Key'), + panel: , + }, + ] + : []), + ]; + + return ( + <> + + + + + + + +
} + title={_('Feed Configuration')} + /> + + + + {tabs.map(t => ( + {t.label} + ))} + + + + + + {tabs.map(t => ( + {t.panel} + ))} + + + + + + ); +}; + +const FeedConfigurationPage = () => { + const gmp = useGmp(); + const [feeds, setFeeds] = useState([]); + + const loadFeeds = async () => { + const response = await gmp.feedstatus.readFeedInformation(); + setFeeds(response.data); + }; + + const calculateSyncInterval = (feedsArray: Feed[] = []) => { + const isSyncing = feedsArray.some(feed => feed.currentlySyncing === true); + + return isSyncing + ? USE_DEFAULT_RELOAD_INTERVAL_ACTIVE + : USE_DEFAULT_RELOAD_INTERVAL; + }; + + return ( + calculateSyncInterval(feedsArray)} + > + {() => } + + ); +}; + +export default FeedConfigurationPage; diff --git a/src/web/pages/feed-configuration/FeedKeyUploadDialog.tsx b/src/web/pages/feed-configuration/FeedKeyUploadDialog.tsx new file mode 100644 index 0000000000..5dc3a4c51f --- /dev/null +++ b/src/web/pages/feed-configuration/FeedKeyUploadDialog.tsx @@ -0,0 +1,114 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState} from 'react'; +import SaveDialog from 'web/components/dialog/SaveDialog'; +import FileField from 'web/components/form/FileField'; +import FormGroup from 'web/components/form/FormGroup'; +import actionFunction from 'web/entity/hooks/action-function'; +import {useUploadKey} from 'web/hooks/use-query/feed-key'; +import useTranslation from 'web/hooks/useTranslation'; +import {validateFeedKeyFile} from 'web/utils/feed-key-validation'; + +interface FeedKeyUploadDialogProps { + onClose: () => void; + onError: (error: Error) => void; + onSuccess: () => void; +} + +interface FormValues { + keyFile?: File; +} + +const FeedKeyUploadDialog = ({ + onClose, + onError, + onSuccess, +}: FeedKeyUploadDialogProps) => { + const [_] = useTranslation(); + const uploadKeyMutation = useUploadKey(); + const [validationError, setValidationError] = useState(null); + const [error, setError] = useState(); + + const handleFileChange = async (file: File | undefined) => { + if (!file) { + setValidationError(null); + return; + } + + // Validate the file + const validationResult = await validateFeedKeyFile(file); + if (validationResult.isValid === false) { + setValidationError(validationResult.error || _('Invalid key file')); + } else { + setValidationError(null); + } + }; + + const handleError = (e: Error) => { + setError(e.message); + }; + + const handleErrorClose = () => { + setError(undefined); + }; + + const handleSave = async (values: FormValues) => { + if (!values.keyFile) { + throw new Error(_('Please select a key file to upload')); + } + + // If there's a validation error, don't proceed + if (validationError) { + throw new Error(validationError); + } + + await actionFunction(uploadKeyMutation.mutateAsync(values.keyFile), { + onSuccess: onSuccess, + successMessage: _('Key uploaded successfully'), + }); + }; + + return ( + + buttonTitle={_('Upload')} + defaultValues={{keyFile: undefined}} + error={error} + title={_('Upload Feed Key')} + width="500px" + onClose={onClose} + onError={handleError} + onErrorClose={handleErrorClose} + onSave={handleSave} + > + {({values, onValueChange}) => ( + <> + + { + onValueChange(file, name); + void handleFileChange(file); + }} + /> + + +

+ {_( + 'Please upload your feed key file provided by Greenbone. The file should be in .pem or .key format.', + )} +

+
+ + )} + + ); +}; + +export default FeedKeyUploadDialog; diff --git a/src/web/pages/feed-configuration/__tests__/FeedConfigurationPage.test.tsx b/src/web/pages/feed-configuration/__tests__/FeedConfigurationPage.test.tsx new file mode 100644 index 0000000000..c1465e3e28 --- /dev/null +++ b/src/web/pages/feed-configuration/__tests__/FeedConfigurationPage.test.tsx @@ -0,0 +1,179 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import {rendererWith, screen} from 'web/testing'; +import Features from 'gmp/capabilities/features'; +import {createFeed} from 'gmp/commands/feed-status'; +import Response from 'gmp/http/response'; +import FeedStatus from 'web/pages/feed-configuration/FeedConfigurationPage'; + +testing.setSystemTime(new Date('2020-07-25T07:00:00Z')); + +const nvtFeed = createFeed({ + name: 'Greenbone Community Feed', + type: 'NVT', + description: 'Community Feed', + version: 202007241005, +}); + +const scapFeed = createFeed({ + name: 'Greenbone Community SCAP Feed', + type: 'SCAP', + description: 'Community SCAP Feed', + version: 202007230130, +}); + +const certFeed = createFeed({ + name: 'Greenbone Community CERT Feed', + type: 'CERT', + description: 'Community CERT Feed', + version: 202005231003, +}); + +const gvmdDataFeed = createFeed({ + name: 'Greenbone Community gvmd Data Feed', + type: 'GVMD_DATA', + description: 'Community gvmd Data Feed', + version: 202006221009, + currently_syncing: {timestamp: 'foo'}, +}); + +describe('Feed status page tests', () => { + test('should render', async () => { + const response = new Response([nvtFeed, scapFeed, certFeed, gvmdDataFeed]); + const gmp = { + feedstatus: { + readFeedInformation: testing.fn(() => Promise.resolve(response)), + }, + settings: { + manualUrl: 'http://foo.bar', + }, + }; + + const {render} = rendererWith({gmp, router: true}); + render(); + + await screen.findByRole('table'); + + const helpIcon = screen.getByTestId('help-icon'); + expect(helpIcon).toHaveAttribute('title', 'Help: Feed Configuration'); + + // Batch link queries and check specific ones + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(10); + + expect(links[0]).toHaveAttribute( + 'href', + 'http://foo.bar/en/web-interface.html#displaying-the-feed-configuration', + ); + expect(links[1]).toHaveAttribute('href', '/nvts'); + expect(links[2]).toHaveAttribute('href', '/cves'); + expect(links[3]).toHaveAttribute('href', '/cpes'); + expect(links[4]).toHaveAttribute('href', '/certbunds'); + expect(links[5]).toHaveAttribute('href', '/dfncerts'); + expect(links[6]).toHaveAttribute('href', '/policies?filter=predefined%3D1'); + expect(links[7]).toHaveAttribute( + 'href', + '/portlists?filter=predefined%3D1', + ); + expect(links[8]).toHaveAttribute( + 'href', + '/reportformats?filter=predefined%3D1', + ); + expect(links[9]).toHaveAttribute( + 'href', + '/scanconfigs?filter=predefined%3D1', + ); + + // Batch header queries + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(5); + expect(headers[0]).toHaveTextContent('Type'); + expect(headers[1]).toHaveTextContent('Content'); + expect(headers[2]).toHaveTextContent('Origin'); + expect(headers[3]).toHaveTextContent('Version'); + expect(headers[4]).toHaveTextContent('Status'); + + // Check critical text content + screen.getByText('NVT'); + screen.getByText('SCAP'); + screen.getByText('CERT'); + screen.getByText('GVMD_DATA'); + + screen.getByText('Greenbone Community Feed'); + screen.getByText('Greenbone Community SCAP Feed'); + screen.getByText('Greenbone Community CERT Feed'); + screen.getByText('Greenbone Community gvmd Data Feed'); + + screen.getByText('20200724T1005'); + screen.getByText('20200723T0130'); + screen.getByText('20200523T1003'); + screen.getByText('20200622T1009'); + + // Batch update messages and age indicators + const updateMsgs = screen.getAllByTestId('update-msg'); + expect(updateMsgs).toHaveLength(4); + + // Check status messages + screen.getByText('Current'); + screen.getByText('2 days old'); + screen.getByText('Too old (62 days)'); + expect(updateMsgs[2]).toHaveTextContent( + 'Please check the automatic synchronization of your system.', + ); + screen.getByText('Update in progress...'); + }); + + test('should show Feed Key tab when ENABLE_FEED_KEY_SERVICE is enabled', async () => { + const response = new Response([nvtFeed, scapFeed, certFeed, gvmdDataFeed]); + const gmp = { + feedstatus: { + readFeedInformation: testing.fn(() => Promise.resolve(response)), + }, + settings: { + manualUrl: 'http://foo.bar', + }, + }; + + const features = new Features(['ENABLE_FEED_KEY_SERVICE']); + const {render} = rendererWith({gmp, router: true, features}); + render(); + + await screen.findByRole('table'); + + // Check that both tabs are present + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('Feed Status'); + expect(tabs[1]).toHaveTextContent('Feed Key'); + }); + + test('should hide Feed Key tab when ENABLE_FEED_KEY_SERVICE is disabled', async () => { + const response = new Response([nvtFeed, scapFeed, certFeed, gvmdDataFeed]); + const gmp = { + feedstatus: { + readFeedInformation: testing.fn(() => Promise.resolve(response)), + }, + settings: { + manualUrl: 'http://foo.bar', + }, + }; + + const features = new Features(); + const {render} = rendererWith({gmp, router: true, features}); + render(); + + await screen.findByRole('table'); + + // Check that only the Feed Status tab is present + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(1); + expect(tabs[0]).toHaveTextContent('Feed Status'); + expect(screen.queryByText('Feed Key')).not.toBeInTheDocument(); + }); +}); + +testing.useRealTimers(); diff --git a/src/web/pages/feed-configuration/__tests__/FeedKeyUploadDialog.test.tsx b/src/web/pages/feed-configuration/__tests__/FeedKeyUploadDialog.test.tsx new file mode 100644 index 0000000000..9cdcbfd745 --- /dev/null +++ b/src/web/pages/feed-configuration/__tests__/FeedKeyUploadDialog.test.tsx @@ -0,0 +1,108 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen} from 'web/testing'; +import FeedKeyUploadDialog from 'web/pages/feed-configuration/FeedKeyUploadDialog'; + +const createGmp = (overrides: Record = {}) => ({ + feedkey: { + get: testing.fn().mockResolvedValue(null), + delete: testing.fn().mockResolvedValue({status: 'success', message: 'ok'}), + save: testing.fn().mockResolvedValue({status: 'success', message: 'ok'}), + }, + settings: { + jwt: 'test-jwt-token', + manualUrl: 'http://foo.bar', + }, + ...overrides, +}); + +describe('FeedKeyUploadDialog', () => { + test('should render the upload dialog', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render( + , + ); + + screen.getByText('Upload Feed Key'); + screen.getByText('Key File'); + screen.getByText('Please upload your feed key file', {exact: false}); + }); + + test('should have an Upload button', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render( + , + ); + + const uploadButton = screen.getByTestId('dialog-save-button'); + expect(uploadButton).toHaveTextContent('Upload'); + }); + + test('should call onClose when cancel button is clicked', () => { + const onClose = testing.fn(); + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render( + , + ); + + const cancelButton = screen.getByTestId('dialog-close-button'); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); + + test('should show error when trying to save without a file', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render( + , + ); + + const uploadButton = screen.getByTestId('dialog-save-button'); + fireEvent.click(uploadButton); + + await screen.findByText('Please select a key file to upload'); + }); + + test('should render the file input field', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render( + , + ); + + screen.getByTestId('file-input'); + }); +}); diff --git a/src/web/pages/feed-configuration/tabs/FeedKeyTab.tsx b/src/web/pages/feed-configuration/tabs/FeedKeyTab.tsx new file mode 100644 index 0000000000..82f629e4eb --- /dev/null +++ b/src/web/pages/feed-configuration/tabs/FeedKeyTab.tsx @@ -0,0 +1,182 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState} from 'react'; +import styled from 'styled-components'; +import ConfirmationDialog from 'web/components/dialog/ConfirmationDialog'; +import {DELETE_ACTION} from 'web/components/dialog/DialogTwoButtonFooter'; +import Button from 'web/components/form/Button'; +import {VerifyIcon, VerifyNoIcon} from 'web/components/icon'; +import Layout from 'web/components/layout/Layout'; +import Loading from 'web/components/loading/Loading'; +import DialogNotification from 'web/components/notification/DialogNotification'; +import actionFunction from 'web/entity/hooks/action-function'; +import {useDeleteKey, useGetKeyStatus} from 'web/hooks/use-query/feed-key'; +import useTranslation from 'web/hooks/useTranslation'; +import FeedKeyUploadDialog from 'web/pages/feed-configuration/FeedKeyUploadDialog'; +import Theme from 'web/utils/Theme'; + +const FeedKeyCard = styled(Layout)` + background: ${Theme.white}; + border: 1px solid ${Theme.inputBorderGray}; + border-radius: 8px; + padding: 24px; + gap: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +`; + +const IconContainer = styled.div<{$hasKey: boolean}>` + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: ${({$hasKey}) => ($hasKey ? Theme.lightGreen : Theme.lightGray)}; + color: ${({$hasKey}) => ($hasKey ? Theme.green : Theme.mediumGray)}; + flex-shrink: 0; +`; + +const Title = styled.h3` + margin: 0 0 4px 0; + font-size: 16px; + font-weight: 600; +`; + +const Description = styled.p` + margin: 0; + font-size: 14px; + line-height: 1.5; + color: ${Theme.mediumGray}; +`; + +const FeedKeyTab = () => { + const [_] = useTranslation(); + + const {data, isLoading, error} = useGetKeyStatus(); + const deleteKeyMutation = useDeleteKey(); + + const [uploadDialogVisible, setUploadDialogVisible] = useState(false); + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const hasKey = data?.hasKey === true && !error; + + const handleDeleteClick = () => { + setDeleteConfirmVisible(true); + }; + + const handleConfirmDelete = async () => { + await actionFunction(deleteKeyMutation.mutateAsync(), { + onSuccess: async () => { + setDeleteConfirmVisible(false); + }, + successMessage: _('Key deleted successfully'), + onError: async deleteError => { + setDeleteConfirmVisible(false); + setErrorMessage( + _('Failed to delete key: {{error}}', { + error: + deleteError instanceof Error + ? deleteError.message + : String(deleteError), + }), + ); + }, + }); + }; + + const handleUploadSuccess = () => { + setUploadDialogVisible(false); + }; + + const handleUploadError = (err: Error) => { + setErrorMessage(_('Failed to upload key: {{error}}', {error: err.message})); + }; + + if (isLoading) { + return ; + } + + const feedKeyData = hasKey + ? { + icon: , + title: _('Feed Key Active'), + description: _( + 'Your feed key is properly configured and active. Enterprise Feed features are available.', + ), + action: ( + + ), + } + : { + icon: , + title: _('No Feed Key Configured'), + description: _( + 'Upload a feed key to enable Enterprise Feed features and content updates.', + ), + action: ( + + ), + }; + + return ( + <> + + {feedKeyData.icon} + + + {feedKeyData.title} + {feedKeyData.description} + + + {feedKeyData.action} + + + {errorMessage && ( + setErrorMessage(null)} + /> + )} + {deleteConfirmVisible && ( + setDeleteConfirmVisible(false)} + onResumeClick={handleConfirmDelete} + /> + )} + {uploadDialogVisible && ( + setUploadDialogVisible(false)} + onError={handleUploadError} + onSuccess={handleUploadSuccess} + /> + )} + + ); +}; + +export default FeedKeyTab; diff --git a/src/web/pages/feed-configuration/tabs/FeedStatusTab.tsx b/src/web/pages/feed-configuration/tabs/FeedStatusTab.tsx new file mode 100644 index 0000000000..b1d922c752 --- /dev/null +++ b/src/web/pages/feed-configuration/tabs/FeedStatusTab.tsx @@ -0,0 +1,201 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + CERT_FEED, + GVMD_DATA_FEED, + NVT_FEED, + SCAP_FEED, + type Feed, +} from 'gmp/commands/feed-status'; +import {isDefined} from 'gmp/utils/identity'; +import { + CertBundAdvIcon, + CpeLogoIcon, + CveIcon, + DfnCertAdvIcon, + NvtIcon, + PolicyIcon, + PortListIcon, + ReportFormatIcon, + ScanConfigIcon, +} from 'web/components/icon'; +import Divider from 'web/components/layout/Divider'; +import IconDivider from 'web/components/layout/IconDivider'; +import Link from 'web/components/link/Link'; +import Table from 'web/components/table/StripedTable'; +import TableBody from 'web/components/table/TableBody'; +import TableData from 'web/components/table/TableData'; +import TableHead from 'web/components/table/TableHead'; +import TableRow from 'web/components/table/TableRow'; +import useTranslation from 'web/hooks/useTranslation'; + +interface FeedStatusTabProps { + feeds: Feed[]; +} + +interface FeedCheckProps { + feed: Feed; +} + +interface FeedStatusDisplayProps { + feed: Feed; +} + +const FeedCheck = ({feed}: FeedCheckProps) => { + const [_] = useTranslation(); + const age = feed.age; + + if (age >= 30 && !feed.currentlySyncing) { + return ( + + {_('Please check the automatic synchronization of your system.')} + + ); + } + + return null; +}; + +const FeedStatusDisplay = ({feed}: FeedStatusDisplayProps) => { + const [_] = useTranslation(); + + if (feed.currentlySyncing) { + return {_('Update in progress...')}; + } + + if (isDefined(feed.syncNotAvailableError)) { + return ( + + {_('Synchronization issue: {{error}}', { + error: feed.syncNotAvailableError, + })} + + ); + } + + const {age} = feed; + if (age >= 30) { + return {_('Too old ({{age}} days)', {age: String(age)})}; + } + + if (age >= 2) { + return {_('{{age}} days old', {age: String(age)})}; + } + + return {_('Current')}; +}; + +const FeedStatusTab = ({feeds}: FeedStatusTabProps) => { + const [_] = useTranslation(); + + return ( + + + + {_('Type')} + {_('Content')} + {_('Origin')} + {_('Version')} + {_('Status')} + + + {feeds.map(feed => { + return ( + + {feed.feedType} + + {feed.feedType === NVT_FEED && ( + + + + + {_('NVTs')} + + + + )} + {feed.feedType === SCAP_FEED && ( + + + + + {_('CVEs')} + + + + + + {_('CPEs')} + + + + )} + {feed.feedType === CERT_FEED && ( + + + + + {_('CERT-Bund Advisories')} + + + + + + {_('DFN-CERT Advisories')} + + + + )} + {feed.feedType === GVMD_DATA_FEED && ( + + + + + {_('Compliance Policies')} + + + + + + {_('Port Lists')} + + + + + + {_('Report Formats')} + + + + + + {_('Scan Configs')} + + + + )} + + {feed.name} + {feed.version} + + + + + + + + + + + + ); + })} + +
+ ); +}; + +export default FeedStatusTab; diff --git a/src/web/pages/feed-configuration/tabs/__tests__/FeedKeyTab.test.tsx b/src/web/pages/feed-configuration/tabs/__tests__/FeedKeyTab.test.tsx new file mode 100644 index 0000000000..dbf9d4b4fc --- /dev/null +++ b/src/web/pages/feed-configuration/tabs/__tests__/FeedKeyTab.test.tsx @@ -0,0 +1,191 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import { + closeDialog, + fireEvent, + rendererWith, + screen, + waitFor, +} from 'web/testing'; +import FeedKeyTab from 'web/pages/feed-configuration/tabs/FeedKeyTab'; + +const createGmp = (overrides: Record = {}) => ({ + feedkey: { + get: testing.fn().mockResolvedValue(null), + getStatus: testing.fn().mockResolvedValue({hasKey: false}), + delete: testing.fn().mockResolvedValue({status: 'success', message: 'ok'}), + save: testing.fn().mockResolvedValue({status: 'success', message: 'ok'}), + }, + settings: { + jwt: 'test-jwt-token', + manualUrl: 'http://foo.bar', + }, + ...overrides, +}); + +describe('FeedKeyTab', () => { + test('should show loading state initially', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render(); + + screen.getByTestId('loading'); + }); + + test('should render no key state when no key data is returned', async () => { + const gmp = createGmp({ + feedkey: { + get: testing.fn().mockResolvedValue(null), + getStatus: testing.fn().mockResolvedValue({hasKey: false}), + delete: testing.fn(), + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + await screen.findByText('No Feed Key Configured'); + screen.getByText('Upload a feed key to enable', {exact: false}); + screen.getByText('Upload Key'); + }); + + test('should render active key state when key data is returned', async () => { + const gmp = createGmp({ + feedkey: { + get: testing + .fn() + .mockResolvedValue({status: 'success', message: 'Key found'}), + getStatus: testing.fn().mockResolvedValue({hasKey: true}), + delete: testing.fn(), + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + await screen.findByText('Feed Key Active'); + screen.getByText('Your feed key is properly configured', {exact: false}); + screen.getByText('Delete Key'); + }); + + test('should open upload dialog when Upload Key button is clicked', async () => { + const gmp = createGmp({ + feedkey: { + get: testing.fn().mockResolvedValue(null), + getStatus: testing.fn().mockResolvedValue({hasKey: false}), + delete: testing.fn(), + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + const uploadButton = await screen.findByText('Upload Key'); + fireEvent.click(uploadButton); + + screen.getByText('Upload Feed Key'); + screen.getByText('Please upload your feed key file', {exact: false}); + }); + + test('should open delete confirmation dialog when Delete Key button is clicked', async () => { + const gmp = createGmp({ + feedkey: { + get: testing + .fn() + .mockResolvedValue({status: 'success', message: 'Key found'}), + getStatus: testing.fn().mockResolvedValue({hasKey: true}), + delete: testing.fn(), + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + const deleteButton = await screen.findByText('Delete Key'); + fireEvent.click(deleteButton); + + screen.getByText('Are you sure you want to delete', {exact: false}); + }); + + test('should call delete mutation when confirming delete', async () => { + const deleteFn = testing + .fn() + .mockResolvedValue({status: 'success', message: 'ok'}); + + const gmp = createGmp({ + feedkey: { + get: testing + .fn() + .mockResolvedValue({status: 'success', message: 'Key found'}), + getStatus: testing.fn().mockResolvedValue({hasKey: true}), + delete: deleteFn, + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + const deleteButton = await screen.findByText('Delete Key'); + fireEvent.click(deleteButton); + + const confirmButton = screen.getByTestId('dialog-save-button'); + fireEvent.click(confirmButton); + + await waitFor(() => expect(deleteFn).toHaveBeenCalled()); + }); + + test('should close confirmation dialog when cancelled', async () => { + const gmp = createGmp({ + feedkey: { + get: testing + .fn() + .mockResolvedValue({status: 'success', message: 'Key found'}), + getStatus: testing.fn().mockResolvedValue({hasKey: true}), + delete: testing.fn(), + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + const deleteButton = await screen.findByText('Delete Key'); + fireEvent.click(deleteButton); + + screen.getByText('Are you sure you want to delete', {exact: false}); + + closeDialog(); + + await waitFor(() => + expect( + screen.queryByText('Are you sure you want to delete', {exact: false}), + ).not.toBeInTheDocument(), + ); + }); + + test('should show no key state when there is an error fetching status', async () => { + const gmp = createGmp({ + feedkey: { + get: testing.fn().mockRejectedValue(new Error('Network error')), + getStatus: testing.fn().mockRejectedValue(new Error('Network error')), + delete: testing.fn(), + save: testing.fn(), + }, + }); + + const {render} = rendererWith({gmp, router: true}); + render(); + + await screen.findByText('No Feed Key Configured'); + }); +}); diff --git a/src/web/utils/__tests__/feed-key-validation.test.ts b/src/web/utils/__tests__/feed-key-validation.test.ts new file mode 100644 index 0000000000..ba7f76c214 --- /dev/null +++ b/src/web/utils/__tests__/feed-key-validation.test.ts @@ -0,0 +1,98 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; +import { + isValidFeedKeyExtension, + validateFeedKeyFile, +} from 'web/utils/feed-key-validation'; + +describe('Feed Key Validation', () => { + describe('isValidFeedKeyExtension', () => { + test('should accept .pem extension', () => { + expect(isValidFeedKeyExtension('key.pem')).toBe(true); + expect(isValidFeedKeyExtension('my-key.pem')).toBe(true); + expect(isValidFeedKeyExtension('KEY.PEM')).toBe(true); + }); + + test('should accept .key extension', () => { + expect(isValidFeedKeyExtension('key.key')).toBe(true); + expect(isValidFeedKeyExtension('my-key.key')).toBe(true); + expect(isValidFeedKeyExtension('KEY.KEY')).toBe(true); + }); + + test('should reject other extensions', () => { + expect(isValidFeedKeyExtension('key.txt')).toBe(false); + expect(isValidFeedKeyExtension('key.pub')).toBe(false); + expect(isValidFeedKeyExtension('key')).toBe(false); + expect(isValidFeedKeyExtension('key.cer')).toBe(false); + }); + }); + + describe('validateFeedKeyFile', () => { + test('should reject file with invalid extension', async () => { + const file = new File(['content'], 'test.txt', {type: 'text/plain'}); + const result = await validateFeedKeyFile(file); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Invalid file extension'); + }); + + test('should reject empty file content', async () => { + const file = new File([''], 'test.pem', { + type: 'application/x-pem-file', + }); + file.text = () => Promise.resolve(''); + const result = await validateFeedKeyFile(file); + expect(result.isValid).toBe(false); + expect(result.error).toContain('empty'); + }); + + test('should reject file with only whitespace', async () => { + const file = new File([' \n \t '], 'test.pem', { + type: 'application/x-pem-file', + }); + file.text = () => Promise.resolve(' \n \t '); + const result = await validateFeedKeyFile(file); + expect(result.isValid).toBe(false); + expect(result.error).toContain('empty'); + }); + + test('should accept any non-empty content with valid extension', async () => { + const content = 'any content here'; + const file = new File([content], 'test.pem', { + type: 'application/x-pem-file', + }); + file.text = () => Promise.resolve(content); + const result = await validateFeedKeyFile(file); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test('should accept valid PEM content', async () => { + const validPem = + '-----BEGIN RSA PRIVATE KEY-----\n' + + 'MIIEpAIBAAKCAQEA1234567890abcdef'.repeat(5) + + '\n-----END RSA PRIVATE KEY-----\n'; + const file = new File([validPem], 'test.key', { + type: 'application/octet-stream', + }); + file.text = () => Promise.resolve(validPem); + const result = await validateFeedKeyFile(file); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test('should accept content with leading/trailing whitespace', async () => { + const validContent = ' some key content \n'; + const file = new File([validContent], 'test.pem', { + type: 'application/x-pem-file', + }); + file.text = () => Promise.resolve(validContent); + const result = await validateFeedKeyFile(file); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); +}); diff --git a/src/web/utils/feed-key-validation.ts b/src/web/utils/feed-key-validation.ts new file mode 100644 index 0000000000..9033daeccc --- /dev/null +++ b/src/web/utils/feed-key-validation.ts @@ -0,0 +1,50 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface FeedKeyValidationResult { + isValid: boolean; + error?: string; +} + +/** + * Validates the file extension for a feed key file + * @param fileName - The name of the file + * @returns true if the extension is valid, false otherwise + */ +export const isValidFeedKeyExtension = (fileName: string): boolean => { + const validExtensions = ['.pem', '.key']; + const lowerFileName = fileName.toLowerCase(); + return validExtensions.some(ext => lowerFileName.endsWith(ext)); +}; + +/** + * Basic feed key validation used by the web UI. + * Rules: + * - file must have a valid extension (.pem or .key) + * - file must not be empty + * - file must contain a feed identifier like `@feed.greenbone.net:/feed/` + * - file must contain both BEGIN and END markers (e.g. `-----BEGIN ... KEY-----`) + */ +export const validateFeedKeyFile = async ( + file: File, +): Promise => { + // Only verify extension and that the file is not empty + if (!isValidFeedKeyExtension(file.name)) { + return { + isValid: false, + error: 'Invalid file extension. Please upload a .pem or .key file', + }; + } + + const content = await file.text(); + if (!content || content.trim().length === 0) { + return { + isValid: false, + error: 'The selected file is empty', + }; + } + + return {isValid: true}; +}; diff --git a/tsconfig.json b/tsconfig.json index df7168a498..aa58aed505 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,6 @@ "@gsa/testing": ["src/testing.js"] } }, - "include": ["src", "vitest.projects.ts"], + "include": ["src", "vitest.projects.ts", "scripts"], "exclude": ["node_modules", "build", "coverage"] }