diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a90f56c..f51a9fb2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -48,7 +48,8 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.7.0", "vite": "^6.0.0", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^3.2.6" }, "engines": { "node": ">=20" @@ -4064,6 +4065,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -4136,6 +4148,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4274,6 +4293,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4414,6 +4558,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -4654,6 +4808,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4741,6 +4905,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4793,6 +4974,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5390,6 +5581,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5749,6 +5950,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5975,6 +6183,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -8156,6 +8374,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -9742,6 +9967,23 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10987,6 +11229,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11072,6 +11321,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11081,6 +11337,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -11294,6 +11557,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -11459,6 +11742,20 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -11475,6 +11772,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", @@ -12164,6 +12491,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-pwa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", @@ -12195,6 +12545,79 @@ } } }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -12343,6 +12766,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/workbox-background-sync": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d987a0fb..40fe5851 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "build": "tsc -b && vite build", "build:tauri": "tsc -b && vite build --outDir dist", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.3.0", @@ -54,6 +55,7 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.7.0", "vite": "^6.0.0", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^3.2.6" } } diff --git a/frontend/src/lib/api.auth.test.ts b/frontend/src/lib/api.auth.test.ts new file mode 100644 index 00000000..949ea0ce --- /dev/null +++ b/frontend/src/lib/api.auth.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// Regression for #266: the frontend must send the local API key as a Bearer +// token on /v1 + /api requests, or `jarvis serve` with a key configured 401s +// every data-plane call. These tests cover the pure helpers (getApiKey, +// authHeaders) that source the key and build the header. + +const SETTINGS_KEY = 'openjarvis-settings'; + +// Minimal in-memory localStorage stub so the helpers can run under node +// (no jsdom dependency). +class MemoryStorage { + private store = new Map(); + getItem(k: string): string | null { + return this.store.has(k) ? (this.store.get(k) as string) : null; + } + setItem(k: string, v: string): void { + this.store.set(k, String(v)); + } + removeItem(k: string): void { + this.store.delete(k); + } + clear(): void { + this.store.clear(); + } +} + +beforeEach(() => { + (globalThis as unknown as { localStorage: MemoryStorage }).localStorage = + new MemoryStorage(); +}); + +afterEach(() => { + (globalThis as unknown as { localStorage?: MemoryStorage }).localStorage = + undefined; +}); + +async function freshApi() { + // Re-import to pick up the current localStorage stub. + return await import('./api'); +} + +describe('getApiKey', () => { + it('returns empty string when no key is configured', async () => { + const { getApiKey } = await freshApi(); + expect(getApiKey()).toBe(''); + }); + + it('reads apiKey from the openjarvis-settings localStorage blob', async () => { + localStorage.setItem( + SETTINGS_KEY, + JSON.stringify({ apiUrl: 'http://x', apiKey: 'sk-local-123' }), + ); + const { getApiKey } = await freshApi(); + expect(getApiKey()).toBe('sk-local-123'); + }); + + it('returns empty string when the blob has no apiKey field', async () => { + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiUrl: 'http://x' })); + const { getApiKey } = await freshApi(); + expect(getApiKey()).toBe(''); + }); +}); + +describe('authHeaders', () => { + it('omits Authorization when no key is set (keyless default unchanged)', async () => { + const { authHeaders } = await freshApi(); + expect(authHeaders()).toEqual({}); + }); + + it('adds a Bearer Authorization header when a key is set', async () => { + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey: 'sk-local-123' })); + const { authHeaders } = await freshApi(); + expect(authHeaders()).toEqual({ Authorization: 'Bearer sk-local-123' }); + }); + + it('merges extra headers alongside Authorization', async () => { + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey: 'sk-local-123' })); + const { authHeaders } = await freshApi(); + expect(authHeaders({ 'Content-Type': 'application/json' })).toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer sk-local-123', + }); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ff0b3d24..aa2a74b1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,6 +52,50 @@ export const getBase = (): string => { return ''; }; +// Resolve the local server API key (OPENJARVIS_API_KEY). When `jarvis serve` +// is started with a key, AuthMiddleware 401s every /v1 and /api request that +// lacks a Bearer token — so the frontend must send it (#266). Sourced from the +// same settings blob as the API URL, with an optional build-time env override. +// Returns '' when unset, so a keyless local server keeps working unchanged. +export const getApiKey = (): string => { + try { + const raw = localStorage.getItem('openjarvis-settings'); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed.apiKey) return String(parsed.apiKey); + } + } catch {} + if (import.meta.env.VITE_OPENJARVIS_API_KEY) { + return import.meta.env.VITE_OPENJARVIS_API_KEY as string; + } + return ''; +}; + +// Build request headers with the Bearer Authorization token when a local key +// is configured, merging any caller-supplied headers. Adds no Authorization +// header when no key is set, so keyless local dev is byte-for-byte unchanged. +export const authHeaders = ( + extra: Record = {}, +): Record => { + const key = getApiKey(); + return key ? { ...extra, Authorization: `Bearer ${key}` } : { ...extra }; +}; + +// Centralized fetch for the local server: prepends getBase() and injects the +// Bearer auth header (when a key is set) on every call. Using this everywhere +// guarantees no /v1 or /api request is sent without auth — the bug in #266 was +// that direct fetch() calls omitted the header and 401'd. `path` is the +// server-relative path (e.g. "/v1/savings"). +export const apiFetch = ( + path: string, + init: RequestInit = {}, +): Promise => { + const headers = authHeaders( + (init.headers as Record | undefined) ?? {}, + ); + return fetch(`${getBase()}${path}`, { ...init, headers }); +}; + async function tauriInvoke(command: string, args: Record = {}): Promise { const { invoke } = await import('@tauri-apps/api/core'); const apiUrl = getBase(); @@ -95,14 +139,14 @@ export async function fetchModels(): Promise { // Fall through to fetch } } - const res = await fetch(`${getBase()}/v1/models`); + const res = await apiFetch(`/v1/models`); if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`); const data = await res.json(); return data.data || []; } export async function fetchRecommendedModel(): Promise<{ model: string; reason: string }> { - const res = await fetch(`${getBase()}/v1/recommended-model`); + const res = await apiFetch(`/v1/recommended-model`); if (!res.ok) return { model: '', reason: 'Failed to fetch' }; return res.json(); } @@ -119,7 +163,7 @@ export async function pullModel(modelName: string): Promise { throw new Error(e?.message || e || 'Download failed'); } } - const res = await fetch(`${getBase()}/v1/models/pull`, { + const res = await apiFetch(`/v1/models/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelName }), @@ -140,7 +184,7 @@ export async function deleteModel(modelName: string): Promise { throw new Error(e?.message || e || 'Delete failed'); } } - const res = await fetch(`${getBase()}/v1/models/${encodeURIComponent(modelName)}`, { + const res = await apiFetch(`/v1/models/${encodeURIComponent(modelName)}`, { method: 'DELETE', }); if (!res.ok) { @@ -173,13 +217,13 @@ export async function preloadModel(modelName: string): Promise { } export async function fetchSavings(): Promise { - const res = await fetch(`${getBase()}/v1/savings`); + const res = await apiFetch(`/v1/savings`); if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`); return res.json(); } export async function fetchServerInfo(): Promise { - const res = await fetch(`${getBase()}/v1/info`); + const res = await apiFetch(`/v1/info`); if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`); return res.json(); } @@ -221,7 +265,7 @@ export async function fetchEnergy(): Promise { return await tauriInvoke('fetch_energy', { apiUrl: getBase() }); } catch {} } - const res = await fetch(`${getBase()}/v1/telemetry/energy`); + const res = await apiFetch(`/v1/telemetry/energy`); if (!res.ok) throw new Error(`Failed: ${res.status}`); return res.json(); } @@ -232,7 +276,7 @@ export async function fetchTelemetry(): Promise { return await tauriInvoke('fetch_telemetry', { apiUrl: getBase() }); } catch {} } - const res = await fetch(`${getBase()}/v1/telemetry/stats`); + const res = await apiFetch(`/v1/telemetry/stats`); if (!res.ok) throw new Error(`Failed: ${res.status}`); return res.json(); } @@ -243,7 +287,7 @@ export async function fetchTraces(limit: number = 50): Promise { return await tauriInvoke('fetch_traces', { apiUrl: getBase(), limit }); } catch {} } - const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`); + const res = await apiFetch(`/v1/traces?limit=${limit}`); if (!res.ok) throw new Error(`Failed: ${res.status}`); return res.json(); } @@ -279,7 +323,7 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web } const formData = new FormData(); formData.append('file', audioBlob, filename); - const res = await fetch(`${getBase()}/v1/speech/transcribe`, { + const res = await apiFetch(`/v1/speech/transcribe`, { method: 'POST', body: formData, }); @@ -295,7 +339,7 @@ export async function fetchSpeechHealth(): Promise { return { available: false }; } } - const res = await fetch(`${getBase()}/v1/speech/health`); + const res = await apiFetch(`/v1/speech/health`); if (!res.ok) return { available: false }; return res.json(); } @@ -379,14 +423,14 @@ export interface AgentMessage { } export async function fetchManagedAgents(): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents`); + const res = await apiFetch(`/v1/managed-agents`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.agents || []; } export async function fetchManagedAgent(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`); + const res = await apiFetch(`/v1/managed-agents/${agentId}`); if (!res.ok) throw new Error(`Failed: ${res.status}`); return res.json(); } @@ -397,7 +441,7 @@ export async function createManagedAgent(body: { template_id?: string; config?: Record; }): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents`, { + const res = await apiFetch(`/v1/managed-agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -410,7 +454,7 @@ export async function updateManagedAgent( agentId: string, body: Partial<{ name: string; agent_type: string; config: Record }>, ): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`, { + const res = await apiFetch(`/v1/managed-agents/${agentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -420,29 +464,29 @@ export async function updateManagedAgent( } export async function deleteManagedAgent(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`, { method: 'DELETE' }); + const res = await apiFetch(`/v1/managed-agents/${agentId}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`Failed: ${res.status}`); } export async function pauseManagedAgent(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/pause`, { method: 'POST' }); + const res = await apiFetch(`/v1/managed-agents/${agentId}/pause`, { method: 'POST' }); if (!res.ok) throw new Error(`Failed: ${res.status}`); } export async function resumeManagedAgent(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/resume`, { method: 'POST' }); + const res = await apiFetch(`/v1/managed-agents/${agentId}/resume`, { method: 'POST' }); if (!res.ok) throw new Error(`Failed: ${res.status}`); } export async function fetchAgentTasks(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/tasks`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/tasks`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.tasks || []; } export async function createAgentTask(agentId: string, description: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/tasks`, { + const res = await apiFetch(`/v1/managed-agents/${agentId}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description }), @@ -452,7 +496,7 @@ export async function createAgentTask(agentId: string, description: string): Pro } export async function fetchAgentChannels(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/channels`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/channels`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.bindings || []; @@ -496,7 +540,7 @@ export async function sendblueVerify( apiKeyId: string, apiSecretKey: string, ): Promise<{ valid: boolean; numbers: string[]; raw: unknown }> { - const res = await fetch(`${getBase()}/v1/channels/sendblue/verify`, { + const res = await apiFetch(`/v1/channels/sendblue/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key_id: apiKeyId, api_secret_key: apiSecretKey }), @@ -513,7 +557,7 @@ export async function sendblueRegisterWebhook( apiSecretKey: string, webhookUrl: string, ): Promise<{ registered: boolean; status: number }> { - const res = await fetch(`${getBase()}/v1/channels/sendblue/register-webhook`, { + const res = await apiFetch(`/v1/channels/sendblue/register-webhook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -535,7 +579,7 @@ export async function sendblueTest( fromNumber: string, toNumber: string, ): Promise<{ sent: boolean; status: number }> { - const res = await fetch(`${getBase()}/v1/channels/sendblue/test`, { + const res = await apiFetch(`/v1/channels/sendblue/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -553,20 +597,20 @@ export async function sendblueTest( } export async function sendblueHealth(): Promise<{ channel_connected: boolean; bridge_wired: boolean; ready: boolean }> { - const res = await fetch(`${getBase()}/v1/channels/sendblue/health`); + const res = await apiFetch(`/v1/channels/sendblue/health`); if (!res.ok) return { channel_connected: false, bridge_wired: false, ready: false }; return res.json(); } export async function fetchTemplates(): Promise { - const res = await fetch(`${getBase()}/v1/templates`); + const res = await apiFetch(`/v1/templates`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.templates || []; } export async function runManagedAgent(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/run`, { method: 'POST' }); + const res = await apiFetch(`/v1/managed-agents/${agentId}/run`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(body.detail || `Failed: ${res.status}`); @@ -574,7 +618,7 @@ export async function runManagedAgent(agentId: string): Promise { } export async function recoverManagedAgent(agentId: string): Promise<{ recovered: boolean; checkpoint: unknown }> { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/recover`, { method: 'POST' }); + const res = await apiFetch(`/v1/managed-agents/${agentId}/recover`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(body.detail || `Failed: ${res.status}`); @@ -589,7 +633,7 @@ export async function fetchAgentState(agentId: string): Promise<{ messages: AgentMessage[]; checkpoint: unknown; }> { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/state`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/state`); if (!res.ok) throw new Error(`Failed: ${res.status}`); return res.json(); } @@ -618,7 +662,7 @@ export async function sendAgentMessage( onDone?: (fullContent: string, usage?: Record, telemetry?: Record) => void; }, ): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, { + const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, mode, stream: true }), @@ -733,7 +777,7 @@ export async function sendAgentMessage( * the `/v1/agents/events` WebSocket and the resulting trace. */ export async function askAgent(agentId: string, content: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, { + const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, mode: 'immediate', stream: false }), @@ -743,14 +787,14 @@ export async function askAgent(agentId: string, content: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.messages || []; } export async function fetchErrorAgents(): Promise { - const res = await fetch(`${getBase()}/v1/agents/errors`); + const res = await apiFetch(`/v1/agents/errors`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.agents || []; @@ -790,7 +834,7 @@ export interface ToolInfo { } export async function fetchAvailableTools(): Promise { - const res = await fetch(`${getBase()}/v1/tools`); + const res = await apiFetch(`/v1/tools`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.tools || []; @@ -800,7 +844,7 @@ export async function saveToolCredentials( toolName: string, credentials: Record, ): Promise { - const res = await fetch(`${getBase()}/v1/tools/${toolName}/credentials`, { + const res = await apiFetch(`/v1/tools/${toolName}/credentials`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), @@ -824,26 +868,26 @@ export interface AgentTraceDetail { } export async function fetchLearningLog(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/learning`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/learning`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.learning_log || []; } export async function triggerLearning(agentId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' }); + const res = await apiFetch(`/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' }); if (!res.ok) throw new Error(`Failed: ${res.status}`); } export async function fetchAgentTraces(agentId: string, limit = 20): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/traces?limit=${limit}`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/traces?limit=${limit}`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.traces || []; } export async function fetchAgentTrace(agentId: string, traceId: string): Promise { - const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/traces/${traceId}`); + const res = await apiFetch(`/v1/managed-agents/${agentId}/traces/${traceId}`); if (!res.ok) throw new Error(`Failed: ${res.status}`); return res.json(); } @@ -911,13 +955,13 @@ export interface MemoryConfig { } export async function getMemoryStats(): Promise { - const res = await fetch(`${getBase()}/v1/memory/stats`); + const res = await apiFetch(`/v1/memory/stats`); if (!res.ok) throw new Error('Failed to fetch memory stats'); return res.json(); } export async function searchMemory(query: string, topK: number = 5): Promise { - const res = await fetch(`${getBase()}/v1/memory/search`, { + const res = await apiFetch(`/v1/memory/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, top_k: topK }), @@ -928,7 +972,7 @@ export async function searchMemory(query: string, topK: number = 5): Promise): Promise { - const res = await fetch(`${getBase()}/v1/memory/store`, { + const res = await apiFetch(`/v1/memory/store`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, metadata }), @@ -937,7 +981,7 @@ export async function storeMemory(content: string, metadata?: Record { - const res = await fetch(`${getBase()}/v1/memory/index`, { + const res = await apiFetch(`/v1/memory/index`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), @@ -947,7 +991,7 @@ export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: n } export async function getMemoryConfig(): Promise { - const res = await fetch(`${getBase()}/v1/memory/config`); + const res = await apiFetch(`/v1/memory/config`); if (!res.ok) throw new Error('Failed to fetch memory config'); return res.json(); } @@ -969,19 +1013,19 @@ export interface PendingApproval { } export async function fetchPendingApprovals(): Promise { - const res = await fetch(`${getBase()}/v1/approvals/pending`); + const res = await apiFetch(`/v1/approvals/pending`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const data = await res.json(); return data.actions || []; } export async function approveAction(actionId: string): Promise { - const res = await fetch(`${getBase()}/v1/approvals/${actionId}/approve`, { method: 'POST' }); + const res = await apiFetch(`/v1/approvals/${actionId}/approve`, { method: 'POST' }); if (!res.ok) throw new Error(`Failed: ${res.status}`); } export async function denyAction(actionId: string): Promise { - const res = await fetch(`${getBase()}/v1/approvals/${actionId}/deny`, { method: 'POST' }); + const res = await apiFetch(`/v1/approvals/${actionId}/deny`, { method: 'POST' }); if (!res.ok) throw new Error(`Failed: ${res.status}`); } diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index af7d5a1e..d1a0775e 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -70,6 +70,10 @@ export type ThemeMode = 'light' | 'dark' | 'system'; interface Settings { theme: ThemeMode; apiUrl: string; + // Local server API key (OPENJARVIS_API_KEY). Sent as a Bearer token on + // /v1 + /api requests so a key-protected `jarvis serve` doesn't 401 the + // frontend (#266). Empty = no auth header (keyless local default). + apiKey: string; fontSize: 'small' | 'default' | 'large'; defaultModel: string; defaultAgent: string; @@ -82,6 +86,7 @@ function loadSettings(): Settings { const defaults: Settings = { theme: 'system', apiUrl: '', + apiKey: '', fontSize: 'default', defaultModel: '', defaultAgent: '', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 37788d97..951937cd 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -345,6 +345,21 @@ export function SettingsPage() { }} /> + + { updateSettings({ apiKey: e.target.value }); showSaved(); }} + placeholder="OPENJARVIS_API_KEY" + autoComplete="off" + className="text-sm px-3 py-1.5 rounded-lg outline-none w-56" + style={{ + background: 'var(--color-bg-secondary)', + color: 'var(--color-text)', + border: '1px solid var(--color-border)', + }} + /> + {/* Inference source */}