From ff6c7789bb4a01814707318923f074d5ec83d713 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:03:54 +0200 Subject: [PATCH 01/28] test(web): add vitest and Testing Library toolchain --- web/package-lock.json | 1028 +++++++++++++++++++++++++- web/package.json | 14 +- web/src/test/renderWithProviders.tsx | 53 ++ web/src/test/setup.ts | 37 + web/src/test/smoke.test.tsx | 11 + web/vite.config.ts | 10 +- 6 files changed, 1132 insertions(+), 21 deletions(-) create mode 100644 web/src/test/renderWithProviders.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/src/test/smoke.test.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 24d127f1..caba18b7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -40,6 +40,10 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.3.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.8", "@types/react": "^19.2.4", @@ -53,12 +57,21 @@ "eslint-plugin-react-refresh": "^0.4.14", "glob": "^12.0.0", "globals": "^15.12.0", + "jsdom": "^30.0.1", "postcss": "^8.5.1", "tailwindcss": "^4.3.3", "typescript": "^5.7.2", - "vite": "^8.0.5" + "vite": "^8.0.5", + "vitest": "^5.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", @@ -72,6 +85,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.3.tgz", + "integrity": "sha512-MDKmPBa6CfNN1zAJroTl7Uc+nBbcNtfE/F24iROfZAtcLffEeY3IbHR8XeHH5dJBspMYTpAsjEYmnp3tAmoVxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -351,6 +397,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -375,6 +434,146 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.2.tgz", + "integrity": "sha512-tL6LRF844sHjAKO7jt2w+uHo8Tocqpb9+KPUvIQETpR4RU87IaUzDuOV0QyzuQ37jmzFgwPHLLtLu/PDuH5qgg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.5.tgz", + "integrity": "sha512-Qqbp5pjbMwoaErP2t5qne5QDVDr3G5243oNW8ev5zfyyoqbzrQKbYDpKP/cuo3xBkzwqZqpP/rChrJhBdyTX7g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.2" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", @@ -530,6 +729,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", @@ -1977,6 +2194,104 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1988,6 +2303,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "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, + "license": "MIT" + }, + "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": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2248,6 +2581,13 @@ "dev": true, "license": "MIT" }, + "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2595,6 +2935,54 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2647,12 +3035,55 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2694,6 +3125,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2789,6 +3230,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2868,7 +3319,28 @@ "node": ">= 8" } }, - "node_modules/csstype": { + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", @@ -3061,6 +3533,35 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-arithmetic": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", @@ -3106,6 +3607,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3147,6 +3655,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz", @@ -3192,6 +3707,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3210,6 +3738,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3471,6 +4006,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3487,6 +4032,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3841,6 +4396,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3874,6 +4442,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3915,6 +4493,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3976,6 +4561,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4342,9 +4967,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -4369,6 +4994,16 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4388,6 +5023,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -4415,6 +5057,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -4513,6 +5165,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4570,6 +5236,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4614,6 +5293,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -4663,6 +5355,28 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "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, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4962,6 +5676,30 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -4996,6 +5734,19 @@ "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5067,6 +5818,26 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -5119,6 +5890,26 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5154,17 +5945,50 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", "dev": true, "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, "engines": { - "node": ">=12" + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=20" } }, "node_modules/ts-api-utils": { @@ -5230,6 +6054,16 @@ "react": ">=15.0.0" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -5387,17 +6221,110 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=12" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/warning": { @@ -5409,6 +6336,41 @@ "loose-envify": "^1.0.0" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5425,6 +6387,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5435,6 +6410,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 7fc99661..ba3809cd 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,11 @@ "build:static": "vite build --mode static", "build:check": "tsc && vite build", "preview": "vite preview", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "lint:auth": "eslint --report-unused-disable-directives --max-warnings 0 src/types/auth.ts src/lib/authApi.ts src/lib/authEvents.ts src/lib/api.ts src/contexts/AuthContext.tsx src/components/auth src/components/admin src/components/Toast.tsx src/components/Layout.tsx src/components/DemoBanner.tsx src/pages/Login.tsx src/pages/account src/pages/admin src/pages/Links.tsx src/pages/Locks.tsx src/components/LockActions.tsx src/App.tsx src/test", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.7.2", @@ -44,6 +48,10 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.3.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.8", "@types/react": "^19.2.4", @@ -57,9 +65,11 @@ "eslint-plugin-react-refresh": "^0.4.14", "glob": "^12.0.0", "globals": "^15.12.0", + "jsdom": "^30.0.1", "postcss": "^8.5.1", "tailwindcss": "^4.3.3", "typescript": "^5.7.2", - "vite": "^8.0.5" + "vite": "^8.0.5", + "vitest": "^5.0.0" } } diff --git a/web/src/test/renderWithProviders.tsx b/web/src/test/renderWithProviders.tsx new file mode 100644 index 00000000..bf96289b --- /dev/null +++ b/web/src/test/renderWithProviders.tsx @@ -0,0 +1,53 @@ +import type { ReactElement, ReactNode } from 'react' +import { render, type RenderOptions } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +interface Options extends Omit { + /** Initial URL pushed into the MemoryRouter. */ + route?: string +} + +/** Renders the current pathname + search so tests can assert on navigation. */ +export function LocationProbe() { + const location = useLocation() + return
{location.pathname + location.search}
+} + +// eslint-disable-next-line react-refresh/only-export-components -- test helper file, not subject to Fast Refresh +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }) +} + +/** + * Wraps `ui` in a MemoryRouter and a fresh QueryClient. The element is + * mounted at `route` on a catch-all route so `useNavigate` and `useLocation` + * work; `` is rendered alongside for navigation assertions. + */ +// eslint-disable-next-line react-refresh/only-export-components -- test helper file, not subject to Fast Refresh +export function renderWithProviders(ui: ReactElement, { route = '/', ...options }: Options = {}) { + const queryClient = createTestQueryClient() + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + {children} + + + } + /> + + + + ) + return { queryClient, ...render(ui, { wrapper: Wrapper, ...options }) } +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 00000000..cd28e1f7 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,37 @@ +import '@testing-library/jest-dom/vitest' +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' + +// jsdom lacks these browser APIs used by ThemeProvider and Layout. +if (typeof window.matchMedia !== 'function') { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as MediaQueryList +} + +if (typeof window.ResizeObserver !== 'function') { + class ResizeObserverStub { + observe() { + return undefined + } + unobserve() { + return undefined + } + disconnect() { + return undefined + } + } + window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver +} + +afterEach(() => { + cleanup() +}) diff --git a/web/src/test/smoke.test.tsx b/web/src/test/smoke.test.tsx new file mode 100644 index 00000000..12cb0557 --- /dev/null +++ b/web/src/test/smoke.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from './renderWithProviders' + +describe('test toolchain', () => { + it('renders inside router and query providers', () => { + renderWithProviders(

hello tracker

, { route: '/locks?x=1' }) + expect(screen.getByText('hello tracker')).toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/locks?x=1') + }) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 9d33774b..3343d092 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,4 +1,5 @@ -import { defineConfig, loadEnv } from 'vite' +import { defineConfig } from 'vitest/config' +import { loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' @@ -39,5 +40,12 @@ export default defineConfig(({ mode }) => { assetsDir: 'assets', sourcemap: mode !== 'production', }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + css: false, + restoreMocks: true, + }, } }) From e78d32c9f1a4998f05e7388f713fc30b473550ed Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:08:43 +0200 Subject: [PATCH 02/28] feat(web): add typed auth API client --- web/src/lib/authApi.test.ts | 88 +++++++++++++++ web/src/lib/authApi.ts | 217 ++++++++++++++++++++++++++++++++++++ web/src/types/auth.ts | 152 +++++++++++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 web/src/lib/authApi.test.ts create mode 100644 web/src/lib/authApi.ts create mode 100644 web/src/types/auth.ts diff --git a/web/src/lib/authApi.test.ts b/web/src/lib/authApi.test.ts new file mode 100644 index 00000000..6b2662ed --- /dev/null +++ b/web/src/lib/authApi.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AxiosError, AxiosHeaders, type InternalAxiosRequestConfig } from 'axios' +import axiosInstance from './api' +import { authApi, getApiErrorMessage, getApiErrorStatus } from './authApi' + +function axiosErrorWith(status: number, data: unknown) { + const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig + return new AxiosError('request failed', String(status), config, undefined, { + status, + statusText: '', + headers: {}, + config, + data, + }) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('authApi', () => { + it('posts credentials to /auth/login', async () => { + const post = vi.spyOn(axiosInstance, 'post').mockResolvedValue({ status: 204, data: '' }) + await authApi.login('alice', 's3cret-password') + expect(post).toHaveBeenCalledWith('/auth/login', { username: 'alice', password: 's3cret-password' }) + }) + + it('normalises the Me response into a Principal', async () => { + vi.spyOn(axiosInstance, 'get').mockResolvedValue({ + status: 200, + data: { + authenticated: true, + kind: 'user', + userId: '66f0', + username: 'alice', + displayName: '', + source: 'local', + teams: [{ id: 't1', name: 'Platform' }], + permissions: ['event:read'], + scopeAll: true, + mustChangePassword: false, + isAdmin: false, + }, + }) + const me = await authApi.me() + expect(me.permissions).toEqual(['event:read']) + expect(me.scopeServices).toEqual([]) + expect(me.teams[0].name).toBe('Platform') + expect(me.displayName).toBe('alice') + }) + + it('unwraps list responses', async () => { + vi.spyOn(axiosInstance, 'get').mockResolvedValue({ + status: 200, + data: { apiKeys: [{ id: 'k1', prefix: 'trk_abcd', name: 'ci', teamId: '', createdBy: 'admin' }] }, + }) + const keys = await authApi.listApiKeys() + expect(keys).toHaveLength(1) + expect(keys[0].prefix).toBe('trk_abcd') + }) + + it('sends PUT /auth/users/{id} with the full replacement body', async () => { + const put = vi.spyOn(axiosInstance, 'put').mockResolvedValue({ status: 200, data: { user: { id: 'u1' } } }) + await authApi.updateUser('u1', { email: 'a@x.io', displayName: 'Alice', teamIds: ['t1'], disabled: false }) + expect(put).toHaveBeenCalledWith('/auth/users/u1', { + email: 'a@x.io', + displayName: 'Alice', + teamIds: ['t1'], + disabled: false, + }) + }) +}) + +describe('getApiErrorMessage', () => { + it('reads the cookie endpoint {error} shape', () => { + expect(getApiErrorMessage(axiosErrorWith(401, { error: 'invalid credentials' }), 'fallback')).toBe('invalid credentials') + }) + + it('reads the gateway {message} shape', () => { + expect(getApiErrorMessage(axiosErrorWith(400, { code: 3, message: 'name is required' }), 'fallback')).toBe('name is required') + }) + + it('falls back for unknown errors', () => { + expect(getApiErrorMessage(new Error('boom'), 'fallback')).toBe('fallback') + expect(getApiErrorStatus(new Error('boom'))).toBeUndefined() + expect(getApiErrorStatus(axiosErrorWith(429, {}))).toBe(429) + }) +}) diff --git a/web/src/lib/authApi.ts b/web/src/lib/authApi.ts new file mode 100644 index 00000000..3c98c530 --- /dev/null +++ b/web/src/lib/authApi.ts @@ -0,0 +1,217 @@ +import { isAxiosError } from 'axios' +import axiosInstance from './api' +import type { + ApiKey, + AuthConfig, + CreateApiKeyInput, + CreateApiKeyResult, + CreateUserInput, + Principal, + PrincipalKind, + Team, + TeamInput, + UpdateUserInput, + User, +} from '../types/auth' + +// The gateway emits lowerCamelCase JSON. Arrays are normalised to [] and +// strings to '' so components never branch on undefined. +type Raw = Record + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +function bool(v: unknown): boolean { + return v === true +} + +function strings(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] +} + +function optStr(v: unknown): string | undefined { + return typeof v === 'string' && v !== '' ? v : undefined +} + +function toKind(v: unknown): PrincipalKind { + return v === 'user' || v === 'apikey' ? v : 'anonymous' +} + +export function toPrincipal(raw: Raw): Principal { + const username = str(raw.username) + const teams = Array.isArray(raw.teams) + ? (raw.teams as Raw[]).map((t) => ({ id: str(t.id), name: str(t.name) })) + : [] + return { + authenticated: bool(raw.authenticated), + kind: toKind(raw.kind), + userId: str(raw.userId), + username, + displayName: str(raw.displayName) || username, + source: str(raw.source), + teams, + permissions: strings(raw.permissions), + scopeAll: bool(raw.scopeAll), + scopeServices: strings(raw.scopeServices), + mustChangePassword: bool(raw.mustChangePassword), + isAdmin: bool(raw.isAdmin), + } +} + +function toConfig(raw: Raw): AuthConfig { + return { + localLoginEnabled: bool(raw.localLoginEnabled), + oidcEnabled: bool(raw.oidcEnabled), + oidcButtonLabel: str(raw.oidcButtonLabel) || 'Sign in with SSO', + anonymousPermissions: strings(raw.anonymousPermissions), + demoMode: bool(raw.demoMode), + } +} + +function toUser(raw: Raw): User { + return { + id: str(raw.id), + username: str(raw.username), + email: str(raw.email), + displayName: str(raw.displayName), + source: str(raw.source), + teamIds: strings(raw.teamIds), + disabled: bool(raw.disabled), + mustChangePassword: bool(raw.mustChangePassword), + createdAt: optStr(raw.createdAt), + lastLoginAt: optStr(raw.lastLoginAt), + } +} + +function toTeam(raw: Raw): Team { + return { + id: str(raw.id), + name: str(raw.name), + description: str(raw.description), + permissions: strings(raw.permissions), + scopeAll: bool(raw.scopeAll), + scopeServices: strings(raw.scopeServices), + oidcGroups: strings(raw.oidcGroups), + builtin: bool(raw.builtin), + } +} + +function toApiKey(raw: Raw): ApiKey { + return { + id: str(raw.id), + prefix: str(raw.prefix), + name: str(raw.name), + teamId: str(raw.teamId), + createdBy: str(raw.createdBy), + createdAt: optStr(raw.createdAt), + expiresAt: optStr(raw.expiresAt), + lastUsedAt: optStr(raw.lastUsedAt), + revokedAt: optStr(raw.revokedAt), + } +} + +function list(raw: Raw, key: string): Raw[] { + const v = raw[key] + return Array.isArray(v) ? (v as Raw[]) : [] +} + +export const authApi = { + getConfig: async (): Promise => { + const { data } = await axiosInstance.get('/auth/config') + return toConfig(data) + }, + + me: async (): Promise => { + const { data } = await axiosInstance.get('/auth/me') + return toPrincipal(data) + }, + + login: async (username: string, password: string): Promise => { + await axiosInstance.post('/auth/login', { username, password }) + }, + + logout: async (): Promise => { + await axiosInstance.post('/auth/logout') + }, + + changePassword: async (currentPassword: string, newPassword: string): Promise => { + await axiosInstance.post('/auth/password', { currentPassword, newPassword }) + }, + + listUsers: async (): Promise => { + const { data } = await axiosInstance.get('/auth/users') + return list(data, 'users').map(toUser) + }, + + createUser: async (input: CreateUserInput): Promise => { + const { data } = await axiosInstance.post('/auth/users', input) + return toUser((data.user as Raw) ?? {}) + }, + + updateUser: async (id: string, input: UpdateUserInput): Promise => { + const body: Raw = { + email: input.email, + displayName: input.displayName, + teamIds: input.teamIds, + disabled: input.disabled, + } + if (input.newPassword) body.newPassword = input.newPassword + const { data } = await axiosInstance.put(`/auth/users/${encodeURIComponent(id)}`, body) + return toUser((data.user as Raw) ?? {}) + }, + + listTeams: async (): Promise => { + const { data } = await axiosInstance.get('/auth/teams') + return list(data, 'teams').map(toTeam) + }, + + createTeam: async (input: TeamInput): Promise => { + const { data } = await axiosInstance.post('/auth/teams', input) + return toTeam((data.team as Raw) ?? {}) + }, + + updateTeam: async (id: string, input: TeamInput): Promise => { + const { data } = await axiosInstance.put(`/auth/teams/${encodeURIComponent(id)}`, input) + return toTeam((data.team as Raw) ?? {}) + }, + + deleteTeam: async (id: string): Promise => { + await axiosInstance.delete(`/auth/teams/${encodeURIComponent(id)}`) + }, + + listApiKeys: async (): Promise => { + const { data } = await axiosInstance.get('/auth/api-keys') + return list(data, 'apiKeys').map(toApiKey) + }, + + createApiKey: async (input: CreateApiKeyInput): Promise => { + const body: Raw = { name: input.name, teamId: input.teamId } + if (input.expiresAt) body.expiresAt = input.expiresAt + const { data } = await axiosInstance.post('/auth/api-keys', body) + return { apiKey: toApiKey((data.apiKey as Raw) ?? {}), secret: str(data.secret) } + }, + + revokeApiKey: async (id: string): Promise => { + await axiosInstance.delete(`/auth/api-keys/${encodeURIComponent(id)}`) + }, +} + +/** HTTP status of an axios error, undefined for anything else. */ +export function getApiErrorStatus(err: unknown): number | undefined { + return isAxiosError(err) ? err.response?.status : undefined +} + +/** + * Human message from either error shape the backend produces: + * `{error}` (cookie endpoints) or `{message}` (grpc-gateway). + */ +export function getApiErrorMessage(err: unknown, fallback: string): string { + if (!isAxiosError(err)) return fallback + const data = err.response?.data as Raw | undefined + if (data && typeof data === 'object') { + if (typeof data.error === 'string' && data.error) return data.error + if (typeof data.message === 'string' && data.message) return data.message + } + return fallback +} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts new file mode 100644 index 00000000..333119be --- /dev/null +++ b/web/src/types/auth.ts @@ -0,0 +1,152 @@ +export type Permission = + | 'event:read' + | 'event:write' + | 'catalog:read' + | 'catalog:write' + | 'lock:read' + | 'lock:write' + | 'links:read' + | 'links:write' + | 'access:manage' + +export const ALL_PERMISSIONS: Permission[] = [ + 'event:read', + 'event:write', + 'catalog:read', + 'catalog:write', + 'lock:read', + 'lock:write', + 'links:read', + 'links:write', + 'access:manage', +] + +export const PERMISSION_LABELS: Record = { + 'event:read': 'Read events, drifts and RPA operations', + 'event:write': 'Create and edit events, drifts and RPA operations', + 'catalog:read': 'Read the service catalog', + 'catalog:write': 'Create and edit catalog services', + 'lock:read': 'Read locks', + 'lock:write': 'Create and release locks', + 'links:read': 'Read links', + 'links:write': 'Create and edit links', + 'access:manage': 'Manage users, teams and API keys', +} + +/** Display grouping for the team dialog. */ +export const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [ + { label: 'Events', permissions: ['event:read', 'event:write'] }, + { label: 'Catalog', permissions: ['catalog:read', 'catalog:write'] }, + { label: 'Locks', permissions: ['lock:read', 'lock:write'] }, + { label: 'Links', permissions: ['links:read', 'links:write'] }, + { label: 'Administration', permissions: ['access:manage'] }, +] + +export interface AuthConfig { + localLoginEnabled: boolean + oidcEnabled: boolean + oidcButtonLabel: string + anonymousPermissions: string[] + demoMode: boolean +} + +export interface TeamRef { + id: string + name: string +} + +export type PrincipalKind = 'anonymous' | 'user' | 'apikey' + +export interface Principal { + authenticated: boolean + kind: PrincipalKind + userId: string + username: string + displayName: string + /** "local" or "oidc" for users, empty otherwise. */ + source: string + teams: TeamRef[] + permissions: string[] + scopeAll: boolean + scopeServices: string[] + mustChangePassword: boolean + isAdmin: boolean +} + +export interface User { + id: string + username: string + email: string + displayName: string + source: string + teamIds: string[] + disabled: boolean + mustChangePassword: boolean + createdAt?: string + lastLoginAt?: string +} + +export interface Team { + id: string + name: string + description: string + permissions: string[] + scopeAll: boolean + scopeServices: string[] + oidcGroups: string[] + builtin: boolean +} + +export interface ApiKey { + id: string + prefix: string + name: string + /** Empty string for a global key. */ + teamId: string + createdBy: string + createdAt?: string + expiresAt?: string + lastUsedAt?: string + revokedAt?: string +} + +export interface CreateUserInput { + username: string + email: string + displayName: string + password: string + teamIds: string[] +} + +/** UpdateUser replaces every field: callers send the full current state. */ +export interface UpdateUserInput { + email: string + displayName: string + teamIds: string[] + disabled: boolean + /** Optional password reset for local users. */ + newPassword?: string +} + +export interface TeamInput { + name: string + description: string + permissions: string[] + scopeAll: boolean + scopeServices: string[] + oidcGroups: string[] +} + +export interface CreateApiKeyInput { + name: string + /** Empty string requests a global key (admins only). */ + teamId: string + /** RFC3339 timestamp; omit for a key that never expires. */ + expiresAt?: string +} + +export interface CreateApiKeyResult { + apiKey: ApiKey + /** Shown exactly once; never stored. */ + secret: string +} From 580c3834be05ddf16288be0468b1bbd5b90520b4 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:13:43 +0200 Subject: [PATCH 03/28] feat(web): send session cookies and surface 401/403 as auth events --- web/src/components/Toast.tsx | 24 ++++++--- web/src/lib/api.ts | 17 ++++-- web/src/lib/authEvents.test.ts | 96 ++++++++++++++++++++++++++++++++++ web/src/lib/authEvents.ts | 87 ++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/authEvents.test.ts create mode 100644 web/src/lib/authEvents.ts diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index 4b9368f5..30a3b7e7 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -1,13 +1,21 @@ import { useEffect } from 'react' -import { CheckCircle, X } from 'lucide-react' +import { AlertCircle, CheckCircle, X } from 'lucide-react' + +export type ToastVariant = 'success' | 'error' interface ToastProps { message: string onClose: () => void duration?: number + variant?: ToastVariant +} + +const styles: Record = { + success: { box: 'bg-green-600', close: 'hover:text-green-100' }, + error: { box: 'bg-red-600', close: 'hover:text-red-100' }, } -export default function Toast({ message, onClose, duration = 3000 }: ToastProps) { +export default function Toast({ message, onClose, duration = 3000, variant = 'success' }: ToastProps) { useEffect(() => { const timer = setTimeout(() => { onClose() @@ -16,14 +24,18 @@ export default function Toast({ message, onClose, duration = 3000 }: ToastProps) return () => clearTimeout(timer) }, [duration, onClose]) + const Icon = variant === 'error' ? AlertCircle : CheckCircle + return ( -
-
- +
+
+ {message} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 90a8b837..ca86a6da 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2,6 +2,7 @@ import axios from 'axios' import type { CreateEventRequest, Event, ListEventsResponse, Catalog, ListCatalogsResponse } from '../types/api' import { staticEventsApi, staticCatalogApi, staticLocksApi } from './staticApi' import { convertCatalogForAPI, convertCatalogFromAPI, convertCommunicationChannelsFromAPI } from './apiConverters' +import { handleAuthError } from './authEvents' // Détecter si on est en mode statique (GitHub Pages) const isStaticMode = import.meta.env.VITE_STATIC_MODE === 'true' @@ -14,15 +15,23 @@ const getApiBaseUrl = () => { const axiosInstance = axios.create({ baseURL: getApiBaseUrl(), + // Sessions are carried by the HttpOnly "tracker_session" cookie. + withCredentials: true, headers: { 'Content-Type': 'application/json', - // Ajouter un token d'authentification si nécessaire - ...(import.meta.env.VITE_API_TOKEN && { - 'Authorization': `Bearer ${import.meta.env.VITE_API_TOKEN}` - }), }, }) +// 401 -> redirect to /login, 403 -> "Access denied" toast. Both are handled +// by AuthProvider through window events so this module stays router-free. +axiosInstance.interceptors.response.use( + (response) => response, + (error: unknown) => { + handleAuthError(error) + return Promise.reject(error) + }, +) + const realEventsApi = { list: async (params?: { perPage?: number; page?: number }) => { const { data } = await axiosInstance.get('/events/list', { params }) diff --git a/web/src/lib/authEvents.test.ts b/web/src/lib/authEvents.test.ts new file mode 100644 index 00000000..2269509f --- /dev/null +++ b/web/src/lib/authEvents.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AUTH_FORBIDDEN_EVENT, + AUTH_UNAUTHORIZED_EVENT, + handleAuthError, + loginPathFor, + onAuthEvent, + safeRedirect, + type AuthEventDetail, +} from './authEvents' + +function axiosLikeError(status: number, url: string) { + return { isAxiosError: true, response: { status }, config: { url, baseURL: '/api/v1alpha1' } } +} + +describe('handleAuthError', () => { + it('emits unauthorized for a 401 on a protected route', () => { + const handler = vi.fn() + const off = onAuthEvent(AUTH_UNAUTHORIZED_EVENT, handler) + handleAuthError(axiosLikeError(401, '/events/list')) + off() + expect(handler).toHaveBeenCalledTimes(1) + const detail = handler.mock.calls[0][0] as AuthEventDetail + expect(detail).toEqual({ url: '/events/list', status: 401 }) + }) + + it('emits forbidden for a 403', () => { + const handler = vi.fn() + const off = onAuthEvent(AUTH_FORBIDDEN_EVENT, handler) + handleAuthError(axiosLikeError(403, '/event')) + off() + expect(handler).toHaveBeenCalledWith({ url: '/event', status: 403 }) + }) + + it('ignores 401 from the login, password, me and config endpoints', () => { + const handler = vi.fn() + const off = onAuthEvent(AUTH_UNAUTHORIZED_EVENT, handler) + for (const url of ['/auth/login', '/auth/password', '/auth/me', '/auth/config']) { + handleAuthError(axiosLikeError(401, url)) + } + off() + expect(handler).not.toHaveBeenCalled() + }) + + it('ignores 403 from the login endpoint (cross-site refusal is shown inline)', () => { + const handler = vi.fn() + const off = onAuthEvent(AUTH_FORBIDDEN_EVENT, handler) + handleAuthError(axiosLikeError(403, '/auth/login')) + off() + expect(handler).not.toHaveBeenCalled() + }) + + it('ignores non-axios errors and other statuses', () => { + const handler = vi.fn() + const off1 = onAuthEvent(AUTH_UNAUTHORIZED_EVENT, handler) + const off2 = onAuthEvent(AUTH_FORBIDDEN_EVENT, handler) + handleAuthError(new Error('boom')) + handleAuthError(axiosLikeError(500, '/events/list')) + off1() + off2() + expect(handler).not.toHaveBeenCalled() + }) + + it('unsubscribes', () => { + const handler = vi.fn() + const off = onAuthEvent(AUTH_UNAUTHORIZED_EVENT, handler) + off() + handleAuthError(axiosLikeError(401, '/events/list')) + expect(handler).not.toHaveBeenCalled() + }) +}) + +describe('safeRedirect', () => { + it('accepts a relative path with a query string', () => { + expect(safeRedirect('/catalog/foo?tab=events')).toBe('/catalog/foo?tab=events') + }) + + it('rejects protocol-relative, absolute and login targets', () => { + expect(safeRedirect('//evil.example')).toBe('/dashboard') + expect(safeRedirect('https://evil.example/x')).toBe('/dashboard') + expect(safeRedirect('/login?redirect=/x')).toBe('/dashboard') + expect(safeRedirect('')).toBe('/dashboard') + expect(safeRedirect(null)).toBe('/dashboard') + expect(safeRedirect('/\\evil.example')).toBe('/dashboard') + }) +}) + +describe('loginPathFor', () => { + it('encodes the current location into the redirect parameter', () => { + expect(loginPathFor({ pathname: '/locks', search: '?env=prod' })).toBe('/login?redirect=%2Flocks%3Fenv%3Dprod') + }) + + it('does not chain redirects when already on the login page', () => { + expect(loginPathFor({ pathname: '/login', search: '?redirect=%2Fx' })).toBe('/login?redirect=%2Fx') + }) +}) diff --git a/web/src/lib/authEvents.ts b/web/src/lib/authEvents.ts new file mode 100644 index 00000000..553d8aee --- /dev/null +++ b/web/src/lib/authEvents.ts @@ -0,0 +1,87 @@ +// Bridges the axios layer (no React) and the AuthProvider (React): the +// interceptor emits window events, the provider listens and navigates. + +export const AUTH_UNAUTHORIZED_EVENT = 'tracker:auth:unauthorized' +export const AUTH_FORBIDDEN_EVENT = 'tracker:auth:forbidden' + +export interface AuthEventDetail { + /** Request URL relative to the axios baseURL, e.g. "/events/list". */ + url: string + status: number +} + +type AuthEventName = typeof AUTH_UNAUTHORIZED_EVENT | typeof AUTH_FORBIDDEN_EVENT + +/** 401 from these endpoints is a business answer handled by the calling form. */ +const UNAUTHORIZED_EXEMPT = ['/auth/login', '/auth/password', '/auth/me', '/auth/config'] +/** 403 from login is the cross-site guard; the login form shows it inline. */ +const FORBIDDEN_EXEMPT = ['/auth/login'] + +function emit(name: AuthEventName, detail: AuthEventDetail) { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent(name, { detail })) +} + +export function emitUnauthorized(url: string) { + emit(AUTH_UNAUTHORIZED_EVENT, { url, status: 401 }) +} + +export function emitForbidden(url: string) { + emit(AUTH_FORBIDDEN_EVENT, { url, status: 403 }) +} + +export function onAuthEvent(name: AuthEventName, handler: (detail: AuthEventDetail) => void): () => void { + const listener = (e: Event) => handler((e as CustomEvent).detail) + window.addEventListener(name, listener) + return () => window.removeEventListener(name, listener) +} + +interface AxiosLikeError { + isAxiosError?: boolean + response?: { status?: number } + config?: { url?: string; baseURL?: string } +} + +function relativeUrl(err: AxiosLikeError): string { + const url = err.config?.url ?? '' + const base = err.config?.baseURL ?? '' + return base && url.startsWith(base) ? url.slice(base.length) : url +} + +function matches(url: string, exempt: string[]): boolean { + const path = url.split('?')[0] + return exempt.some((e) => path === e || path.endsWith(e)) +} + +/** + * Inspects a rejected axios response and emits the matching auth event. + * Safe to call with any value; non-axios errors are ignored. + */ +export function handleAuthError(error: unknown): void { + if (!error || typeof error !== 'object') return + const err = error as AxiosLikeError + if (!err.isAxiosError || !err.response) return + const status = err.response.status + const url = relativeUrl(err) + if (status === 401 && !matches(url, UNAUTHORIZED_EXEMPT)) emitUnauthorized(url) + if (status === 403 && !matches(url, FORBIDDEN_EXEMPT)) emitForbidden(url) +} + +export const DEFAULT_AFTER_LOGIN = '/dashboard' + +/** + * Validates a `redirect` query parameter: same-origin relative path only, + * never the login page itself. Anything else falls back to the dashboard. + */ +export function safeRedirect(raw: string | null | undefined): string { + if (!raw) return DEFAULT_AFTER_LOGIN + if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return DEFAULT_AFTER_LOGIN + if (raw === '/login' || raw.startsWith('/login?') || raw.startsWith('/login/')) return DEFAULT_AFTER_LOGIN + return raw +} + +/** Login URL that brings the user back to `location` after signing in. */ +export function loginPathFor(location: { pathname: string; search: string }): string { + if (location.pathname === '/login') return `/login${location.search}` + return `/login?redirect=${encodeURIComponent(location.pathname + location.search)}` +} From 53f349068b17c60f6fa72b8afb8934448b2cf072 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:21:35 +0200 Subject: [PATCH 04/28] feat(web): add AuthProvider with session loading and 401/403 handling --- web/src/contexts/AuthContext.test.tsx | 146 ++++++++++++++++++++++++++ web/src/contexts/AuthContext.tsx | 139 ++++++++++++++++++++++++ web/src/types/auth.ts | 24 +++++ 3 files changed, 309 insertions(+) create mode 100644 web/src/contexts/AuthContext.test.tsx create mode 100644 web/src/contexts/AuthContext.tsx diff --git a/web/src/contexts/AuthContext.test.tsx b/web/src/contexts/AuthContext.test.tsx new file mode 100644 index 00000000..17f2aacc --- /dev/null +++ b/web/src/contexts/AuthContext.test.tsx @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../test/renderWithProviders' +import { AuthProvider, useAuth } from './AuthContext' +import { emitForbidden, emitUnauthorized } from '../lib/authEvents' +import type { AuthConfig, Principal } from '../types/auth' + +vi.mock('../lib/authApi', () => ({ + authApi: { + getConfig: vi.fn(), + me: vi.fn(), + logout: vi.fn(), + }, + getApiErrorStatus: (err: unknown) => + err && typeof err === 'object' && 'status' in err ? (err as { status: number }).status : undefined, +})) + +import { authApi } from '../lib/authApi' + +const mocked = authApi as unknown as { + getConfig: ReturnType + me: ReturnType + logout: ReturnType +} + +const config: AuthConfig = { + localLoginEnabled: true, + oidcEnabled: false, + oidcButtonLabel: 'Sign in with SSO', + anonymousPermissions: [], + demoMode: false, +} + +const alice: Principal = { + authenticated: true, + kind: 'user', + userId: 'u1', + username: 'alice', + displayName: 'Alice', + source: 'local', + teams: [{ id: 't1', name: 'Platform' }], + permissions: ['event:read', 'lock:read'], + scopeAll: false, + scopeServices: ['payments'], + mustChangePassword: false, + isAdmin: false, +} + +function Probe() { + const { status, principal, hasPermission, inScope } = useAuth() + return ( +
+ {status} + {principal.username || 'anonymous'} + {String(hasPermission('event:read'))} + {String(hasPermission('event:write'))} + {String(inScope('payments'))} + {String(inScope('billing'))} +
+ ) +} + +beforeEach(() => { + mocked.getConfig.mockResolvedValue(config) + mocked.me.mockResolvedValue(alice) + mocked.logout.mockResolvedValue(undefined) +}) + +describe('AuthProvider', () => { + it('loads the principal and answers permission and scope questions', async () => { + renderWithProviders( + + + , + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + expect(screen.getByTestId('user')).toHaveTextContent('alice') + expect(screen.getByTestId('event-read')).toHaveTextContent('true') + expect(screen.getByTestId('event-write')).toHaveTextContent('false') + expect(screen.getByTestId('scope-payments')).toHaveTextContent('true') + expect(screen.getByTestId('scope-billing')).toHaveTextContent('false') + }) + + it('treats scopeAll as every service', async () => { + mocked.me.mockResolvedValue({ ...alice, scopeAll: true, scopeServices: [] }) + renderWithProviders( + + + , + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + expect(screen.getByTestId('scope-billing')).toHaveTextContent('true') + }) + + it('falls back to the transitional anonymous principal when /auth/me is unreachable', async () => { + mocked.me.mockRejectedValue(new Error('network down')) + mocked.getConfig.mockRejectedValue(new Error('network down')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + renderWithProviders( + + + , + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + expect(screen.getByTestId('user')).toHaveTextContent('anonymous') + expect(screen.getByTestId('event-write')).toHaveTextContent('true') + expect(warn).toHaveBeenCalled() + }) + + it('redirects to /login with the current location on an unauthorized event', async () => { + renderWithProviders( + + + , + { route: '/locks?env=prod' }, + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + act(() => emitUnauthorized('/locks/list')) + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/login?redirect=%2Flocks%3Fenv%3Dprod'), + ) + }) + + it('does not redirect when already on the login page', async () => { + renderWithProviders( + + + , + { route: '/login?redirect=%2Fx' }, + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + act(() => emitUnauthorized('/events/list')) + expect(screen.getByTestId('location')).toHaveTextContent('/login?redirect=%2Fx') + }) + + it('shows an access denied toast on a forbidden event', async () => { + renderWithProviders( + + + , + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + act(() => emitForbidden('/event')) + expect(await screen.findByRole('status')).toHaveTextContent('Access denied') + }) +}) diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..f457b1d5 --- /dev/null +++ b/web/src/contexts/AuthContext.tsx @@ -0,0 +1,139 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { authApi, getApiErrorStatus } from '../lib/authApi' +import { + AUTH_FORBIDDEN_EVENT, + AUTH_UNAUTHORIZED_EVENT, + loginPathFor, + onAuthEvent, +} from '../lib/authEvents' +import Toast from '../components/Toast' +import type { AuthConfig, Permission, Principal } from '../types/auth' +import { ANONYMOUS_FALLBACK, DEFAULT_CONFIG } from '../types/auth' + +const isStaticMode = import.meta.env.VITE_STATIC_MODE === 'true' + +export interface AuthContextValue { + status: 'loading' | 'ready' + principal: Principal + config: AuthConfig + hasPermission: (perm: Permission | string) => boolean + inScope: (service: string) => boolean + logout: () => Promise + reload: () => Promise +} + +const AuthContext = createContext(undefined) + +function LoadingScreen() { + return ( +
+
+
+ ) +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate() + const location = useLocation() + const [status, setStatus] = useState<'loading' | 'ready'>(isStaticMode ? 'ready' : 'loading') + const [principal, setPrincipal] = useState(ANONYMOUS_FALLBACK) + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [toast, setToast] = useState(null) + // useLocation is refreshed on every render; the event handler reads the + // latest value through a ref so the listener is registered once. + const locationRef = useRef(location) + locationRef.current = location + + const reload = useCallback(async () => { + if (isStaticMode) return + const [cfg, me] = await Promise.allSettled([authApi.getConfig(), authApi.me()]) + if (cfg.status === 'fulfilled') setConfig(cfg.value) + if (me.status === 'fulfilled') { + setPrincipal(me.value) + } else { + const httpStatus = getApiErrorStatus(me.reason) + if (httpStatus === 401 || httpStatus === 403) { + // The backend refused an anonymous Me: no permission at all. + setPrincipal({ ...ANONYMOUS_FALLBACK, permissions: [] }) + } else { + console.warn('auth: /auth/me unreachable, using the transitional anonymous principal', me.reason) + setPrincipal(ANONYMOUS_FALLBACK) + } + } + setStatus('ready') + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + useEffect(() => { + const offUnauthorized = onAuthEvent(AUTH_UNAUTHORIZED_EVENT, () => { + const current = locationRef.current + if (current.pathname === '/login') return + navigate(loginPathFor(current), { replace: true }) + }) + const offForbidden = onAuthEvent(AUTH_FORBIDDEN_EVENT, () => { + setToast('Access denied: you do not have the required permission') + }) + return () => { + offUnauthorized() + offForbidden() + } + }, [navigate]) + + const hasPermission = useCallback( + (perm: Permission | string) => principal.permissions.includes(perm), + [principal.permissions], + ) + + const inScope = useCallback( + (service: string) => principal.scopeAll || principal.scopeServices.includes(service), + [principal.scopeAll, principal.scopeServices], + ) + + const logout = useCallback(async () => { + try { + await authApi.logout() + } finally { + await reload() + navigate('/login', { replace: true }) + } + }, [navigate, reload]) + + const closeToast = useCallback(() => setToast(null), []) + + const value = useMemo( + () => ({ status, principal, config, hasPermission, inScope, logout, reload }), + [status, principal, config, hasPermission, inScope, logout, reload], + ) + + return ( + + {status === 'loading' ? : children} + {toast && } + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components -- hook paired with its provider, not itself a component +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) { + throw new Error('useAuth must be used within an AuthProvider') + } + return ctx +} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 333119be..3fe0b96e 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -73,6 +73,30 @@ export interface Principal { isAdmin: boolean } +/** PR 1 transitional default: anonymous may do everything except administer access. */ +export const ANONYMOUS_FALLBACK: Principal = { + authenticated: false, + kind: 'anonymous', + userId: '', + username: '', + displayName: '', + source: '', + teams: [], + permissions: ALL_PERMISSIONS.filter((p) => p !== 'access:manage'), + scopeAll: true, + scopeServices: [], + mustChangePassword: false, + isAdmin: false, +} + +export const DEFAULT_CONFIG: AuthConfig = { + localLoginEnabled: true, + oidcEnabled: false, + oidcButtonLabel: 'Sign in with SSO', + anonymousPermissions: ANONYMOUS_FALLBACK.permissions, + demoMode: false, +} + export interface User { id: string username: string From 42c4871a0848ecf2cce9be967fdfb2b359dd78fd Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:27:33 +0200 Subject: [PATCH 05/28] feat(web): add permission guards for routes and actions --- web/src/components/auth/AccessDenied.tsx | 32 ++++++ web/src/components/auth/Can.tsx | 16 +++ web/src/components/auth/RequirePermission.tsx | 32 ++++++ web/src/components/auth/gating.test.tsx | 107 ++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 web/src/components/auth/AccessDenied.tsx create mode 100644 web/src/components/auth/Can.tsx create mode 100644 web/src/components/auth/RequirePermission.tsx create mode 100644 web/src/components/auth/gating.test.tsx diff --git a/web/src/components/auth/AccessDenied.tsx b/web/src/components/auth/AccessDenied.tsx new file mode 100644 index 00000000..94a564a8 --- /dev/null +++ b/web/src/components/auth/AccessDenied.tsx @@ -0,0 +1,32 @@ +import { Link } from 'react-router-dom' +import { ShieldOff } from 'lucide-react' + +interface AccessDeniedProps { + title?: string + message?: string +} + +export default function AccessDenied({ + title = 'Access denied', + message = 'Your account does not have the permission required for this page. Ask an administrator to add you to a team that grants it.', +}: AccessDeniedProps) { + return ( +
+
+ +

{title}

+

{message}

+ + Back to dashboard + +
+
+ ) +} diff --git a/web/src/components/auth/Can.tsx b/web/src/components/auth/Can.tsx new file mode 100644 index 00000000..51dd750d --- /dev/null +++ b/web/src/components/auth/Can.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' +import { useAuth } from '../../contexts/AuthContext' +import type { Permission } from '../../types/auth' + +interface CanProps { + perm: Permission + /** Rendered when the permission is missing. Defaults to nothing. */ + fallback?: ReactNode + children: ReactNode +} + +/** Renders its children only when the current principal holds `perm`. */ +export function Can({ perm, fallback = null, children }: CanProps) { + const { hasPermission } = useAuth() + return <>{hasPermission(perm) ? children : fallback} +} diff --git a/web/src/components/auth/RequirePermission.tsx b/web/src/components/auth/RequirePermission.tsx new file mode 100644 index 00000000..c6e317b2 --- /dev/null +++ b/web/src/components/auth/RequirePermission.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '../../contexts/AuthContext' +import { loginPathFor } from '../../lib/authEvents' +import type { Permission } from '../../types/auth' +import AccessDenied from './AccessDenied' + +interface RequirePermissionProps { + /** Permission the route needs. Omit to only require a signed-in user. */ + perm?: Permission + /** Require a signed-in user (session cookie), whatever the permissions. */ + user?: boolean + children: ReactNode +} + +/** + * Route guard. Anonymous visitors lacking access are sent to /login with a + * redirect back here; signed-in users lacking access see AccessDenied. + */ +export function RequirePermission({ perm, user = false, children }: RequirePermissionProps) { + const { principal, hasPermission } = useAuth() + const location = useLocation() + + const isUser = principal.kind === 'user' + const allowed = (perm ? hasPermission(perm) : true) && (user ? isUser : true) + + if (allowed) return <>{children} + if (!principal.authenticated) { + return + } + return +} diff --git a/web/src/components/auth/gating.test.tsx b/web/src/components/auth/gating.test.tsx new file mode 100644 index 00000000..e906926f --- /dev/null +++ b/web/src/components/auth/gating.test.tsx @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test/renderWithProviders' +import { Can } from './Can' +import { RequirePermission } from './RequirePermission' +import type { AuthContextValue } from '../../contexts/AuthContext' +import type { Principal } from '../../types/auth' + +const base: Principal = { + authenticated: false, + kind: 'anonymous', + userId: '', + username: '', + displayName: '', + source: '', + teams: [], + permissions: ['event:read'], + scopeAll: true, + scopeServices: [], + mustChangePassword: false, + isAdmin: false, +} + +let current: Principal = base + +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: (): AuthContextValue => ({ + status: 'ready', + principal: current, + config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, + hasPermission: (p: string) => current.permissions.includes(p), + inScope: () => true, + logout: async () => {}, + reload: async () => {}, + }), +})) + +describe('Can', () => { + it('renders children when the permission is granted', () => { + current = base + renderWithProviders( + + + , + ) + expect(screen.getByRole('button', { name: 'New Event' })).toBeInTheDocument() + }) + + it('renders the fallback otherwise', () => { + current = base + renderWithProviders( + read only}> + + , + ) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('read only')).toBeInTheDocument() + }) +}) + +describe('RequirePermission', () => { + it('renders the page when the permission is granted', () => { + current = base + renderWithProviders( + +

Timeline

+
, + { route: '/events/timeline' }, + ) + expect(screen.getByRole('heading', { name: 'Timeline' })).toBeInTheDocument() + }) + + it('sends an anonymous visitor to the login page with a redirect', () => { + current = base + renderWithProviders( + +

Locks

+
, + { route: '/locks?env=prod' }, + ) + expect(screen.queryByRole('heading')).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/login?redirect=%2Flocks%3Fenv%3Dprod') + }) + + it('shows access denied to an authenticated user without the permission', () => { + current = { ...base, authenticated: true, kind: 'user', username: 'bob' } + renderWithProviders( + +

Users

+
, + { route: '/admin/users' }, + ) + expect(screen.getByRole('heading', { name: 'Access denied' })).toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/admin/users') + }) + + it('requires a signed-in user when user is set', () => { + current = base + renderWithProviders( + +

Change password

+
, + { route: '/account/password' }, + ) + expect(screen.getByTestId('location')).toHaveTextContent('/login?redirect=%2Faccount%2Fpassword') + }) +}) From d19935a1842cdc14f3c8c8c157145730eca10bfd Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:33:01 +0200 Subject: [PATCH 06/28] feat(web): add login page --- web/src/pages/Login.test.tsx | 115 ++++++++++++++++++++++++++++ web/src/pages/Login.tsx | 143 +++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 web/src/pages/Login.test.tsx create mode 100644 web/src/pages/Login.tsx diff --git a/web/src/pages/Login.test.tsx b/web/src/pages/Login.test.tsx new file mode 100644 index 00000000..3b80f5a8 --- /dev/null +++ b/web/src/pages/Login.test.tsx @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test/renderWithProviders' +import Login from './Login' +import type { AuthContextValue } from '../contexts/AuthContext' +import type { Principal } from '../types/auth' + +const anonymous: Principal = { + authenticated: false, + kind: 'anonymous', + userId: '', + username: '', + displayName: '', + source: '', + teams: [], + permissions: [], + scopeAll: true, + scopeServices: [], + mustChangePassword: false, + isAdmin: false, +} + +const alice: Principal = { ...anonymous, authenticated: true, kind: 'user', userId: 'u1', username: 'alice', displayName: 'Alice', source: 'local' } + +const reload = vi.fn(async () => {}) +let oidcEnabled = false + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: (): AuthContextValue => ({ + status: 'ready', + principal: anonymous, + config: { localLoginEnabled: true, oidcEnabled, oidcButtonLabel: 'Sign in with Okta', anonymousPermissions: [], demoMode: false }, + hasPermission: () => false, + inScope: () => true, + logout: async () => {}, + reload, + }), +})) + +vi.mock('../lib/authApi', () => ({ + authApi: { login: vi.fn(), me: vi.fn() }, + getApiErrorStatus: (err: unknown) => (err as { status?: number } | null)?.status, + getApiErrorMessage: (err: unknown, fallback: string) => (err as { message?: string } | null)?.message || fallback, +})) + +import { authApi } from '../lib/authApi' + +const mocked = authApi as unknown as { login: ReturnType; me: ReturnType } + +beforeEach(() => { + oidcEnabled = false + reload.mockClear() + mocked.login.mockReset() + mocked.me.mockReset() + mocked.me.mockResolvedValue(alice) +}) + +async function fillAndSubmit(username: string, password: string) { + const user = userEvent.setup() + await user.type(screen.getByLabelText('Username'), username) + await user.type(screen.getByLabelText('Password'), password) + await user.click(screen.getByRole('button', { name: 'Sign in' })) +} + +describe('Login', () => { + it('signs in and follows the validated redirect', async () => { + mocked.login.mockResolvedValue(undefined) + renderWithProviders(, { route: '/login?redirect=%2Flocks%3Fenv%3Dprod' }) + await fillAndSubmit('alice', 'correct horse') + expect(mocked.login).toHaveBeenCalledWith('alice', 'correct horse') + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/locks?env=prod')) + expect(reload).toHaveBeenCalled() + }) + + it('ignores an external redirect target', async () => { + mocked.login.mockResolvedValue(undefined) + renderWithProviders(, { route: '/login?redirect=https%3A%2F%2Fevil.example' }) + await fillAndSubmit('alice', 'correct horse') + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/dashboard')) + }) + + it('shows a generic error on 401 and keeps the form', async () => { + mocked.login.mockRejectedValue({ status: 401, message: 'invalid credentials' }) + renderWithProviders(, { route: '/login' }) + await fillAndSubmit('alice', 'wrong') + expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password') + expect(screen.getByTestId('location')).toHaveTextContent('/login') + expect(reload).not.toHaveBeenCalled() + }) + + it('shows the rate limit message on 429', async () => { + mocked.login.mockRejectedValue({ status: 429 }) + renderWithProviders(, { route: '/login' }) + await fillAndSubmit('alice', 'wrong') + expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts') + }) + + it('sends a user who must change their password to the password page', async () => { + mocked.login.mockResolvedValue(undefined) + mocked.me.mockResolvedValue({ ...alice, mustChangePassword: true }) + renderWithProviders(, { route: '/login?redirect=%2Fcatalog' }) + await fillAndSubmit('admin', 'temporary') + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/account/password?redirect=%2Fcatalog'), + ) + }) + + it('offers the SSO button only when OIDC is enabled', () => { + oidcEnabled = true + renderWithProviders(, { route: '/login?redirect=%2Flocks' }) + const sso = screen.getByRole('link', { name: 'Sign in with Okta' }) + expect(sso).toHaveAttribute('href', '/api/v1alpha1/auth/oidc/login?redirect=%2Flocks') + }) +}) diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 00000000..78b921f0 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,143 @@ +import { useState, type FormEvent } from 'react' +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' +import { KeyRound, LogIn, Rocket } from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { authApi, getApiErrorMessage, getApiErrorStatus } from '../lib/authApi' +import { safeRedirect } from '../lib/authEvents' +import { Button } from '../components/ui/button' +import { Input } from '../components/ui/input' +import { Label } from '../components/ui/label' + +const OIDC_LOGIN_PATH = '/api/v1alpha1/auth/oidc/login' + +function messageFor(err: unknown): string { + switch (getApiErrorStatus(err)) { + case 401: + return 'Invalid username or password' + case 429: + return 'Too many attempts, wait a minute and try again' + case 403: + return 'Sign-in refused: open Tracker from its configured public URL' + default: + return getApiErrorMessage(err, 'Sign-in failed') + } +} + +export default function Login() { + const { principal, config, reload } = useAuth() + const navigate = useNavigate() + const [params] = useSearchParams() + const target = safeRedirect(params.get('redirect')) + + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + if (principal.kind === 'user') { + return + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setPending(true) + try { + await authApi.login(username.trim(), password) + const me = await authApi.me() + if (me.mustChangePassword) { + navigate(`/account/password?redirect=${encodeURIComponent(target)}`, { replace: true }) + return + } + await reload() + navigate(target, { replace: true }) + } catch (err) { + setError(messageFor(err)) + setPassword('') + } finally { + setPending(false) + } + } + + const ssoHref = `${OIDC_LOGIN_PATH}?redirect=${encodeURIComponent(target)}` + + return ( +
+
+
+
+ +
+
+

Sign in to Tracker

+

Use your account or your organisation SSO

+
+
+ + {config.oidcEnabled && ( + + + {config.oidcButtonLabel} + + )} + + {config.oidcEnabled && config.localLoginEnabled && ( +
+ + or + +
+ )} + + {config.localLoginEnabled && ( +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error && ( +

+ {error} +

+ )} + +
+ )} + + {!config.localLoginEnabled && !config.oidcEnabled && ( +

No sign-in method is enabled on this instance.

+ )} +
+
+ ) +} From 01938ae9dfd036e38d8b109e0d605fd215f3df7c Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:38:20 +0200 Subject: [PATCH 07/28] feat(web): add change password page --- web/src/pages/account/ChangePassword.test.tsx | 98 ++++++++++++ web/src/pages/account/ChangePassword.tsx | 144 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 web/src/pages/account/ChangePassword.test.tsx create mode 100644 web/src/pages/account/ChangePassword.tsx diff --git a/web/src/pages/account/ChangePassword.test.tsx b/web/src/pages/account/ChangePassword.test.tsx new file mode 100644 index 00000000..314620e6 --- /dev/null +++ b/web/src/pages/account/ChangePassword.test.tsx @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test/renderWithProviders' +import ChangePassword from './ChangePassword' +import type { AuthContextValue } from '../../contexts/AuthContext' +import type { Principal } from '../../types/auth' + +let current: Principal = { + authenticated: true, + kind: 'user', + userId: 'u1', + username: 'admin', + displayName: 'admin', + source: 'local', + teams: [], + permissions: ['access:manage'], + scopeAll: true, + scopeServices: [], + mustChangePassword: true, + isAdmin: true, +} +const reload = vi.fn(async () => {}) + +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: (): AuthContextValue => ({ + status: 'ready', + principal: current, + config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, + hasPermission: () => true, + inScope: () => true, + logout: async () => {}, + reload, + }), +})) + +vi.mock('../../lib/authApi', () => ({ + authApi: { changePassword: vi.fn() }, + getApiErrorStatus: (err: unknown) => (err as { status?: number } | null)?.status, + getApiErrorMessage: (err: unknown, fallback: string) => (err as { message?: string } | null)?.message || fallback, +})) + +import { authApi } from '../../lib/authApi' + +const mocked = authApi as unknown as { changePassword: ReturnType } + +beforeEach(() => { + reload.mockClear() + mocked.changePassword.mockReset() +}) + +async function submit(currentPw: string, next: string, confirm: string) { + const user = userEvent.setup() + await user.type(screen.getByLabelText('Current password'), currentPw) + await user.type(screen.getByLabelText('New password'), next) + await user.type(screen.getByLabelText('Confirm new password'), confirm) + await user.click(screen.getByRole('button', { name: 'Change password' })) +} + +describe('ChangePassword', () => { + it('changes the password and follows the redirect', async () => { + mocked.changePassword.mockResolvedValue(undefined) + renderWithProviders(, { route: '/account/password?redirect=%2Fcatalog' }) + expect(screen.getByText(/must choose a new password/i)).toBeInTheDocument() + await submit('temporary-pass', 'a-much-longer-secret', 'a-much-longer-secret') + expect(mocked.changePassword).toHaveBeenCalledWith('temporary-pass', 'a-much-longer-secret') + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/catalog')) + expect(reload).toHaveBeenCalled() + }) + + it('rejects a mismatching confirmation without calling the API', async () => { + renderWithProviders(, { route: '/account/password' }) + await submit('temporary-pass', 'a-much-longer-secret', 'a-much-longer-secreT') + expect(await screen.findByRole('alert')).toHaveTextContent('do not match') + expect(mocked.changePassword).not.toHaveBeenCalled() + }) + + it('rejects a short password without calling the API', async () => { + renderWithProviders(, { route: '/account/password' }) + await submit('temporary-pass', 'short', 'short') + expect(await screen.findByRole('alert')).toHaveTextContent('at least 12 characters') + expect(mocked.changePassword).not.toHaveBeenCalled() + }) + + it('shows the backend answer for a wrong current password', async () => { + mocked.changePassword.mockRejectedValue({ status: 401, message: 'current password is incorrect' }) + renderWithProviders(, { route: '/account/password' }) + await submit('nope-not-it-really', 'a-much-longer-secret', 'a-much-longer-secret') + expect(await screen.findByRole('alert')).toHaveTextContent('Current password is incorrect') + }) + + it('hides the form for identity provider accounts', () => { + current = { ...current, source: 'oidc', mustChangePassword: false } + renderWithProviders(, { route: '/account/password' }) + expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument() + expect(screen.getByText(/managed by your identity provider/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/account/ChangePassword.tsx b/web/src/pages/account/ChangePassword.tsx new file mode 100644 index 00000000..c2b6b6cd --- /dev/null +++ b/web/src/pages/account/ChangePassword.tsx @@ -0,0 +1,144 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { KeyRound, ShieldAlert } from 'lucide-react' +import { useAuth } from '../../contexts/AuthContext' +import { authApi, getApiErrorMessage, getApiErrorStatus } from '../../lib/authApi' +import { safeRedirect } from '../../lib/authEvents' +import { Button } from '../../components/ui/button' +import { Input } from '../../components/ui/input' +import { Label } from '../../components/ui/label' + +/** Mirrors the PR 1 password policy minimum; the backend stays authoritative. */ +const MIN_PASSWORD_LENGTH = 12 + +function messageFor(err: unknown): string { + switch (getApiErrorStatus(err)) { + case 401: + return 'Current password is incorrect' + case 400: + return getApiErrorMessage(err, 'The new password was rejected') + default: + return getApiErrorMessage(err, 'Could not change the password') + } +} + +export default function ChangePassword() { + const { principal, reload } = useAuth() + const navigate = useNavigate() + const [params] = useSearchParams() + const target = safeRedirect(params.get('redirect')) + + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + const isLocal = principal.source === 'local' + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + if (newPassword.length < MIN_PASSWORD_LENGTH) { + setError(`The new password must be at least ${MIN_PASSWORD_LENGTH} characters long`) + return + } + if (newPassword !== confirm) { + setError('The new password and its confirmation do not match') + return + } + if (newPassword === currentPassword) { + setError('The new password must differ from the current one') + return + } + setPending(true) + try { + await authApi.changePassword(currentPassword, newPassword) + await reload() + navigate(target, { replace: true }) + } catch (err) { + setError(messageFor(err)) + setCurrentPassword('') + } finally { + setPending(false) + } + } + + return ( +
+
+
+ +

Change password

+
+

+ Signed in as {principal.username} +

+ + {!isLocal ? ( +

+ Your password is managed by your identity provider. Change it there. +

+ ) : ( +
+ {principal.mustChangePassword && ( +
+ + You must choose a new password before continuing. +
+ )} +
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +

At least {MIN_PASSWORD_LENGTH} characters.

+
+
+ + setConfirm(e.target.value)} + /> +
+ {error && ( +

+ {error} +

+ )} + +
+ )} +
+
+ ) +} From d90db29dcadea6192594a317f12f194ba2c38993 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 18:44:34 +0200 Subject: [PATCH 08/28] feat(web): permission-aware navigation and user menu --- web/src/components/DemoBanner.tsx | 22 +++- web/src/components/Layout.tsx | 145 +++++++++++++--------- web/src/components/auth/UserMenu.test.tsx | 65 ++++++++++ web/src/components/auth/UserMenu.tsx | 128 +++++++++++++++++++ 4 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 web/src/components/auth/UserMenu.test.tsx create mode 100644 web/src/components/auth/UserMenu.tsx diff --git a/web/src/components/DemoBanner.tsx b/web/src/components/DemoBanner.tsx index e07f83c9..0af57101 100644 --- a/web/src/components/DemoBanner.tsx +++ b/web/src/components/DemoBanner.tsx @@ -1,16 +1,20 @@ import { Coffee, Heart } from 'lucide-react' +import { Link } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' export default function DemoBanner() { // Vérifier si le mode démo est activé (runtime config ou env var) - const isDemoMode = - window.TRACKER_CONFIG?.demoMode || + const isDemoMode = + window.TRACKER_CONFIG?.demoMode || import.meta.env.VITE_DEMO_MODE === 'true' - - const buyMeCoffeeUrl = - window.TRACKER_CONFIG?.buyMeCoffeeUrl || - import.meta.env.VITE_BUY_ME_COFFEE_URL || + + const buyMeCoffeeUrl = + window.TRACKER_CONFIG?.buyMeCoffeeUrl || + import.meta.env.VITE_BUY_ME_COFFEE_URL || 'https://www.buymeacoffee.com/bananaops' + const { principal } = useAuth() + if (!isDemoMode) { return null } @@ -26,6 +30,12 @@ export default function DemoBanner() { This is a demo instance of Tracker - Try it out! + {principal.kind !== 'user' && ( + <> + {' '}Browsing is read-only:{' '} + sign in to create or edit. + + )}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index a7172a49..8ddd9dc6 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,9 +1,9 @@ -import { Outlet, Link, useLocation } from 'react-router-dom' -import { Calendar, Clock, Table, GitBranch, Bot, LayoutDashboard, Rocket, Package, AlertTriangle, BookOpen, MessageSquare, Lock, BarChart3, ChevronLeft, ChevronRight, Link as LinkIcon, Plus } from 'lucide-react' +import { Outlet, Link, useLocation, Navigate } from 'react-router-dom' +import { Calendar, Clock, Table, GitBranch, Bot, LayoutDashboard, Rocket, Package, AlertTriangle, BookOpen, MessageSquare, Lock, BarChart3, ChevronLeft, ChevronRight, Link as LinkIcon, Plus, Users, Shield, KeyRound } from 'lucide-react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCodeBranch } from '@fortawesome/free-solid-svg-icons' import { faRobot } from '@fortawesome/free-solid-svg-icons' -import { useState, useRef, useEffect, type MouseEvent } from 'react' +import { useState, useRef, useEffect, type MouseEvent, type ComponentType } from 'react' import ThemeToggle from './ThemeToggle' import OpenSourceBanner from './OpenSourceBanner' import StaticModeBanner from './StaticModeBanner' @@ -13,41 +13,66 @@ import LinksSearch from './LinksSearch' import CreatePanelHost from './CreatePanelHost' import { useCreatePanel } from '../contexts/CreatePanelContext' import { getSlackEventsChannelUrl } from '../config' +import { useAuth } from '../contexts/AuthContext' +import { Can } from './auth/Can' +import { UserMenu } from './auth/UserMenu' +import type { Permission } from '../types/auth' -const navigationSections = [ +interface NavItem { + name: string + href: string + icon: ComponentType<{ className?: string }> + /** Hidden from the sidebar when the principal lacks it. Public when omitted. */ + permission?: Permission +} + +interface NavSection { + label: string + items: NavItem[] +} + +const navigationSections: NavSection[] = [ { label: 'Operations', items: [ - { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { name: 'Timeline', href: '/events/timeline', icon: Clock }, - { name: 'Streamline', href: '/events/streamline', icon: Package }, - { name: 'Calendar', href: '/events/calendar', icon: Calendar }, - { name: 'Overlaps', href: '/events/overlaps', icon: AlertTriangle }, - { name: 'Insights', href: '/insights', icon: BarChart3 }, + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: 'event:read' }, + { name: 'Timeline', href: '/events/timeline', icon: Clock, permission: 'event:read' }, + { name: 'Streamline', href: '/events/streamline', icon: Package, permission: 'event:read' }, + { name: 'Calendar', href: '/events/calendar', icon: Calendar, permission: 'event:read' }, + { name: 'Overlaps', href: '/events/overlaps', icon: AlertTriangle, permission: 'event:read' }, + { name: 'Insights', href: '/insights', icon: BarChart3, permission: 'event:read' }, ], }, { label: 'Services', items: [ - { name: 'Catalog', href: '/catalog', icon: Table }, - { name: 'Architecture', href: '/catalog/dependencies', icon: ({ className }: { className?: string }) => }, + { name: 'Catalog', href: '/catalog', icon: Table, permission: 'catalog:read' }, + { name: 'Architecture', href: '/catalog/dependencies', icon: ({ className }: { className?: string }) => , permission: 'catalog:read' }, ], }, { label: 'Infrastructure', items: [ - { name: 'Drifts', href: '/drifts', icon: GitBranch }, - { name: 'RPA Usage', href: '/rpa', icon: Bot }, - { name: 'Locks', href: '/locks', icon: Lock }, + { name: 'Drifts', href: '/drifts', icon: GitBranch, permission: 'event:read' }, + { name: 'RPA Usage', href: '/rpa', icon: Bot, permission: 'event:read' }, + { name: 'Locks', href: '/locks', icon: Lock, permission: 'lock:read' }, ], }, { label: 'Resources', items: [ - { name: 'Links', href: '/links', icon: LinkIcon }, + { name: 'Links', href: '/links', icon: LinkIcon, permission: 'links:read' }, { name: 'Docs', href: '/docs', icon: BookOpen }, ], }, + { + label: 'Administration', + items: [ + { name: 'Users', href: '/admin/users', icon: Users, permission: 'access:manage' }, + { name: 'Teams', href: '/admin/teams', icon: Shield, permission: 'access:manage' }, + { name: 'API keys', href: '/admin/api-keys', icon: KeyRound, permission: 'access:manage' }, + ], + }, ] export default function Layout() { @@ -56,6 +81,13 @@ export default function Layout() { const { open } = useCreatePanel() const bannersRef = useRef(null) const [bannersHeight, setBannersHeight] = useState(0) + const { principal, hasPermission } = useAuth() + const visibleSections = navigationSections + .map((section) => ({ + ...section, + items: section.items.filter((item) => !item.permission || hasPermission(item.permission)), + })) + .filter((section) => section.items.length > 0) // Mesurer la hauteur des banneaux et la mettre à jour si elle change useEffect(() => { @@ -75,6 +107,10 @@ export default function Layout() { const navActive = 'bg-white/10 text-white' const navInactive = 'text-white/45 hover:text-white/80 hover:bg-white/6' + if (principal.mustChangePassword && location.pathname !== '/account/password') { + return + } + return (
{/* Banneaux sticky en haut */} @@ -130,7 +166,7 @@ export default function Layout() { {/* Navigation */}
{!isEditing && (
- - - + + + + + + +
)} - {isLocked ? ( - - ) : ( - - )} + + {isLocked ? ( + + ) : ( + + )} +
) } diff --git a/web/src/pages/CatalogDetail.tsx b/web/src/pages/CatalogDetail.tsx index 63da7740..ee9a3f8e 100644 --- a/web/src/pages/CatalogDetail.tsx +++ b/web/src/pages/CatalogDetail.tsx @@ -50,6 +50,7 @@ import UsedDeliverablesManager from '../components/UsedDeliverablesManager' import VulnerabilityManager from '../components/VulnerabilityManager' import InfrastructureResourceManager from '../components/InfrastructureResourceManager' import type { InfrastructureResource } from '../types/api' +import { Can } from '../components/auth/Can' // Provider Colors // AWS: Database: #C925D1, Storage: #7AA116, Network: #8C4FFF, Compute: #ED7100, Messaging: #E7157B, Security: #DD344C @@ -938,22 +939,24 @@ export default function CatalogDetail({ serviceNameProp, onClose }: CatalogDetai

- - + + + + ) : ( - + + + )}
diff --git a/web/src/pages/EventDetail.tsx b/web/src/pages/EventDetail.tsx index ce683d31..a421d109 100644 --- a/web/src/pages/EventDetail.tsx +++ b/web/src/pages/EventDetail.tsx @@ -13,6 +13,7 @@ import EventChangelog from '../components/EventChangelog' import LockIndicator from '../components/LockIndicator' import { Button } from '../components/ui/button' import { Badge } from '../components/ui/badge' +import { Can } from '../components/auth/Can' export default function EventDetail() { const { eventId } = useParams<{ eventId: string }>() @@ -197,14 +198,16 @@ export default function EventDetail() {
{!isEditing ? ( <> - + + + ) : ( <> diff --git a/web/src/pages/Links.tsx b/web/src/pages/Links.tsx index 88d1ed47..ae02961a 100644 --- a/web/src/pages/Links.tsx +++ b/web/src/pages/Links.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { getLinksConfig, getHomerUrl, type LinkGroup, type LinkItem } from '../config' import { linksApi, type StoredLink } from '../lib/linksApi' import LinkFormDialog from '../components/LinkFormDialog' +import { Can } from '../components/auth/Can' interface HomerLink { name: string; url: string; icon?: string; logo?: string } interface HomerServiceItem { name: string; url: string; subtitle?: string; logo?: string; icon?: string } @@ -182,10 +183,12 @@ export default function Links() { Refresh )} - + + +
@@ -371,20 +374,22 @@ function LinkRow({ item, stored, onEdit, onDelete }: LinkRowProps) { )} {stored ? (
- - + + + +
) : !item._fromHomer && ( diff --git a/web/src/pages/Locks.tsx b/web/src/pages/Locks.tsx index 3bd79510..29c13fb1 100644 --- a/web/src/pages/Locks.tsx +++ b/web/src/pages/Locks.tsx @@ -5,6 +5,7 @@ import { Lock as LockIcon, Unlock, RefreshCw, AlertCircle, Eye } from 'lucide-re import { getEnvironmentLabel } from '../lib/eventUtils' import { EnvBadge } from '../components/Badges' import { useCreatePanel } from '../contexts/CreatePanelContext' +import { Can } from '../components/auth/Can' export default function Locks() { const navigate = useNavigate() @@ -228,14 +229,16 @@ export default function Locks() { View Event )} - + + + From d78b09c09a4bf3911703a9574fb8b682860fcf8b Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:11:54 +0200 Subject: [PATCH 11/28] feat(web): add users administration page --- web/src/components/admin/ChipInput.test.tsx | 23 ++++ web/src/components/admin/ChipInput.tsx | 58 +++++++++ web/src/components/admin/ConfirmDialog.tsx | 35 ++++++ web/src/components/admin/UserFormDialog.tsx | 133 ++++++++++++++++++++ web/src/components/admin/adminUi.tsx | 74 +++++++++++ web/src/pages/admin/UsersPage.test.tsx | 116 +++++++++++++++++ web/src/pages/admin/UsersPage.tsx | 111 +++++++++++++++- 7 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 web/src/components/admin/ChipInput.test.tsx create mode 100644 web/src/components/admin/ChipInput.tsx create mode 100644 web/src/components/admin/ConfirmDialog.tsx create mode 100644 web/src/components/admin/UserFormDialog.tsx create mode 100644 web/src/components/admin/adminUi.tsx create mode 100644 web/src/pages/admin/UsersPage.test.tsx diff --git a/web/src/components/admin/ChipInput.test.tsx b/web/src/components/admin/ChipInput.test.tsx new file mode 100644 index 00000000..eea00792 --- /dev/null +++ b/web/src/components/admin/ChipInput.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ChipInput } from './ChipInput' + +describe('ChipInput', () => { + it('adds trimmed unique values on Enter and comma', async () => { + const onChange = vi.fn() + render() + const user = userEvent.setup() + await user.type(screen.getByRole('textbox'), ' platform {Enter}') + expect(onChange).toHaveBeenLastCalledWith(['ops', 'platform']) + await user.type(screen.getByRole('textbox'), 'ops,') + expect(onChange).toHaveBeenLastCalledWith(['ops']) + }) + + it('removes a value from its chip', async () => { + const onChange = vi.fn() + render() + await userEvent.setup().click(screen.getByRole('button', { name: 'Remove ops' })) + expect(onChange).toHaveBeenCalledWith(['platform']) + }) +}) diff --git a/web/src/components/admin/ChipInput.tsx b/web/src/components/admin/ChipInput.tsx new file mode 100644 index 00000000..17b46131 --- /dev/null +++ b/web/src/components/admin/ChipInput.tsx @@ -0,0 +1,58 @@ +import { useState, type KeyboardEvent } from 'react' +import { X } from 'lucide-react' +import { Input } from '../ui/input' + +interface ChipInputProps { + id: string + values: string[] + onChange: (values: string[]) => void + placeholder?: string + disabled?: boolean +} + +/** Free-text list editor: Enter or comma adds a value, chips remove one. */ +export function ChipInput({ id, values, onChange, placeholder = 'Type and press Enter', disabled = false }: ChipInputProps) { + const [draft, setDraft] = useState('') + + const commit = () => { + const v = draft.trim() + setDraft('') + if (!v) return + onChange(values.includes(v) ? values : [...values, v]) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + commit() + } + } + + return ( +
+ {values.length > 0 && ( +
+ {values.map((v) => ( + + {v} + {!disabled && ( + + )} + + ))} +
+ )} + setDraft(e.target.value)} + onKeyDown={onKeyDown} + onBlur={commit} + /> +
+ ) +} diff --git a/web/src/components/admin/ConfirmDialog.tsx b/web/src/components/admin/ConfirmDialog.tsx new file mode 100644 index 00000000..6a9e46da --- /dev/null +++ b/web/src/components/admin/ConfirmDialog.tsx @@ -0,0 +1,35 @@ +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' +import { Button } from '../ui/button' + +interface ConfirmDialogProps { + open: boolean + title: string + message: string + confirmLabel?: string + destructive?: boolean + pending?: boolean + onConfirm: () => void + onClose: () => void +} + +export function ConfirmDialog({ open, title, message, confirmLabel = 'Confirm', destructive = false, pending = false, onConfirm, onClose }: ConfirmDialogProps) { + return ( + + + + {title} + + +
+

{message}

+
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/admin/UserFormDialog.tsx b/web/src/components/admin/UserFormDialog.tsx new file mode 100644 index 00000000..2bab8181 --- /dev/null +++ b/web/src/components/admin/UserFormDialog.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Checkbox } from '../ui/checkbox' +import { Label } from '../ui/label' +import { FieldRow, FormError } from './adminUi' +import type { CreateUserInput, Team, UpdateUserInput, User } from '../../types/auth' + +const MIN_PASSWORD_LENGTH = 12 + +interface UserFormDialogProps { + open: boolean + /** undefined = create mode. */ + user?: User + teams: Team[] + /** Id of the signed-in user: their own account cannot be disabled here. */ + currentUserId: string + pending: boolean + error: string | null + onCreate: (input: CreateUserInput) => void + onUpdate: (id: string, input: UpdateUserInput) => void + onClose: () => void +} + +export function UserFormDialog({ open, user, teams, currentUserId, pending, error, onCreate, onUpdate, onClose }: UserFormDialogProps) { + const isEdit = user !== undefined + const isLocal = !isEdit || user.source === 'local' + const isSelf = isEdit && user.id === currentUserId + + const [username, setUsername] = useState('') + const [email, setEmail] = useState('') + const [displayName, setDisplayName] = useState('') + const [password, setPassword] = useState('') + const [teamIds, setTeamIds] = useState([]) + const [disabled, setDisabled] = useState(false) + const [localError, setLocalError] = useState(null) + + // Reset the form each time the dialog opens for a (different) user. + useEffect(() => { + if (!open) return + setUsername(user?.username ?? '') + setEmail(user?.email ?? '') + setDisplayName(user?.displayName ?? '') + setPassword('') + setTeamIds(user?.teamIds ?? []) + setDisabled(user?.disabled ?? false) + setLocalError(null) + }, [open, user]) + + const toggleTeam = (id: string, checked: boolean) => { + setTeamIds((ids) => (checked ? (ids.includes(id) ? ids : [...ids, id]) : ids.filter((x) => x !== id))) + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setLocalError(null) + if (password && password.length < MIN_PASSWORD_LENGTH) { + setLocalError(`The password must be at least ${MIN_PASSWORD_LENGTH} characters long`) + return + } + if (isEdit) { + const input: UpdateUserInput = { email, displayName, teamIds, disabled } + if (isLocal && password) input.newPassword = password + onUpdate(user.id, input) + return + } + if (!username.trim() || !password) { + setLocalError('Username and temporary password are required') + return + } + onCreate({ username: username.trim(), email: email.trim(), displayName: displayName.trim(), password, teamIds }) + } + + return ( + + + + {isEdit ? `Edit ${user.username}` : 'New user'} + + +
+ {isEdit ? ( + + + + ) : ( + + setUsername(e.target.value)} /> + + )} + + setEmail(e.target.value)} /> + + + setDisplayName(e.target.value)} /> + + {isLocal && ( + + setPassword(e.target.value)} /> + + )} +
+ Teams + {teams.length === 0 &&

No team yet.

} + {teams.map((t) => ( +
+ toggleTeam(t.id, c)} /> + +
+ ))} +
+ {isEdit && ( +
+ + + {isSelf && (you cannot disable your own account)} +
+ )} + +
+ + +
+ +
+
+ ) +} diff --git a/web/src/components/admin/adminUi.tsx b/web/src/components/admin/adminUi.tsx new file mode 100644 index 00000000..28633dfc --- /dev/null +++ b/web/src/components/admin/adminUi.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from 'react' +import { Label } from '../ui/label' + +export function AdminTable({ columns, children }: { columns: string[]; children: ReactNode }) { + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + {children} +
+ {c} +
+
+ ) +} + +export function Row({ children }: { children: ReactNode }) { + return {children} +} + +export function Cell({ children, className = '' }: { children: ReactNode; className?: string }) { + return {children} +} + +export function EmptyRow({ colSpan, message }: { colSpan: number; message: string }) { + return ( + + + {message} + + + ) +} + +export function FormError({ message }: { message: string | null }) { + if (!message) return null + return ( +

+ {message} +

+ ) +} + +export function FieldRow({ id, label, hint, children }: { id: string; label: string; hint?: string; children: ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +/** "Never" for missing timestamps, otherwise a compact local date-time. */ +// eslint-disable-next-line react-refresh/only-export-components -- shared admin table/form helper file, not itself a component +export function formatDate(iso?: string): string { + if (!iso) return 'Never' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +} + +// eslint-disable-next-line react-refresh/only-export-components -- shared query keys, not a component +export const QUERY_KEYS = { + users: ['auth', 'users'] as const, + teams: ['auth', 'teams'] as const, + apiKeys: ['auth', 'api-keys'] as const, +} diff --git a/web/src/pages/admin/UsersPage.test.tsx b/web/src/pages/admin/UsersPage.test.tsx new file mode 100644 index 00000000..d0c9e7a2 --- /dev/null +++ b/web/src/pages/admin/UsersPage.test.tsx @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test/renderWithProviders' +import UsersPage from './UsersPage' +import type { AuthContextValue } from '../../contexts/AuthContext' +import type { Team, User } from '../../types/auth' + +const teams: Team[] = [ + { id: 't-admin', name: 'Administrators', description: '', permissions: ['access:manage'], scopeAll: true, scopeServices: [], oidcGroups: [], builtin: true }, + { id: 't-plat', name: 'Platform', description: '', permissions: ['event:read'], scopeAll: true, scopeServices: [], oidcGroups: [], builtin: false }, +] +const users: User[] = [ + { id: 'u-admin', username: 'admin', email: '', displayName: 'admin', source: 'local', teamIds: ['t-admin'], disabled: false, mustChangePassword: false }, + { id: 'u-bob', username: 'bob', email: 'bob@x.io', displayName: 'Bob', source: 'oidc', teamIds: ['t-plat'], disabled: true, mustChangePassword: false }, +] + +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: (): AuthContextValue => ({ + status: 'ready', + principal: { authenticated: true, kind: 'user', userId: 'u-admin', username: 'admin', displayName: 'admin', source: 'local', teams: [{ id: 't-admin', name: 'Administrators' }], permissions: ['access:manage'], scopeAll: true, scopeServices: [], mustChangePassword: false, isAdmin: true }, + config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, + hasPermission: () => true, + inScope: () => true, + logout: async () => {}, + reload: async () => {}, + }), +})) + +vi.mock('../../lib/authApi', () => ({ + authApi: { listUsers: vi.fn(), listTeams: vi.fn(), createUser: vi.fn(), updateUser: vi.fn() }, + getApiErrorStatus: () => undefined, + getApiErrorMessage: (err: unknown, fallback: string) => (err as { message?: string } | null)?.message || fallback, +})) + +import { authApi } from '../../lib/authApi' + +const mocked = authApi as unknown as Record<'listUsers' | 'listTeams' | 'createUser' | 'updateUser', ReturnType> + +beforeEach(() => { + mocked.listUsers.mockReset().mockResolvedValue(users) + mocked.listTeams.mockReset().mockResolvedValue(teams) + mocked.createUser.mockReset() + mocked.updateUser.mockReset() +}) + +describe('UsersPage', () => { + it('lists users with their source, teams and status', async () => { + renderWithProviders(, { route: '/admin/users' }) + const bobRow = (await screen.findByText('bob')).closest('tr') as HTMLElement + expect(within(bobRow).getByText('oidc')).toBeInTheDocument() + expect(within(bobRow).getByText('Platform')).toBeInTheDocument() + expect(within(bobRow).getByText('Disabled')).toBeInTheDocument() + expect(screen.getByText('(you)')).toBeInTheDocument() + }) + + it('creates a local user with teams', async () => { + mocked.createUser.mockResolvedValue({ ...users[0], id: 'u-new', username: 'carol' }) + renderWithProviders(, { route: '/admin/users' }) + await screen.findByText('bob') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'New user' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Username'), 'carol') + await user.type(within(dialog).getByLabelText('Email'), 'carol@x.io') + await user.type(within(dialog).getByLabelText('Display name'), 'Carol') + await user.type(within(dialog).getByLabelText('Temporary password'), 'temporary-pass-123') + await user.click(within(dialog).getByLabelText('Platform')) + await user.click(within(dialog).getByRole('button', { name: 'Create user' })) + await waitFor(() => + expect(mocked.createUser).toHaveBeenCalledWith({ + username: 'carol', + email: 'carol@x.io', + displayName: 'Carol', + password: 'temporary-pass-123', + teamIds: ['t-plat'], + }), + ) + expect(await screen.findByRole('status')).toHaveTextContent('User created') + }) + + it('edits an identity provider user: only teams and status are editable', async () => { + mocked.updateUser.mockResolvedValue(users[1]) + renderWithProviders(, { route: '/admin/users' }) + const bobRow = (await screen.findByText('bob')).closest('tr') as HTMLElement + const user = userEvent.setup() + await user.click(within(bobRow).getByRole('button', { name: 'Edit' })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByLabelText('Email')).toBeDisabled() + expect(within(dialog).queryByLabelText('Reset password')).not.toBeInTheDocument() + await user.click(within(dialog).getByLabelText('Administrators')) + await user.click(within(dialog).getByLabelText('Account disabled')) + await user.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(mocked.updateUser).toHaveBeenCalledWith('u-bob', { + email: 'bob@x.io', + displayName: 'Bob', + teamIds: ['t-plat', 't-admin'], + disabled: false, + }), + ) + }) + + it('shows the API error inside the dialog', async () => { + mocked.createUser.mockRejectedValue({ message: 'username already exists' }) + renderWithProviders(, { route: '/admin/users' }) + await screen.findByText('bob') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'New user' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Username'), 'bob') + await user.type(within(dialog).getByLabelText('Temporary password'), 'temporary-pass-123') + await user.click(within(dialog).getByRole('button', { name: 'Create user' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent('username already exists') + }) +}) diff --git a/web/src/pages/admin/UsersPage.tsx b/web/src/pages/admin/UsersPage.tsx index 7689928f..9aac5165 100644 --- a/web/src/pages/admin/UsersPage.tsx +++ b/web/src/pages/admin/UsersPage.tsx @@ -1,9 +1,116 @@ +import { useCallback, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Pencil, UserPlus } from 'lucide-react' import AdminPageShell from './AdminPageShell' +import { AdminTable, Cell, EmptyRow, QUERY_KEYS, Row } from '../../components/admin/adminUi' +import { UserFormDialog } from '../../components/admin/UserFormDialog' +import { Badge } from '../../components/ui/badge' +import { Button } from '../../components/ui/button' +import Toast from '../../components/Toast' +import { useAuth } from '../../contexts/AuthContext' +import { authApi, getApiErrorMessage } from '../../lib/authApi' +import type { CreateUserInput, UpdateUserInput, User } from '../../types/auth' + +const COLUMNS = ['Username', 'Display name', 'Email', 'Source', 'Teams', 'Status', 'Actions'] export default function UsersPage() { + const { principal } = useAuth() + const queryClient = useQueryClient() + const usersQuery = useQuery({ queryKey: QUERY_KEYS.users, queryFn: authApi.listUsers }) + const teamsQuery = useQuery({ queryKey: QUERY_KEYS.teams, queryFn: authApi.listTeams }) + + const [dialog, setDialog] = useState<{ open: boolean; user?: User }>({ open: false }) + const [toast, setToast] = useState(null) + const [error, setError] = useState(null) + + const teamName = useMemo(() => { + const map = new Map() + for (const t of teamsQuery.data ?? []) map.set(t.id, t.name) + return (id: string) => map.get(id) ?? id + }, [teamsQuery.data]) + + const closeDialog = useCallback(() => { + setDialog({ open: false }) + setError(null) + }, []) + + const onSuccess = (message: string) => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.users }) + closeDialog() + setToast(message) + } + + const createMutation = useMutation({ + mutationFn: (input: CreateUserInput) => authApi.createUser(input), + onSuccess: () => onSuccess('User created'), + onError: (err) => setError(getApiErrorMessage(err, 'Could not create the user')), + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateUserInput }) => authApi.updateUser(id, input), + onSuccess: () => onSuccess('User updated'), + onError: (err) => setError(getApiErrorMessage(err, 'Could not update the user')), + }) + + const pending = createMutation.isPending || updateMutation.isPending + const users = usersQuery.data ?? [] + return ( - -

Loading...

+ setDialog({ open: true })}> + + New user + + } + > + {usersQuery.isError && ( +

+ {getApiErrorMessage(usersQuery.error, 'Could not load users')} +

+ )} + + {usersQuery.isPending && } + {usersQuery.isSuccess && users.length === 0 && } + {users.map((u) => ( + + + {u.username} {u.id === principal.userId && (you)} + + {u.displayName} + {u.email || '-'} + {u.source} + {u.teamIds.length ? u.teamIds.map(teamName).join(', ') : '-'} + +
+ {u.disabled ? Disabled : Active} + {u.mustChangePassword && Password change required} +
+
+ + + +
+ ))} +
+ + createMutation.mutate(input)} + onUpdate={(id, input) => updateMutation.mutate({ id, input })} + onClose={closeDialog} + /> + {toast && setToast(null)} />}
) } From 8abb7819985277bf1729f0dbc80c537a543d6524 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:19:53 +0200 Subject: [PATCH 12/28] feat(web): add teams administration page --- web/src/components/admin/TeamFormDialog.tsx | 131 +++++++++++++++++ web/src/pages/admin/TeamsPage.test.tsx | 108 ++++++++++++++ web/src/pages/admin/TeamsPage.tsx | 151 +++++++++++++++++++- 3 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 web/src/components/admin/TeamFormDialog.tsx create mode 100644 web/src/pages/admin/TeamsPage.test.tsx diff --git a/web/src/components/admin/TeamFormDialog.tsx b/web/src/components/admin/TeamFormDialog.tsx new file mode 100644 index 00000000..6dde516d --- /dev/null +++ b/web/src/components/admin/TeamFormDialog.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Checkbox } from '../ui/checkbox' +import { Label } from '../ui/label' +import { ChipInput } from './ChipInput' +import { FieldRow, FormError } from './adminUi' +import { ALL_PERMISSIONS, PERMISSION_GROUPS, PERMISSION_LABELS } from '../../types/auth' +import type { Team, TeamInput, User } from '../../types/auth' + +interface TeamFormDialogProps { + open: boolean + /** undefined = create mode. */ + team?: Team + /** Users whose teamIds include this team (empty in create mode). */ + members: User[] + pending: boolean + error: string | null + onSubmit: (input: TeamInput) => void + onClose: () => void +} + +export function TeamFormDialog({ open, team, members, pending, error, onSubmit, onClose }: TeamFormDialogProps) { + const isEdit = team !== undefined + const locked = isEdit && team.builtin + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [permissions, setPermissions] = useState([]) + const [oidcGroups, setOidcGroups] = useState([]) + const [localError, setLocalError] = useState(null) + + useEffect(() => { + if (!open) return + setName(team?.name ?? '') + setDescription(team?.description ?? '') + setPermissions(team?.permissions ?? []) + setOidcGroups(team?.oidcGroups ?? []) + setLocalError(null) + }, [open, team]) + + const togglePermission = (perm: string, checked: boolean) => { + setPermissions((current) => { + const next = checked ? [...current, perm] : current.filter((p) => p !== perm) + // Keep the canonical order so the payload is stable. + return ALL_PERMISSIONS.filter((p) => next.includes(p)) + }) + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setLocalError(null) + if (!name.trim()) { + setLocalError('Name is required') + return + } + onSubmit({ name: name.trim(), description: description.trim(), permissions, scopeAll: true, scopeServices: [], oidcGroups }) + } + + return ( + + + + {isEdit ? `Edit ${team.name}` : 'New team'} + + +
+ + setName(e.target.value)} /> + + + setDescription(e.target.value)} /> + + +
+ + Permissions{locked && (built-in, read only)} + + {PERMISSION_GROUPS.map((group) => ( +
+

{group.label}

+ {group.permissions.map((perm) => ( +
+ togglePermission(perm, c)} /> +
+ +

{PERMISSION_LABELS[perm]}

+
+
+ ))} +
+ ))} +
+ +
+

Scope

+

All services

+

Per-service scope arrives in a later release.

+
+ + + + + + {isEdit && ( +
+

Members

+ {members.length === 0 ? ( +

No member.

+ ) : ( +
+ {members.map((m) => ( + {m.username} + ))} +
+ )} +

Edit membership from the Users page.

+
+ )} + + +
+ + +
+ +
+
+ ) +} diff --git a/web/src/pages/admin/TeamsPage.test.tsx b/web/src/pages/admin/TeamsPage.test.tsx new file mode 100644 index 00000000..14b657f4 --- /dev/null +++ b/web/src/pages/admin/TeamsPage.test.tsx @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test/renderWithProviders' +import TeamsPage from './TeamsPage' +import type { Team, User } from '../../types/auth' + +const teams: Team[] = [ + { id: 't-admin', name: 'Administrators', description: 'Built-in', permissions: ['access:manage'], scopeAll: true, scopeServices: [], oidcGroups: [], builtin: true }, + { id: 't-plat', name: 'Platform', description: 'Platform team', permissions: ['event:read', 'event:write'], scopeAll: true, scopeServices: [], oidcGroups: ['platform-eng'], builtin: false }, +] +const users: User[] = [ + { id: 'u-admin', username: 'admin', email: '', displayName: 'admin', source: 'local', teamIds: ['t-admin'], disabled: false, mustChangePassword: false }, + { id: 'u-bob', username: 'bob', email: '', displayName: 'Bob', source: 'oidc', teamIds: ['t-plat'], disabled: false, mustChangePassword: false }, +] + +vi.mock('../../lib/authApi', () => ({ + authApi: { listTeams: vi.fn(), listUsers: vi.fn(), createTeam: vi.fn(), updateTeam: vi.fn(), deleteTeam: vi.fn() }, + getApiErrorStatus: () => undefined, + getApiErrorMessage: (err: unknown, fallback: string) => (err as { message?: string } | null)?.message || fallback, +})) + +import { authApi } from '../../lib/authApi' + +const mocked = authApi as unknown as Record<'listTeams' | 'listUsers' | 'createTeam' | 'updateTeam' | 'deleteTeam', ReturnType> + +beforeEach(() => { + mocked.listTeams.mockReset().mockResolvedValue(teams) + mocked.listUsers.mockReset().mockResolvedValue(users) + mocked.createTeam.mockReset() + mocked.updateTeam.mockReset() + mocked.deleteTeam.mockReset() +}) + +describe('TeamsPage', () => { + it('lists teams with members count and hides delete for builtin teams', async () => { + renderWithProviders(, { route: '/admin/teams' }) + const adminRow = (await screen.findByText('Administrators')).closest('tr') as HTMLElement + expect(within(adminRow).getByText('builtin')).toBeInTheDocument() + expect(within(adminRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() + const platRow = screen.getByText('Platform').closest('tr') as HTMLElement + expect(within(platRow).getByText('platform-eng')).toBeInTheDocument() + expect(within(platRow).getByText('1')).toBeInTheDocument() + expect(within(platRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument() + }) + + it('creates a team with permissions and OIDC groups', async () => { + mocked.createTeam.mockResolvedValue(teams[1]) + renderWithProviders(, { route: '/admin/teams' }) + await screen.findByText('Platform') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'New team' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Name'), 'Payments') + await user.type(within(dialog).getByLabelText('Description'), 'Payments squad') + await user.click(within(dialog).getByLabelText('event:read')) + await user.click(within(dialog).getByLabelText('lock:write')) + await user.type(within(dialog).getByLabelText('OIDC groups'), 'payments-team{Enter}') + await user.click(within(dialog).getByRole('button', { name: 'Create team' })) + await waitFor(() => + expect(mocked.createTeam).toHaveBeenCalledWith({ + name: 'Payments', + description: 'Payments squad', + permissions: ['event:read', 'lock:write'], + scopeAll: true, + scopeServices: [], + oidcGroups: ['payments-team'], + }), + ) + expect(await screen.findByRole('status')).toHaveTextContent('Team created') + }) + + it('locks name and permissions for a builtin team but allows OIDC groups', async () => { + mocked.updateTeam.mockResolvedValue(teams[0]) + renderWithProviders(, { route: '/admin/teams' }) + const adminRow = (await screen.findByText('Administrators')).closest('tr') as HTMLElement + const user = userEvent.setup() + await user.click(within(adminRow).getByRole('button', { name: 'Edit' })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByLabelText('Name')).toBeDisabled() + expect(within(dialog).getByLabelText('access:manage')).toBeDisabled() + expect(within(dialog).getByText('admin')).toBeInTheDocument() + await user.type(within(dialog).getByLabelText('OIDC groups'), 'tracker-admins{Enter}') + await user.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(mocked.updateTeam).toHaveBeenCalledWith('t-admin', { + name: 'Administrators', + description: 'Built-in', + permissions: ['access:manage'], + scopeAll: true, + scopeServices: [], + oidcGroups: ['tracker-admins'], + }), + ) + }) + + it('deletes a team after confirmation', async () => { + mocked.deleteTeam.mockResolvedValue(undefined) + renderWithProviders(, { route: '/admin/teams' }) + const platRow = (await screen.findByText('Platform')).closest('tr') as HTMLElement + const user = userEvent.setup() + await user.click(within(platRow).getByRole('button', { name: 'Delete' })) + expect(screen.getByText(/Delete team Platform\?/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Delete team' })) + await waitFor(() => expect(mocked.deleteTeam).toHaveBeenCalledWith('t-plat')) + expect(await screen.findByRole('status')).toHaveTextContent('Team deleted') + }) +}) diff --git a/web/src/pages/admin/TeamsPage.tsx b/web/src/pages/admin/TeamsPage.tsx index d6590fea..133cafc6 100644 --- a/web/src/pages/admin/TeamsPage.tsx +++ b/web/src/pages/admin/TeamsPage.tsx @@ -1,9 +1,156 @@ +import { useCallback, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Pencil, Plus, Trash2 } from 'lucide-react' import AdminPageShell from './AdminPageShell' +import { AdminTable, Cell, EmptyRow, QUERY_KEYS, Row } from '../../components/admin/adminUi' +import { ConfirmDialog } from '../../components/admin/ConfirmDialog' +import { TeamFormDialog } from '../../components/admin/TeamFormDialog' +import { Badge } from '../../components/ui/badge' +import { Button } from '../../components/ui/button' +import Toast from '../../components/Toast' +import { authApi, getApiErrorMessage } from '../../lib/authApi' +import type { Team, TeamInput } from '../../types/auth' + +const COLUMNS = ['Name', 'Description', 'Permissions', 'Scope', 'OIDC groups', 'Members', 'Actions'] export default function TeamsPage() { + const queryClient = useQueryClient() + const teamsQuery = useQuery({ queryKey: QUERY_KEYS.teams, queryFn: authApi.listTeams }) + const usersQuery = useQuery({ queryKey: QUERY_KEYS.users, queryFn: authApi.listUsers }) + + const [dialog, setDialog] = useState<{ open: boolean; team?: Team }>({ open: false }) + const [deleting, setDeleting] = useState(null) + const [toast, setToast] = useState(null) + const [error, setError] = useState(null) + + const membersOf = useMemo(() => { + const users = usersQuery.data ?? [] + return (teamId: string) => users.filter((u) => u.teamIds.includes(teamId)) + }, [usersQuery.data]) + + const closeDialog = useCallback(() => { + setDialog({ open: false }) + setError(null) + }, []) + + const onSuccess = (message: string) => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.teams }) + closeDialog() + setDeleting(null) + setToast(message) + } + + const createMutation = useMutation({ + mutationFn: (input: TeamInput) => authApi.createTeam(input), + onSuccess: () => onSuccess('Team created'), + onError: (err) => setError(getApiErrorMessage(err, 'Could not create the team')), + }) + const updateMutation = useMutation({ + mutationFn: ({ id, input }: { id: string; input: TeamInput }) => authApi.updateTeam(id, input), + onSuccess: () => onSuccess('Team updated'), + onError: (err) => setError(getApiErrorMessage(err, 'Could not update the team')), + }) + const deleteMutation = useMutation({ + mutationFn: (id: string) => authApi.deleteTeam(id), + onSuccess: () => onSuccess('Team deleted'), + onError: (err) => { + setDeleting(null) + setToast(getApiErrorMessage(err, 'Could not delete the team')) + }, + }) + + const pending = createMutation.isPending || updateMutation.isPending + const teams = teamsQuery.data ?? [] + + const submit = (input: TeamInput) => { + if (dialog.team) updateMutation.mutate({ id: dialog.team.id, input }) + else createMutation.mutate(input) + } + return ( - -

Loading...

+ setDialog({ open: true })}> + + New team + + } + > + {teamsQuery.isError && ( +

+ {getApiErrorMessage(teamsQuery.error, 'Could not load teams')} +

+ )} + + {teamsQuery.isPending && } + {teamsQuery.isSuccess && teams.length === 0 && } + {teams.map((t) => ( + + + + {t.name} + {t.builtin && builtin} + + + {t.description || '-'} + + + {t.permissions.length} permission{t.permissions.length === 1 ? '' : 's'} + + + {t.scopeAll ? 'All services' : `${t.scopeServices.length} services`} + + {t.oidcGroups.length === 0 ? ( + - + ) : ( +
+ {t.oidcGroups.map((g) => ( + {g} + ))} +
+ )} +
+ {membersOf(t.id).length} + +
+ + {!t.builtin && ( + + )} +
+
+
+ ))} +
+ + + deleting && deleteMutation.mutate(deleting.id)} + onClose={() => setDeleting(null)} + /> + {toast && setToast(null)} />}
) } From bb8623a25276a08fbd5bbca6da16dbea3fa9e23c Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:30:47 +0200 Subject: [PATCH 13/28] feat(web): add API keys administration page --- .../admin/ApiKeyCreateDialog.test.tsx | 61 ++++++++ .../components/admin/ApiKeyCreateDialog.tsx | 137 +++++++++++++++++ web/src/pages/admin/ApiKeysPage.test.tsx | 87 +++++++++++ web/src/pages/admin/ApiKeysPage.tsx | 142 +++++++++++++++++- 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 web/src/components/admin/ApiKeyCreateDialog.test.tsx create mode 100644 web/src/components/admin/ApiKeyCreateDialog.tsx create mode 100644 web/src/pages/admin/ApiKeysPage.test.tsx diff --git a/web/src/components/admin/ApiKeyCreateDialog.test.tsx b/web/src/components/admin/ApiKeyCreateDialog.test.tsx new file mode 100644 index 00000000..65a70022 --- /dev/null +++ b/web/src/components/admin/ApiKeyCreateDialog.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ApiKeyCreateDialog } from './ApiKeyCreateDialog' +import type { Team } from '../../types/auth' + +const teams: Team[] = [ + { id: 't-plat', name: 'Platform', description: '', permissions: [], scopeAll: true, scopeServices: [], oidcGroups: [], builtin: false }, +] + +describe('ApiKeyCreateDialog', () => { + it('submits name, team and no expiration', async () => { + const onSubmit = vi.fn() + render( {}} />) + const user = userEvent.setup() + await user.type(screen.getByLabelText('Name'), 'gitlab-ci') + expect(screen.queryByRole('option', { name: /Global/ })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Create key' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'gitlab-ci', teamId: 't-plat' }) + }) + + it('offers the global option to admins and converts the expiration', async () => { + const onSubmit = vi.fn() + render( {}} />) + const user = userEvent.setup() + await user.type(screen.getByLabelText('Name'), 'global-sync') + await user.selectOptions(screen.getByLabelText('Team'), '') + fireEvent.change(screen.getByLabelText('Expires at (optional)'), { target: { value: '2027-01-31T10:30' } }) + await user.click(screen.getByRole('button', { name: 'Create key' })) + expect(onSubmit).toHaveBeenCalledTimes(1) + const input = onSubmit.mock.calls[0][0] as { name: string; teamId: string; expiresAt?: string } + expect(input.teamId).toBe('') + expect(input.expiresAt).toBe(new Date('2027-01-31T10:30').toISOString()) + }) + + it('shows the secret once with a copy button', async () => { + const onClose = vi.fn() + render( + {}} + onClose={onClose} + />, + ) + expect(screen.getByText('trk_abcd.verysecretvalue')).toBeInTheDocument() + expect(screen.getByText(/shown once/i)).toBeInTheDocument() + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() + const user = userEvent.setup() + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined) + await user.click(screen.getByRole('button', { name: 'Copy' })) + expect(writeText).toHaveBeenCalledWith('trk_abcd.verysecretvalue') + expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Done' })) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/web/src/components/admin/ApiKeyCreateDialog.tsx b/web/src/components/admin/ApiKeyCreateDialog.tsx new file mode 100644 index 00000000..1d629c6c --- /dev/null +++ b/web/src/components/admin/ApiKeyCreateDialog.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Check, Copy, KeyRound } from 'lucide-react' +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { FieldRow, FormError } from './adminUi' +import type { CreateApiKeyInput, CreateApiKeyResult, Team } from '../../types/auth' + +interface ApiKeyCreateDialogProps { + open: boolean + teams: Team[] + /** Admins may create keys that are not bound to a team. */ + canCreateGlobal: boolean + pending: boolean + error: string | null + /** Set after a successful creation: switches to the secret reveal view. */ + result: CreateApiKeyResult | null + onSubmit: (input: CreateApiKeyInput) => void + onClose: () => void +} + +const selectClass = + 'flex h-9 w-full rounded-md border border-hud-outline-var bg-hud-surface px-3 py-2 text-sm text-hud-on-surface focus:outline-none focus:border-hud-primary' + +export function ApiKeyCreateDialog({ open, teams, canCreateGlobal, pending, error, result, onSubmit, onClose }: ApiKeyCreateDialogProps) { + const [name, setName] = useState('') + const [teamId, setTeamId] = useState('') + const [expiresAt, setExpiresAt] = useState('') + const [localError, setLocalError] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!open) return + setName('') + setTeamId(teams[0]?.id ?? '') + setExpiresAt('') + setLocalError(null) + setCopied(false) + }, [open, teams]) + + useEffect(() => { + if (!copied) return + const t = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(t) + }, [copied]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setLocalError(null) + if (!name.trim()) { + setLocalError('Name is required') + return + } + if (teamId === '' && !canCreateGlobal) { + setLocalError('Select a team') + return + } + const input: CreateApiKeyInput = { name: name.trim(), teamId } + if (expiresAt) { + const d = new Date(expiresAt) + if (Number.isNaN(d.getTime()) || d.getTime() <= Date.now()) { + setLocalError('Expiration must be a future date') + return + } + input.expiresAt = d.toISOString() + } + onSubmit(input) + } + + const copy = async () => { + if (!result) return + try { + await navigator.clipboard.writeText(result.secret) + setCopied(true) + } catch { + setLocalError('Clipboard unavailable: select the secret and copy it manually') + } + } + + return ( + + + + {result ? 'API key created' : 'New API key'} + + + + {result ? ( +
+
+ + This secret is shown once. Store it now; Tracker keeps only its prefix. +
+
+ + {result.secret} + + +
+

+ Send it in the X-Api-Key header. Key {result.apiKey.prefix} ({result.apiKey.name}). +

+ +
+ +
+
+ ) : ( +
+ + setName(e.target.value)} /> + + + + + + setExpiresAt(e.target.value)} /> + + +
+ + +
+ + )} +
+
+ ) +} diff --git a/web/src/pages/admin/ApiKeysPage.test.tsx b/web/src/pages/admin/ApiKeysPage.test.tsx new file mode 100644 index 00000000..5da7a40a --- /dev/null +++ b/web/src/pages/admin/ApiKeysPage.test.tsx @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test/renderWithProviders' +import ApiKeysPage from './ApiKeysPage' +import type { AuthContextValue } from '../../contexts/AuthContext' +import type { ApiKey, Team } from '../../types/auth' + +const teams: Team[] = [ + { id: 't-plat', name: 'Platform', description: '', permissions: [], scopeAll: true, scopeServices: [], oidcGroups: [], builtin: false }, +] +const keys: ApiKey[] = [ + { id: 'k1', prefix: 'trk_aaaa', name: 'gitlab-ci', teamId: 't-plat', createdBy: 'admin', createdAt: '2026-09-01T10:00:00Z', lastUsedAt: '2026-09-04T08:00:00Z' }, + { id: 'k2', prefix: 'trk_bbbb', name: 'old-sync', teamId: '', createdBy: 'admin', createdAt: '2026-01-01T10:00:00Z', revokedAt: '2026-06-01T10:00:00Z' }, + { id: 'k3', prefix: 'trk_cccc', name: 'expired', teamId: 't-plat', createdBy: 'admin', createdAt: '2026-01-01T10:00:00Z', expiresAt: '2026-02-01T10:00:00Z' }, +] + +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: (): AuthContextValue => ({ + status: 'ready', + principal: { authenticated: true, kind: 'user', userId: 'u-admin', username: 'admin', displayName: 'admin', source: 'local', teams: [], permissions: ['access:manage'], scopeAll: true, scopeServices: [], mustChangePassword: false, isAdmin: true }, + config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, + hasPermission: () => true, + inScope: () => true, + logout: async () => {}, + reload: async () => {}, + }), +})) + +vi.mock('../../lib/authApi', () => ({ + authApi: { listApiKeys: vi.fn(), listTeams: vi.fn(), createApiKey: vi.fn(), revokeApiKey: vi.fn() }, + getApiErrorStatus: () => undefined, + getApiErrorMessage: (err: unknown, fallback: string) => (err as { message?: string } | null)?.message || fallback, +})) + +import { authApi } from '../../lib/authApi' + +const mocked = authApi as unknown as Record<'listApiKeys' | 'listTeams' | 'createApiKey' | 'revokeApiKey', ReturnType> + +beforeEach(() => { + mocked.listApiKeys.mockReset().mockResolvedValue(keys) + mocked.listTeams.mockReset().mockResolvedValue(teams) + mocked.createApiKey.mockReset() + mocked.revokeApiKey.mockReset() +}) + +describe('ApiKeysPage', () => { + it('lists keys with team, status and last use', async () => { + renderWithProviders(, { route: '/admin/api-keys' }) + const row1 = (await screen.findByText('trk_aaaa')).closest('tr') as HTMLElement + expect(within(row1).getByText('Platform')).toBeInTheDocument() + expect(within(row1).getByText('Active')).toBeInTheDocument() + expect(within(row1).getByRole('button', { name: 'Revoke' })).toBeInTheDocument() + const row2 = screen.getByText('trk_bbbb').closest('tr') as HTMLElement + expect(within(row2).getByText('Global')).toBeInTheDocument() + expect(within(row2).getByText('Revoked')).toBeInTheDocument() + expect(within(row2).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument() + const row3 = screen.getByText('trk_cccc').closest('tr') as HTMLElement + expect(within(row3).getByText('Expired')).toBeInTheDocument() + expect(within(row3).getByText('Never')).toBeInTheDocument() + }) + + it('creates a key and reveals the secret', async () => { + mocked.createApiKey.mockResolvedValue({ apiKey: { ...keys[0], id: 'k9', prefix: 'trk_zzzz', name: 'new-key' }, secret: 'trk_zzzz.supersecret' }) + renderWithProviders(, { route: '/admin/api-keys' }) + await screen.findByText('trk_aaaa') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'New API key' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Name'), 'new-key') + await user.click(within(dialog).getByRole('button', { name: 'Create key' })) + await waitFor(() => expect(mocked.createApiKey).toHaveBeenCalledWith({ name: 'new-key', teamId: 't-plat' })) + expect(await screen.findByText('trk_zzzz.supersecret')).toBeInTheDocument() + }) + + it('revokes a key after confirmation', async () => { + mocked.revokeApiKey.mockResolvedValue(undefined) + renderWithProviders(, { route: '/admin/api-keys' }) + const row1 = (await screen.findByText('trk_aaaa')).closest('tr') as HTMLElement + const user = userEvent.setup() + await user.click(within(row1).getByRole('button', { name: 'Revoke' })) + expect(screen.getByText(/Revoke key trk_aaaa \(gitlab-ci\)\?/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Revoke key' })) + await waitFor(() => expect(mocked.revokeApiKey).toHaveBeenCalledWith('k1')) + expect(await screen.findByRole('status')).toHaveTextContent('API key revoked') + }) +}) diff --git a/web/src/pages/admin/ApiKeysPage.tsx b/web/src/pages/admin/ApiKeysPage.tsx index 99bea3b6..999d09be 100644 --- a/web/src/pages/admin/ApiKeysPage.tsx +++ b/web/src/pages/admin/ApiKeysPage.tsx @@ -1,9 +1,147 @@ +import { useCallback, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Ban, Plus } from 'lucide-react' import AdminPageShell from './AdminPageShell' +import { AdminTable, Cell, EmptyRow, formatDate, QUERY_KEYS, Row } from '../../components/admin/adminUi' +import { ApiKeyCreateDialog } from '../../components/admin/ApiKeyCreateDialog' +import { ConfirmDialog } from '../../components/admin/ConfirmDialog' +import { Badge } from '../../components/ui/badge' +import { Button } from '../../components/ui/button' +import Toast from '../../components/Toast' +import { useAuth } from '../../contexts/AuthContext' +import { authApi, getApiErrorMessage } from '../../lib/authApi' +import type { ApiKey, CreateApiKeyInput, CreateApiKeyResult } from '../../types/auth' + +const COLUMNS = ['Prefix', 'Name', 'Team', 'Created by', 'Created', 'Last used', 'Expires', 'Status', 'Actions'] + +type KeyStatus = 'active' | 'revoked' | 'expired' + +function keyStatus(key: ApiKey, now: number = Date.now()): KeyStatus { + if (key.revokedAt) return 'revoked' + if (key.expiresAt && new Date(key.expiresAt).getTime() <= now) return 'expired' + return 'active' +} + +function StatusBadge({ status }: { status: KeyStatus }) { + if (status === 'revoked') return Revoked + if (status === 'expired') return Expired + return Active +} export default function ApiKeysPage() { + const { principal } = useAuth() + const queryClient = useQueryClient() + const keysQuery = useQuery({ queryKey: QUERY_KEYS.apiKeys, queryFn: authApi.listApiKeys }) + const teamsQuery = useQuery({ queryKey: QUERY_KEYS.teams, queryFn: authApi.listTeams }) + + const [dialogOpen, setDialogOpen] = useState(false) + const [result, setResult] = useState(null) + const [revoking, setRevoking] = useState(null) + const [toast, setToast] = useState(null) + const [error, setError] = useState(null) + + const teamName = useMemo(() => { + const map = new Map() + for (const t of teamsQuery.data ?? []) map.set(t.id, t.name) + return (id: string) => map.get(id) ?? id + }, [teamsQuery.data]) + + const closeDialog = useCallback(() => { + setDialogOpen(false) + setResult(null) + setError(null) + }, []) + + const createMutation = useMutation({ + mutationFn: (input: CreateApiKeyInput) => authApi.createApiKey(input), + onSuccess: (created) => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.apiKeys }) + setError(null) + setResult(created) + }, + onError: (err) => setError(getApiErrorMessage(err, 'Could not create the API key')), + }) + + const revokeMutation = useMutation({ + mutationFn: (id: string) => authApi.revokeApiKey(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.apiKeys }) + setRevoking(null) + setToast('API key revoked') + }, + onError: (err) => { + setRevoking(null) + setToast(getApiErrorMessage(err, 'Could not revoke the API key')) + }, + }) + + const keys = keysQuery.data ?? [] + return ( - -

Loading...

+ setDialogOpen(true)}> + + New API key + + } + > + {keysQuery.isError && ( +

+ {getApiErrorMessage(keysQuery.error, 'Could not load API keys')} +

+ )} + + {keysQuery.isPending && } + {keysQuery.isSuccess && keys.length === 0 && } + {keys.map((k) => { + const status = keyStatus(k) + return ( + + {k.prefix} + {k.name} + {k.teamId ? teamName(k.teamId) : Global} + {k.createdBy || '-'} + {formatDate(k.createdAt)} + {formatDate(k.lastUsedAt)} + {formatDate(k.expiresAt)} + + + {status !== 'revoked' && ( + + )} + + + ) + })} + + + createMutation.mutate(input)} + onClose={closeDialog} + /> + revoking && revokeMutation.mutate(revoking.id)} + onClose={() => setRevoking(null)} + /> + {toast && setToast(null)} />}
) } From 070722ac8d8510d242bef402aa1f6b4cebbda0ad Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:40:43 +0200 Subject: [PATCH 14/28] ci: lint, test and build the web UI on pull requests --- .github/workflows/web.yml | 53 +++++++++++++++++++++++++++++++++++++++ README.md | 2 +- docs/AUTHENTICATION.md | 27 ++++++++++++++++++++ web/.env.example | 11 ++++++++ web/README.md | 20 +++++++++++++++ web/package.json | 2 +- 6 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/web.yml diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 00000000..29860215 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,53 @@ +name: Web + +on: + pull_request: + paths: + - .github/workflows/web.yml + - web/** + +permissions: + contents: read + +jobs: + web: + name: Lint, test and build + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - name: Check out code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint auth modules + run: npm run lint:auth + + - name: Type check auth modules + # The web tree carries pre-existing type errors outside the auth + # modules; the full log is kept for visibility and only errors in + # the files owned by the auth work fail the job. + run: | + set -o pipefail + npx tsc --noEmit --pretty false 2>&1 | tee tsc.log || true + if grep -E '^src/(types/auth\.ts|lib/authApi(\.test)?\.ts|lib/authEvents(\.test)?\.ts|lib/api\.ts|contexts/AuthContext(\.test)?\.tsx|components/auth/[A-Za-z.]+\.tsx|components/admin/[A-Za-z.]+\.tsx|components/Toast\.tsx|components/Layout\.tsx|components/DemoBanner\.tsx|components/LockActions\.tsx|pages/Login(\.test)?\.tsx|pages/account/[A-Za-z.]+\.tsx|pages/admin/[A-Za-z.]+\.tsx|pages/Links\.tsx|pages/Locks\.tsx|App(\.test)?\.tsx|test/[A-Za-z.]+\.tsx?)\(' tsc.log; then + echo "type errors in auth modules" >&2 + exit 1 + fi + echo "auth modules type check clean ($(grep -c 'error TS' tsc.log || true) pre-existing errors elsewhere)" + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build diff --git a/README.md b/README.md index ec59dab4..f96bea4c 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ npm run dev - [🚀 Installation Guide](./docs/INSTALLATION.md) - Complete installation instructions - [⚙️ Configuration Guide](./docs/CONFIGURATION.md) - Environment variables and settings - [🔧 Development Guide](./docs/DEVELOPMENT.md) - Set up development environment -- [🔐 Authentication](./docs/AUTHENTICATION.md) - Users, teams, permissions, API keys and single sign-on +- [🔐 Authentication](./docs/AUTHENTICATION.md) - Users, teams, permissions, API keys, single sign-on and the admin UI ### User Guides - [📖 User Guide](./docs/USER_GUIDE.md) - How to use Tracker diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 385b6432..ebbe9742 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -471,6 +471,33 @@ instead of shutting it down. Presenting no credential at all is unchanged: the caller is anonymous and gets the anonymous permissions. +## Web UI + +The web interface consumes the endpoints above: + +| Page | Purpose | Requirement | +|------|---------|-------------| +| `/login` | Local sign-in form; SSO button when OIDC is configured | none | +| `/account/password` | Change the password of a local account; forced after first sign-in | signed-in user | +| `/admin/users` | List, create (username, email, temporary password, teams), edit teams, enable or disable accounts | `access:manage` | +| `/admin/teams` | List, create, edit permissions and OIDC groups, delete non built-in teams | `access:manage` | +| `/admin/api-keys` | List, create (team or global), reveal the secret once, revoke | `access:manage` | + +Behaviour in the browser: + +- A `401` on any API call redirects to `/login?redirect=`; a `403` shows an + "Access denied" toast and leaves the page in place. +- Sidebar entries are hidden when the principal lacks the section's `*:read` permission, and + create, edit, delete and lock buttons are hidden without the matching `*:write` permission. + Hiding is a convenience: the backend enforces every permission. +- Anonymous visitors see a "Sign in" button; signed-in users get an account menu with their + source (`local` or `oidc`), teams, "Change password" (local accounts) and "Sign out". +- In `DEMO_MODE`, the banner tells anonymous visitors that browsing is read-only. +- When the UI is served by the Vite dev server (`npm run dev`, on port 3000) with the backend + running separately, start the backend with `AUTH_PUBLIC_URL=http://localhost:3000`, otherwise + the login endpoint's cross-site check refuses the request because the dev proxy rewrites the + Host header. + ## Metrics `tracker_auth_requests_total{principal,result}` counts authorization diff --git a/web/.env.example b/web/.env.example index a1f0b3f1..f74045ea 100644 --- a/web/.env.example +++ b/web/.env.example @@ -17,6 +17,17 @@ VITE_SLACK_EVENTS_CHANNEL= # API Configuration VITE_API_BASE_URL=/api/v1alpha1 +# Authentication +# Browser sessions use the HttpOnly "tracker_session" cookie set by the backend; +# no token is configured on the frontend. Automation uses the X-Api-Key header. +# In development the Vite proxy forwards /api to VITE_BACKEND_URL (default +# http://localhost:8080) so the cookie is first-party. +VITE_BACKEND_URL=http://localhost:8080 +# When the UI runs through the Vite dev server (port 3000) and the backend runs +# separately, start the backend with AUTH_PUBLIC_URL=http://localhost:3000, or +# the login endpoint's cross-site check refuses the request because the dev +# proxy rewrites the Host header. + # Demo Mode Configuration # Set to 'true' to show demo banner with Buy Me a Coffee link VITE_DEMO_MODE=false diff --git a/web/README.md b/web/README.md index 5cd1c252..614a5789 100644 --- a/web/README.md +++ b/web/README.md @@ -96,6 +96,26 @@ Les fichiers de production seront générés dans le dossier `dist/` npm run preview ``` +## Authentication + +The UI signs in against the backend described in [docs/AUTHENTICATION.md](../docs/AUTHENTICATION.md). + +- `/login`: local account form, plus an SSO button when the backend reports `oidcEnabled`. +- Sessions are HttpOnly cookies; the axios client sends them with `withCredentials`. A `401` + redirects to `/login?redirect=`, a `403` shows an "Access denied" toast. +- Navigation entries and create/edit/delete buttons are hidden when the signed-in principal + (or the anonymous principal) lacks the matching `*:read` / `*:write` permission. +- `/admin/users`, `/admin/teams` and `/admin/api-keys` require `access:manage`. +- `/account/password` lets local users change their password; accounts flagged + `mustChangePassword` are sent there first. +- When this UI is served by the Vite dev server (`npm run dev`, port 3000) and the backend + runs separately, start the backend with `AUTH_PUBLIC_URL=http://localhost:3000`, otherwise + the login endpoint's cross-site check refuses the request. + +Scripts: `npm test` (vitest), `npm run lint:auth` (ESLint on the auth modules), +`npm run typecheck` (`tsc --noEmit`, currently reports pre-existing errors outside the auth +modules; CI only fails on the auth modules). + ## 🔧 Configuration ### Proxy API diff --git a/web/package.json b/web/package.json index ba3809cd..41ca5e02 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "build:check": "tsc && vite build", "preview": "vite preview", "lint": "eslint . --report-unused-disable-directives --max-warnings 0", - "lint:auth": "eslint --report-unused-disable-directives --max-warnings 0 src/types/auth.ts src/lib/authApi.ts src/lib/authEvents.ts src/lib/api.ts src/contexts/AuthContext.tsx src/components/auth src/components/admin src/components/Toast.tsx src/components/Layout.tsx src/components/DemoBanner.tsx src/pages/Login.tsx src/pages/account src/pages/admin src/pages/Links.tsx src/pages/Locks.tsx src/components/LockActions.tsx src/App.tsx src/test", + "lint:auth": "eslint --report-unused-disable-directives --max-warnings 0 src/types/auth.ts src/lib/authApi.ts src/lib/authEvents.ts src/contexts/AuthContext.tsx src/components/auth src/components/admin src/components/Toast.tsx src/components/Layout.tsx src/components/DemoBanner.tsx src/pages/Login.tsx src/pages/account src/pages/admin src/pages/Links.tsx src/App.tsx src/test", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" From 4bb92ccc529bfcedd08a8881f9cab824bb17c775 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:42:22 +0200 Subject: [PATCH 15/28] ci: exclude LockActions.tsx from the web type check filter --- .github/workflows/web.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 29860215..81426476 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -40,7 +40,7 @@ jobs: run: | set -o pipefail npx tsc --noEmit --pretty false 2>&1 | tee tsc.log || true - if grep -E '^src/(types/auth\.ts|lib/authApi(\.test)?\.ts|lib/authEvents(\.test)?\.ts|lib/api\.ts|contexts/AuthContext(\.test)?\.tsx|components/auth/[A-Za-z.]+\.tsx|components/admin/[A-Za-z.]+\.tsx|components/Toast\.tsx|components/Layout\.tsx|components/DemoBanner\.tsx|components/LockActions\.tsx|pages/Login(\.test)?\.tsx|pages/account/[A-Za-z.]+\.tsx|pages/admin/[A-Za-z.]+\.tsx|pages/Links\.tsx|pages/Locks\.tsx|App(\.test)?\.tsx|test/[A-Za-z.]+\.tsx?)\(' tsc.log; then + if grep -E '^src/(types/auth\.ts|lib/authApi(\.test)?\.ts|lib/authEvents(\.test)?\.ts|lib/api\.ts|contexts/AuthContext(\.test)?\.tsx|components/auth/[A-Za-z.]+\.tsx|components/admin/[A-Za-z.]+\.tsx|components/Toast\.tsx|components/Layout\.tsx|components/DemoBanner\.tsx|pages/Login(\.test)?\.tsx|pages/account/[A-Za-z.]+\.tsx|pages/admin/[A-Za-z.]+\.tsx|pages/Links\.tsx|pages/Locks\.tsx|App(\.test)?\.tsx|test/[A-Za-z.]+\.tsx?)\(' tsc.log; then echo "type errors in auth modules" >&2 exit 1 fi From a1f471d997e739736e02b4b9e3d9503e31ce3e13 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:51:41 +0200 Subject: [PATCH 16/28] fix(web): refresh auth context before forced password change redirect Login only called reload() on the normal sign-in path. When the backend required an immediate password change, the app navigated to /account/password while the AuthContext still held the stale anonymous principal, so the RequirePermission guard bounced the user straight back to /login. --- web/src/pages/Login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 78b921f0..657b65c1 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -45,11 +45,11 @@ export default function Login() { try { await authApi.login(username.trim(), password) const me = await authApi.me() + await reload() if (me.mustChangePassword) { navigate(`/account/password?redirect=${encodeURIComponent(target)}`, { replace: true }) return } - await reload() navigate(target, { replace: true }) } catch (err) { setError(messageFor(err)) From 7261a78df96756b9362d46b3a3ca969dfd37e5f5 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 19:56:48 +0200 Subject: [PATCH 17/28] fix(web): make the login page's authenticated-redirect honor forced password change The top-level guard that sends an already-signed-in visitor away from /login used the plain redirect target, racing the explicit navigation to /account/password once the auth context refreshes mid-submit. The guard now redirects to /account/password itself when the principal still needs a password change, so the outcome is correct regardless of which navigation wins the race. --- web/src/pages/Login.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 657b65c1..d1be2032 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -35,7 +35,8 @@ export default function Login() { const [pending, setPending] = useState(false) if (principal.kind === 'user') { - return + const to = principal.mustChangePassword ? `/account/password?redirect=${encodeURIComponent(target)}` : target + return } const handleSubmit = async (e: FormEvent) => { From d0d2d86b70d0525cd2fe8ac0b14b4c4d0da5ed0d Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 21:06:14 +0200 Subject: [PATCH 18/28] fix(web): keep the session state consistent when sign-out fails --- web/src/components/auth/UserMenu.test.tsx | 22 +++++- web/src/components/auth/UserMenu.tsx | 4 +- web/src/components/auth/gating.test.tsx | 3 +- web/src/contexts/AuthContext.test.tsx | 68 ++++++++++++++++++- web/src/contexts/AuthContext.tsx | 61 +++++++++++------ web/src/pages/Login.test.tsx | 5 +- web/src/pages/Login.tsx | 3 +- web/src/pages/account/ChangePassword.test.tsx | 3 +- web/src/pages/admin/ApiKeysPage.test.tsx | 7 +- web/src/pages/admin/UsersPage.test.tsx | 7 +- 10 files changed, 145 insertions(+), 38 deletions(-) diff --git a/web/src/components/auth/UserMenu.test.tsx b/web/src/components/auth/UserMenu.test.tsx index 84dde732..86c98ad1 100644 --- a/web/src/components/auth/UserMenu.test.tsx +++ b/web/src/components/auth/UserMenu.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' -import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../../test/renderWithProviders' import { UserMenu } from './UserMenu' @@ -22,6 +22,7 @@ const anonymous: Principal = { } let current: Principal = anonymous const logout = vi.fn(async () => {}) +const showToast = vi.fn() vi.mock('../../contexts/AuthContext', () => ({ useAuth: (): AuthContextValue => ({ @@ -31,10 +32,15 @@ vi.mock('../../contexts/AuthContext', () => ({ hasPermission: (p: string) => current.permissions.includes(p), inScope: () => true, logout, - reload: async () => {}, + reload: async () => current, + showToast, }), })) +beforeEach(() => { + showToast.mockClear() +}) + describe('UserMenu', () => { it('offers a sign-in link that comes back to the current page', () => { current = anonymous @@ -62,4 +68,14 @@ describe('UserMenu', () => { expect(screen.queryByRole('menuitem', { name: 'Change password' })).not.toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument() }) + + it('surfaces a toast when sign-out fails', async () => { + current = { ...anonymous, authenticated: true, kind: 'user', username: 'alice', displayName: 'Alice Doe', source: 'local' } + logout.mockRejectedValueOnce(new Error('logout failed')) + renderWithProviders(, { route: '/dashboard' }) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /Alice Doe/ })) + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })) + await waitFor(() => expect(showToast).toHaveBeenCalledWith('Sign out failed')) + }) }) diff --git a/web/src/components/auth/UserMenu.tsx b/web/src/components/auth/UserMenu.tsx index 8cad23d1..a1a142d4 100644 --- a/web/src/components/auth/UserMenu.tsx +++ b/web/src/components/auth/UserMenu.tsx @@ -12,7 +12,7 @@ function initials(name: string): string { } export function UserMenu() { - const { principal, logout } = useAuth() + const { principal, logout, showToast } = useAuth() const location = useLocation() const [open, setOpen] = useState(false) const rootRef = useRef(null) @@ -113,7 +113,7 @@ export function UserMenu() { type="button" onClick={() => { setOpen(false) - void logout() + logout().catch(() => showToast('Sign out failed')) }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-hud-on-surface hover:bg-hud-surface-high" > diff --git a/web/src/components/auth/gating.test.tsx b/web/src/components/auth/gating.test.tsx index e906926f..90e6f505 100644 --- a/web/src/components/auth/gating.test.tsx +++ b/web/src/components/auth/gating.test.tsx @@ -31,7 +31,8 @@ vi.mock('../../contexts/AuthContext', () => ({ hasPermission: (p: string) => current.permissions.includes(p), inScope: () => true, logout: async () => {}, - reload: async () => {}, + reload: async () => current, + showToast: vi.fn(), }), })) diff --git a/web/src/contexts/AuthContext.test.tsx b/web/src/contexts/AuthContext.test.tsx index 17f2aacc..568d11c1 100644 --- a/web/src/contexts/AuthContext.test.tsx +++ b/web/src/contexts/AuthContext.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test/renderWithProviders' import { AuthProvider, useAuth } from './AuthContext' import { emitForbidden, emitUnauthorized } from '../lib/authEvents' @@ -46,16 +47,33 @@ const alice: Principal = { isAdmin: false, } +const loggedOutByServer: Principal = { + authenticated: false, + kind: 'anonymous', + userId: '', + username: '', + displayName: '', + source: '', + teams: [], + permissions: ['event:read'], + scopeAll: false, + scopeServices: [], + mustChangePassword: false, + isAdmin: false, +} + function Probe() { - const { status, principal, hasPermission, inScope } = useAuth() + const { status, principal, hasPermission, inScope, logout, showToast } = useAuth() return (
{status} {principal.username || 'anonymous'} {String(hasPermission('event:read'))} {String(hasPermission('event:write'))} + {String(hasPermission('access:manage'))} {String(inScope('payments'))} {String(inScope('billing'))} +
) } @@ -104,9 +122,23 @@ describe('AuthProvider', () => { await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) expect(screen.getByTestId('user')).toHaveTextContent('anonymous') expect(screen.getByTestId('event-write')).toHaveTextContent('true') + expect(screen.getByTestId('access-manage')).toHaveTextContent('false') expect(warn).toHaveBeenCalled() }) + it('grants no permission when /auth/me is refused with 401', async () => { + mocked.me.mockRejectedValue({ status: 401 }) + renderWithProviders( + + + , + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + expect(screen.getByTestId('event-read')).toHaveTextContent('false') + expect(screen.getByTestId('event-write')).toHaveTextContent('false') + expect(screen.getByTestId('access-manage')).toHaveTextContent('false') + }) + it('redirects to /login with the current location on an unauthorized event', async () => { renderWithProviders( @@ -143,4 +175,38 @@ describe('AuthProvider', () => { act(() => emitForbidden('/event')) expect(await screen.findByRole('status')).toHaveTextContent('Access denied') }) + + it('signs out, adopts the server anonymous principal and lands on plain /login', async () => { + mocked.me.mockResolvedValueOnce(alice) + renderWithProviders( + + + , + { route: '/locks?env=prod' }, + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + expect(screen.getByTestId('user')).toHaveTextContent('alice') + mocked.me.mockResolvedValueOnce(loggedOutByServer) + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })) + expect(mocked.logout).toHaveBeenCalled() + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/login')) + expect(screen.getByTestId('location')).not.toHaveTextContent('?') + expect(screen.getByTestId('user')).toHaveTextContent('anonymous') + }) + + it('keeps the session and shows a toast when sign-out fails', async () => { + mocked.me.mockResolvedValueOnce(alice) + renderWithProviders( + + + , + { route: '/dashboard' }, + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + mocked.logout.mockRejectedValueOnce({ status: 500 }) + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })) + expect(await screen.findByRole('status')).toHaveTextContent('Sign out failed') + expect(screen.getByTestId('user')).toHaveTextContent('alice') + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') + }) }) diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index f457b1d5..c02deb42 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -29,11 +29,27 @@ export interface AuthContextValue { hasPermission: (perm: Permission | string) => boolean inScope: (service: string) => boolean logout: () => Promise - reload: () => Promise + reload: () => Promise + showToast: (message: string) => void } const AuthContext = createContext(undefined) +/** + * Resolves the principal after a rejected `/auth/me`: a refused session + * (401/403) gets no permission at all, never the permissive fallback; + * anything else (network, 5xx) falls back to the transitional anonymous + * principal so the app stays usable while the backend is unreachable. + */ +function principalAfterMeFailure(reason: unknown): Principal { + const httpStatus = getApiErrorStatus(reason) + if (httpStatus === 401 || httpStatus === 403) { + return { ...ANONYMOUS_FALLBACK, permissions: [] } + } + console.warn('auth: /auth/me unreachable, using the transitional anonymous principal', reason) + return ANONYMOUS_FALLBACK +} + function LoadingScreen() { return (
@@ -57,23 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const locationRef = useRef(location) locationRef.current = location - const reload = useCallback(async () => { - if (isStaticMode) return + const reload = useCallback(async (): Promise => { + if (isStaticMode) return ANONYMOUS_FALLBACK const [cfg, me] = await Promise.allSettled([authApi.getConfig(), authApi.me()]) if (cfg.status === 'fulfilled') setConfig(cfg.value) - if (me.status === 'fulfilled') { - setPrincipal(me.value) - } else { - const httpStatus = getApiErrorStatus(me.reason) - if (httpStatus === 401 || httpStatus === 403) { - // The backend refused an anonymous Me: no permission at all. - setPrincipal({ ...ANONYMOUS_FALLBACK, permissions: [] }) - } else { - console.warn('auth: /auth/me unreachable, using the transitional anonymous principal', me.reason) - setPrincipal(ANONYMOUS_FALLBACK) - } - } + const resolved = me.status === 'fulfilled' ? me.value : principalAfterMeFailure(me.reason) + setPrincipal(resolved) setStatus('ready') + return resolved }, []) useEffect(() => { @@ -106,19 +113,29 @@ export function AuthProvider({ children }: { children: ReactNode }) { ) const logout = useCallback(async () => { + // authApi.logout() rejecting propagates as-is: the session is still + // alive server-side, so neither the principal nor the location change. + await authApi.logout() + let me: Principal try { - await authApi.logout() - } finally { - await reload() - navigate('/login', { replace: true }) + me = await authApi.me() + } catch (err) { + me = principalAfterMeFailure(err) } - }, [navigate, reload]) + // Set the anonymous principal and navigate in the same synchronous + // block (no await between them) so React batches both: no protected + // page gets a chance to render with an anonymous principal and its own + // route guard's `/login?redirect=...`, only the plain `/login` below. + setPrincipal(me) + navigate('/login', { replace: true }) + }, [navigate]) + const showToast = useCallback((message: string) => setToast(message), []) const closeToast = useCallback(() => setToast(null), []) const value = useMemo( - () => ({ status, principal, config, hasPermission, inScope, logout, reload }), - [status, principal, config, hasPermission, inScope, logout, reload], + () => ({ status, principal, config, hasPermission, inScope, logout, reload, showToast }), + [status, principal, config, hasPermission, inScope, logout, reload, showToast], ) return ( diff --git a/web/src/pages/Login.test.tsx b/web/src/pages/Login.test.tsx index 3b80f5a8..a3c602f4 100644 --- a/web/src/pages/Login.test.tsx +++ b/web/src/pages/Login.test.tsx @@ -23,7 +23,7 @@ const anonymous: Principal = { const alice: Principal = { ...anonymous, authenticated: true, kind: 'user', userId: 'u1', username: 'alice', displayName: 'Alice', source: 'local' } -const reload = vi.fn(async () => {}) +const reload = vi.fn(async () => alice) let oidcEnabled = false vi.mock('../contexts/AuthContext', () => ({ @@ -35,6 +35,7 @@ vi.mock('../contexts/AuthContext', () => ({ inScope: () => true, logout: async () => {}, reload, + showToast: vi.fn(), }), })) @@ -98,7 +99,7 @@ describe('Login', () => { it('sends a user who must change their password to the password page', async () => { mocked.login.mockResolvedValue(undefined) - mocked.me.mockResolvedValue({ ...alice, mustChangePassword: true }) + reload.mockResolvedValueOnce({ ...alice, mustChangePassword: true }) renderWithProviders(, { route: '/login?redirect=%2Fcatalog' }) await fillAndSubmit('admin', 'temporary') await waitFor(() => diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index d1be2032..5eff5324 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -45,8 +45,7 @@ export default function Login() { setPending(true) try { await authApi.login(username.trim(), password) - const me = await authApi.me() - await reload() + const me = await reload() if (me.mustChangePassword) { navigate(`/account/password?redirect=${encodeURIComponent(target)}`, { replace: true }) return diff --git a/web/src/pages/account/ChangePassword.test.tsx b/web/src/pages/account/ChangePassword.test.tsx index 314620e6..def312b8 100644 --- a/web/src/pages/account/ChangePassword.test.tsx +++ b/web/src/pages/account/ChangePassword.test.tsx @@ -20,7 +20,7 @@ let current: Principal = { mustChangePassword: true, isAdmin: true, } -const reload = vi.fn(async () => {}) +const reload = vi.fn(async () => current) vi.mock('../../contexts/AuthContext', () => ({ useAuth: (): AuthContextValue => ({ @@ -31,6 +31,7 @@ vi.mock('../../contexts/AuthContext', () => ({ inScope: () => true, logout: async () => {}, reload, + showToast: vi.fn(), }), })) diff --git a/web/src/pages/admin/ApiKeysPage.test.tsx b/web/src/pages/admin/ApiKeysPage.test.tsx index 5da7a40a..959917a8 100644 --- a/web/src/pages/admin/ApiKeysPage.test.tsx +++ b/web/src/pages/admin/ApiKeysPage.test.tsx @@ -15,15 +15,18 @@ const keys: ApiKey[] = [ { id: 'k3', prefix: 'trk_cccc', name: 'expired', teamId: 't-plat', createdBy: 'admin', createdAt: '2026-01-01T10:00:00Z', expiresAt: '2026-02-01T10:00:00Z' }, ] +const adminPrincipal = { authenticated: true, kind: 'user' as const, userId: 'u-admin', username: 'admin', displayName: 'admin', source: 'local', teams: [], permissions: ['access:manage'], scopeAll: true, scopeServices: [], mustChangePassword: false, isAdmin: true } + vi.mock('../../contexts/AuthContext', () => ({ useAuth: (): AuthContextValue => ({ status: 'ready', - principal: { authenticated: true, kind: 'user', userId: 'u-admin', username: 'admin', displayName: 'admin', source: 'local', teams: [], permissions: ['access:manage'], scopeAll: true, scopeServices: [], mustChangePassword: false, isAdmin: true }, + principal: adminPrincipal, config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, hasPermission: () => true, inScope: () => true, logout: async () => {}, - reload: async () => {}, + reload: async () => adminPrincipal, + showToast: vi.fn(), }), })) diff --git a/web/src/pages/admin/UsersPage.test.tsx b/web/src/pages/admin/UsersPage.test.tsx index d0c9e7a2..b992b64f 100644 --- a/web/src/pages/admin/UsersPage.test.tsx +++ b/web/src/pages/admin/UsersPage.test.tsx @@ -15,15 +15,18 @@ const users: User[] = [ { id: 'u-bob', username: 'bob', email: 'bob@x.io', displayName: 'Bob', source: 'oidc', teamIds: ['t-plat'], disabled: true, mustChangePassword: false }, ] +const adminPrincipal = { authenticated: true, kind: 'user' as const, userId: 'u-admin', username: 'admin', displayName: 'admin', source: 'local', teams: [{ id: 't-admin', name: 'Administrators' }], permissions: ['access:manage'], scopeAll: true, scopeServices: [], mustChangePassword: false, isAdmin: true } + vi.mock('../../contexts/AuthContext', () => ({ useAuth: (): AuthContextValue => ({ status: 'ready', - principal: { authenticated: true, kind: 'user', userId: 'u-admin', username: 'admin', displayName: 'admin', source: 'local', teams: [{ id: 't-admin', name: 'Administrators' }], permissions: ['access:manage'], scopeAll: true, scopeServices: [], mustChangePassword: false, isAdmin: true }, + principal: adminPrincipal, config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, hasPermission: () => true, inScope: () => true, logout: async () => {}, - reload: async () => {}, + reload: async () => adminPrincipal, + showToast: vi.fn(), }), })) From 8b67b6ad85e2a66a70f02b3d365500ad2fa8bcd4 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 21:13:42 +0200 Subject: [PATCH 19/28] test(web): cover forced password change ordering on the login page --- web/src/pages/Login.test.tsx | 77 ++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Login.test.tsx b/web/src/pages/Login.test.tsx index a3c602f4..a6f3a458 100644 --- a/web/src/pages/Login.test.tsx +++ b/web/src/pages/Login.test.tsx @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { renderWithProviders } from '../test/renderWithProviders' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { LocationProbe, renderWithProviders } from '../test/renderWithProviders' import Login from './Login' +import { RequirePermission } from '../components/auth/RequirePermission' import type { AuthContextValue } from '../contexts/AuthContext' import type { Principal } from '../types/auth' @@ -23,13 +25,16 @@ const anonymous: Principal = { const alice: Principal = { ...anonymous, authenticated: true, kind: 'user', userId: 'u1', username: 'alice', displayName: 'Alice', source: 'local' } +// Mutable like `oidcEnabled` below: the mock factory reads through this +// binding on every render, so tests can flip it mid-scenario. +let currentPrincipal: Principal = anonymous const reload = vi.fn(async () => alice) let oidcEnabled = false vi.mock('../contexts/AuthContext', () => ({ useAuth: (): AuthContextValue => ({ status: 'ready', - principal: anonymous, + principal: currentPrincipal, config: { localLoginEnabled: true, oidcEnabled, oidcButtonLabel: 'Sign in with Okta', anonymousPermissions: [], demoMode: false }, hasPermission: () => false, inScope: () => true, @@ -51,7 +56,9 @@ const mocked = authApi as unknown as { login: ReturnType; me: Retu beforeEach(() => { oidcEnabled = false - reload.mockClear() + currentPrincipal = anonymous + reload.mockReset() + reload.mockResolvedValue(alice) mocked.login.mockReset() mocked.me.mockReset() mocked.me.mockResolvedValue(alice) @@ -64,6 +71,34 @@ async function fillAndSubmit(username: string, password: string) { await user.click(screen.getByRole('button', { name: 'Sign in' })) } +/** + * Mounts `/login` next to the handful of distinct routes it can redirect to, + * matching how App.tsx separates them. `renderWithProviders`'s single + * catch-all route would keep Login mounted after a redirect and let it + * re-derive `target` from the now-query-less URL, which is not how the real + * router behaves once Login has actually navigated away. + */ +function renderLoginRoutes(entry: string) { + return render( + + + } /> + Catalog page
} /> + Locks page} /> + +
Change password
+ + } + /> + + + , + ) +} + describe('Login', () => { it('signs in and follows the validated redirect', async () => { mocked.login.mockResolvedValue(undefined) @@ -107,6 +142,40 @@ describe('Login', () => { ) }) + it('sends an already signed-in visitor to the redirect target', () => { + currentPrincipal = alice + renderLoginRoutes('/login?redirect=%2Fcatalog') + expect(screen.getByTestId('location')).toHaveTextContent('/catalog') + expect(screen.getByText('Catalog page')).toBeInTheDocument() + }) + + it('sends an already signed-in visitor who must change their password to the password page', () => { + currentPrincipal = { ...alice, mustChangePassword: true } + renderLoginRoutes('/login?redirect=%2Flocks') + expect(screen.getByTestId('location')).toHaveTextContent('/account/password?redirect=%2Flocks') + expect(screen.getByText('Change password')).toBeInTheDocument() + }) + + it('refreshes the context before the forced password change redirect', async () => { + mocked.login.mockResolvedValue(undefined) + // Mimics AuthContext.reload(): the principal only flips to the + // must-change-password state once reload resolves, after a real tick + // (a network round trip in production). If Login navigated before + // awaiting reload, RequirePermission below renders with the still + // anonymous principal first and bounces back to /login instead. + reload.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + currentPrincipal = { ...alice, mustChangePassword: true } + return currentPrincipal + }) + renderLoginRoutes('/login?redirect=%2Fcatalog') + await fillAndSubmit('admin', 'temporary') + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent('/account/password?redirect=%2Fcatalog'), + ) + expect(screen.getByText('Change password')).toBeInTheDocument() + }) + it('offers the SSO button only when OIDC is enabled', () => { oidcEnabled = true renderWithProviders(, { route: '/login?redirect=%2Flocks' }) From 76a712bd85c8a6a3a02f123ce8ffe6d98f28e73b Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 21:15:40 +0200 Subject: [PATCH 20/28] ci: fail the web type check when tsc does not run --- .github/workflows/web.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 81426476..28a044c8 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -36,10 +36,23 @@ jobs: - name: Type check auth modules # The web tree carries pre-existing type errors outside the auth # modules; the full log is kept for visibility and only errors in - # the files owned by the auth work fail the job. + # the files owned by the auth work fail the job. LockActions.tsx is + # excluded from the filter below: it carries a pre-existing TS2322 + # on main (line 39) that predates and is unrelated to the auth work. run: | set -o pipefail - npx tsc --noEmit --pretty false 2>&1 | tee tsc.log || true + # tsc exits 2 when it finds type errors: that is a normal outcome + # here, handled by the grep gate below. Disable -e around the + # pipeline itself so a real failure (missing binary, broken + # tsconfig, OOM) does not abort the script before PIPESTATUS is read. + set +e + npx tsc --noEmit --pretty false 2>&1 | tee tsc.log + status=${PIPESTATUS[0]} + set -e + if [ "$status" -ne 0 ] && [ "$status" -ne 2 ]; then + echo "tsc failed to run (exit $status)" >&2 + exit 1 + fi if grep -E '^src/(types/auth\.ts|lib/authApi(\.test)?\.ts|lib/authEvents(\.test)?\.ts|lib/api\.ts|contexts/AuthContext(\.test)?\.tsx|components/auth/[A-Za-z.]+\.tsx|components/admin/[A-Za-z.]+\.tsx|components/Toast\.tsx|components/Layout\.tsx|components/DemoBanner\.tsx|pages/Login(\.test)?\.tsx|pages/account/[A-Za-z.]+\.tsx|pages/admin/[A-Za-z.]+\.tsx|pages/Links\.tsx|pages/Locks\.tsx|App(\.test)?\.tsx|test/[A-Za-z.]+\.tsx?)\(' tsc.log; then echo "type errors in auth modules" >&2 exit 1 From 4649d15aea1085e728cc30ef4cce77e912eb4494 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 21:21:09 +0200 Subject: [PATCH 21/28] fix(web): tighten admin dialogs and cache handling --- web/src/components/Toast.tsx | 2 +- web/src/components/admin/ConfirmDialog.tsx | 4 +-- web/src/components/admin/TeamFormDialog.tsx | 8 +++-- web/src/contexts/AuthContext.test.tsx | 4 +-- web/src/contexts/AuthContext.tsx | 4 +-- web/src/pages/admin/ApiKeysPage.test.tsx | 17 ++++++++++ web/src/pages/admin/ApiKeysPage.tsx | 13 ++++---- web/src/pages/admin/TeamsPage.test.tsx | 37 +++++++++++++++++++-- web/src/pages/admin/TeamsPage.tsx | 10 ++++-- 9 files changed, 78 insertions(+), 21 deletions(-) diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index 30a3b7e7..3a2187c6 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -27,7 +27,7 @@ export default function Toast({ message, onClose, duration = 3000, variant = 'su const Icon = variant === 'error' ? AlertCircle : CheckCircle return ( -
+
{message} diff --git a/web/src/components/admin/ConfirmDialog.tsx b/web/src/components/admin/ConfirmDialog.tsx index 6a9e46da..a0396560 100644 --- a/web/src/components/admin/ConfirmDialog.tsx +++ b/web/src/components/admin/ConfirmDialog.tsx @@ -15,9 +15,9 @@ interface ConfirmDialogProps { export function ConfirmDialog({ open, title, message, confirmLabel = 'Confirm', destructive = false, pending = false, onConfirm, onClose }: ConfirmDialogProps) { return ( - + - {title} + {title}
diff --git a/web/src/components/admin/TeamFormDialog.tsx b/web/src/components/admin/TeamFormDialog.tsx index 6dde516d..ee0363df 100644 --- a/web/src/components/admin/TeamFormDialog.tsx +++ b/web/src/components/admin/TeamFormDialog.tsx @@ -43,8 +43,12 @@ export function TeamFormDialog({ open, team, members, pending, error, onSubmit, const togglePermission = (perm: string, checked: boolean) => { setPermissions((current) => { const next = checked ? [...current, perm] : current.filter((p) => p !== perm) - // Keep the canonical order so the payload is stable. - return ALL_PERMISSIONS.filter((p) => next.includes(p)) + // Keep the canonical order so the payload is stable, but preserve any + // permission string the frontend union does not know about yet + // (appended after the canonical ones) instead of silently dropping it. + const known = ALL_PERMISSIONS.filter((p) => next.includes(p)) + const unknown = next.filter((p) => !(ALL_PERMISSIONS as readonly string[]).includes(p)) + return [...known, ...unknown] }) } diff --git a/web/src/contexts/AuthContext.test.tsx b/web/src/contexts/AuthContext.test.tsx index 568d11c1..e4e90163 100644 --- a/web/src/contexts/AuthContext.test.tsx +++ b/web/src/contexts/AuthContext.test.tsx @@ -173,7 +173,7 @@ describe('AuthProvider', () => { ) await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) act(() => emitForbidden('/event')) - expect(await screen.findByRole('status')).toHaveTextContent('Access denied') + expect(await screen.findByRole('alert')).toHaveTextContent('Access denied') }) it('signs out, adopts the server anonymous principal and lands on plain /login', async () => { @@ -205,7 +205,7 @@ describe('AuthProvider', () => { await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) mocked.logout.mockRejectedValueOnce({ status: 500 }) await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })) - expect(await screen.findByRole('status')).toHaveTextContent('Sign out failed') + expect(await screen.findByRole('alert')).toHaveTextContent('Sign out failed') expect(screen.getByTestId('user')).toHaveTextContent('alice') expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') }) diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index c02deb42..4daf802c 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -26,7 +26,7 @@ export interface AuthContextValue { status: 'loading' | 'ready' principal: Principal config: AuthConfig - hasPermission: (perm: Permission | string) => boolean + hasPermission: (perm: Permission) => boolean inScope: (service: string) => boolean logout: () => Promise reload: () => Promise @@ -103,7 +103,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [navigate]) const hasPermission = useCallback( - (perm: Permission | string) => principal.permissions.includes(perm), + (perm: Permission) => principal.permissions.includes(perm), [principal.permissions], ) diff --git a/web/src/pages/admin/ApiKeysPage.test.tsx b/web/src/pages/admin/ApiKeysPage.test.tsx index 959917a8..df47e34f 100644 --- a/web/src/pages/admin/ApiKeysPage.test.tsx +++ b/web/src/pages/admin/ApiKeysPage.test.tsx @@ -76,6 +76,23 @@ describe('ApiKeysPage', () => { expect(await screen.findByText('trk_zzzz.supersecret')).toBeInTheDocument() }) + it('shows the create form, not the previous secret, when reopened', async () => { + mocked.createApiKey.mockResolvedValue({ apiKey: { ...keys[0], id: 'k9', prefix: 'trk_zzzz', name: 'new-key' }, secret: 'trk_zzzz.supersecret' }) + renderWithProviders(, { route: '/admin/api-keys' }) + await screen.findByText('trk_aaaa') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'New API key' })) + await user.type(within(screen.getByRole('dialog')).getByLabelText('Name'), 'new-key') + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Create key' })) + expect(await screen.findByText('trk_zzzz.supersecret')).toBeInTheDocument() + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Done' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'New API key' })) + const reopened = screen.getByRole('dialog') + expect(within(reopened).getByLabelText('Name')).toBeInTheDocument() + expect(within(reopened).queryByText('trk_zzzz.supersecret')).not.toBeInTheDocument() + }) + it('revokes a key after confirmation', async () => { mocked.revokeApiKey.mockResolvedValue(undefined) renderWithProviders(, { route: '/admin/api-keys' }) diff --git a/web/src/pages/admin/ApiKeysPage.tsx b/web/src/pages/admin/ApiKeysPage.tsx index 999d09be..8e9a8ef5 100644 --- a/web/src/pages/admin/ApiKeysPage.tsx +++ b/web/src/pages/admin/ApiKeysPage.tsx @@ -46,12 +46,6 @@ export default function ApiKeysPage() { return (id: string) => map.get(id) ?? id }, [teamsQuery.data]) - const closeDialog = useCallback(() => { - setDialogOpen(false) - setResult(null) - setError(null) - }, []) - const createMutation = useMutation({ mutationFn: (input: CreateApiKeyInput) => authApi.createApiKey(input), onSuccess: (created) => { @@ -62,6 +56,13 @@ export default function ApiKeysPage() { onError: (err) => setError(getApiErrorMessage(err, 'Could not create the API key')), }) + const closeDialog = useCallback(() => { + setDialogOpen(false) + setResult(null) + setError(null) + createMutation.reset() + }, [createMutation]) + const revokeMutation = useMutation({ mutationFn: (id: string) => authApi.revokeApiKey(id), onSuccess: () => { diff --git a/web/src/pages/admin/TeamsPage.test.tsx b/web/src/pages/admin/TeamsPage.test.tsx index 14b657f4..cb8a55dd 100644 --- a/web/src/pages/admin/TeamsPage.test.tsx +++ b/web/src/pages/admin/TeamsPage.test.tsx @@ -3,11 +3,14 @@ import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../../test/renderWithProviders' import TeamsPage from './TeamsPage' +import { QUERY_KEYS } from '../../components/admin/adminUi' import type { Team, User } from '../../types/auth' const teams: Team[] = [ { id: 't-admin', name: 'Administrators', description: 'Built-in', permissions: ['access:manage'], scopeAll: true, scopeServices: [], oidcGroups: [], builtin: true }, - { id: 't-plat', name: 'Platform', description: 'Platform team', permissions: ['event:read', 'event:write'], scopeAll: true, scopeServices: [], oidcGroups: ['platform-eng'], builtin: false }, + // 'legacy:custom' is not in the frontend's Permission union: it stands in + // for a permission the backend knows about that this build does not yet. + { id: 't-plat', name: 'Platform', description: 'Platform team', permissions: ['event:read', 'event:write', 'legacy:custom'], scopeAll: true, scopeServices: [], oidcGroups: ['platform-eng'], builtin: false }, ] const users: User[] = [ { id: 'u-admin', username: 'admin', email: '', displayName: 'admin', source: 'local', teamIds: ['t-admin'], disabled: false, mustChangePassword: false }, @@ -94,9 +97,10 @@ describe('TeamsPage', () => { ) }) - it('deletes a team after confirmation', async () => { + it('deletes a team after confirmation and refreshes users and API keys too', async () => { mocked.deleteTeam.mockResolvedValue(undefined) - renderWithProviders(, { route: '/admin/teams' }) + const { queryClient } = renderWithProviders(, { route: '/admin/teams' }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') const platRow = (await screen.findByText('Platform')).closest('tr') as HTMLElement const user = userEvent.setup() await user.click(within(platRow).getByRole('button', { name: 'Delete' })) @@ -104,5 +108,32 @@ describe('TeamsPage', () => { await user.click(screen.getByRole('button', { name: 'Delete team' })) await waitFor(() => expect(mocked.deleteTeam).toHaveBeenCalledWith('t-plat')) expect(await screen.findByRole('status')).toHaveTextContent('Team deleted') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.users }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.apiKeys }) + }) + + it('preserves a permission unknown to this build when toggling another one', async () => { + mocked.updateTeam.mockResolvedValue(teams[1]) + const { queryClient } = renderWithProviders(, { route: '/admin/teams' }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + const platRow = (await screen.findByText('Platform')).closest('tr') as HTMLElement + const user = userEvent.setup() + await user.click(within(platRow).getByRole('button', { name: 'Edit' })) + const dialog = screen.getByRole('dialog') + await user.click(within(dialog).getByLabelText('catalog:read')) + await user.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(mocked.updateTeam).toHaveBeenCalledWith('t-plat', { + name: 'Platform', + description: 'Platform team', + permissions: ['event:read', 'event:write', 'catalog:read', 'legacy:custom'], + scopeAll: true, + scopeServices: [], + oidcGroups: ['platform-eng'], + }), + ) + expect(await screen.findByRole('status')).toHaveTextContent('Team updated') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.users }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.apiKeys }) }) }) diff --git a/web/src/pages/admin/TeamsPage.tsx b/web/src/pages/admin/TeamsPage.tsx index 133cafc6..7cd89fb1 100644 --- a/web/src/pages/admin/TeamsPage.tsx +++ b/web/src/pages/admin/TeamsPage.tsx @@ -33,8 +33,12 @@ export default function TeamsPage() { setError(null) }, []) - const onSuccess = (message: string) => { + // A team rename or removal can also invalidate what users/apiKeys have + // cached (team names shown inline, ids that stop resolving), so those two + // callers pass their query keys along; a fresh team never appears there. + const onSuccess = (message: string, alsoInvalidate: (readonly unknown[])[] = []) => { void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.teams }) + for (const queryKey of alsoInvalidate) void queryClient.invalidateQueries({ queryKey }) closeDialog() setDeleting(null) setToast(message) @@ -47,12 +51,12 @@ export default function TeamsPage() { }) const updateMutation = useMutation({ mutationFn: ({ id, input }: { id: string; input: TeamInput }) => authApi.updateTeam(id, input), - onSuccess: () => onSuccess('Team updated'), + onSuccess: () => onSuccess('Team updated', [QUERY_KEYS.users, QUERY_KEYS.apiKeys]), onError: (err) => setError(getApiErrorMessage(err, 'Could not update the team')), }) const deleteMutation = useMutation({ mutationFn: (id: string) => authApi.deleteTeam(id), - onSuccess: () => onSuccess('Team deleted'), + onSuccess: () => onSuccess('Team deleted', [QUERY_KEYS.users, QUERY_KEYS.apiKeys]), onError: (err) => { setDeleting(null) setToast(getApiErrorMessage(err, 'Could not delete the team')) From d527382ee5a27abbb7c792a979f2d0dc1dae21e3 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 21:26:18 +0200 Subject: [PATCH 22/28] fix(web): disable local sign-in in static mode --- web/src/components/auth/UserMenu.test.tsx | 14 ++++++++++++-- web/src/components/auth/UserMenu.tsx | 6 +++++- web/src/contexts/AuthContext.tsx | 9 ++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/web/src/components/auth/UserMenu.test.tsx b/web/src/components/auth/UserMenu.test.tsx index 86c98ad1..965d9c77 100644 --- a/web/src/components/auth/UserMenu.test.tsx +++ b/web/src/components/auth/UserMenu.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../../test/renderWithProviders' import { UserMenu } from './UserMenu' import type { AuthContextValue } from '../../contexts/AuthContext' -import type { Principal } from '../../types/auth' +import type { AuthConfig, Principal } from '../../types/auth' const anonymous: Principal = { authenticated: false, @@ -21,6 +21,7 @@ const anonymous: Principal = { isAdmin: false, } let current: Principal = anonymous +let currentConfig: AuthConfig = { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false } const logout = vi.fn(async () => {}) const showToast = vi.fn() @@ -28,7 +29,7 @@ vi.mock('../../contexts/AuthContext', () => ({ useAuth: (): AuthContextValue => ({ status: 'ready', principal: current, - config: { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false }, + config: currentConfig, hasPermission: (p: string) => current.permissions.includes(p), inScope: () => true, logout, @@ -39,6 +40,7 @@ vi.mock('../../contexts/AuthContext', () => ({ beforeEach(() => { showToast.mockClear() + currentConfig = { localLoginEnabled: true, oidcEnabled: false, oidcButtonLabel: '', anonymousPermissions: [], demoMode: false } }) describe('UserMenu', () => { @@ -48,6 +50,14 @@ describe('UserMenu', () => { expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/login?redirect=%2Flocks%3Fenv%3Dprod') }) + it('renders nothing when no sign-in method is enabled', () => { + current = anonymous + currentConfig = { ...currentConfig, localLoginEnabled: false, oidcEnabled: false } + renderWithProviders(, { route: '/locks' }) + expect(screen.queryByRole('link', { name: 'Sign in' })).not.toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + it('shows the account, source and actions for a local user', async () => { current = { ...anonymous, authenticated: true, kind: 'user', username: 'alice', displayName: 'Alice Doe', source: 'local', teams: [{ id: 't1', name: 'Platform' }] } renderWithProviders(, { route: '/dashboard' }) diff --git a/web/src/components/auth/UserMenu.tsx b/web/src/components/auth/UserMenu.tsx index a1a142d4..12848ef9 100644 --- a/web/src/components/auth/UserMenu.tsx +++ b/web/src/components/auth/UserMenu.tsx @@ -12,7 +12,7 @@ function initials(name: string): string { } export function UserMenu() { - const { principal, logout, showToast } = useAuth() + const { principal, config, logout, showToast } = useAuth() const location = useLocation() const [open, setOpen] = useState(false) const rootRef = useRef(null) @@ -41,6 +41,10 @@ export function UserMenu() { } if (principal.kind !== 'user') { + // No sign-in method is reachable (static build, or a backend with both + // local login and OIDC turned off): a link into /login would just show + // its own "no sign-in method" message, so skip it entirely here. + if (!config.localLoginEnabled && !config.oidcEnabled) return null return ( diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 4daf802c..7a925454 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -74,7 +74,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { locationRef.current = location const reload = useCallback(async (): Promise => { - if (isStaticMode) return ANONYMOUS_FALLBACK + if (isStaticMode) { + // No backend to sign in against on the static (GitHub Pages) build: + // advertising a local sign-in form there would post to an endpoint + // that does not exist. + setConfig({ ...DEFAULT_CONFIG, localLoginEnabled: false }) + setStatus('ready') + return ANONYMOUS_FALLBACK + } const [cfg, me] = await Promise.allSettled([authApi.getConfig(), authApi.me()]) if (cfg.status === 'fulfilled') setConfig(cfg.value) const resolved = me.status === 'fulfilled' ? me.value : principalAfterMeFailure(me.reason) From af70264124354c6c8f8b9eb80cba57a013fcf8b5 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 23:50:47 +0200 Subject: [PATCH 23/28] fix(web): keep the routed tree hidden while the sign-out navigation is in flight Route changes are React transitions, so after sign-out a protected page re-rendered with the anonymous principal before the navigation committed and its guard pushed /login?redirect=. The provider now shows the loading screen from the sign-out until the location reaches /login. Reproduced in a browser on the built image and pinned by a test that signs out from a guarded route. --- web/src/contexts/AuthContext.test.tsx | 31 +++++++++++++++++++++++++++ web/src/contexts/AuthContext.tsx | 22 +++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/web/src/contexts/AuthContext.test.tsx b/web/src/contexts/AuthContext.test.tsx index e4e90163..88d83166 100644 --- a/web/src/contexts/AuthContext.test.tsx +++ b/web/src/contexts/AuthContext.test.tsx @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test/renderWithProviders' +import { Route, Routes } from 'react-router-dom' +import { RequirePermission } from '../components/auth/RequirePermission' import { AuthProvider, useAuth } from './AuthContext' import { emitForbidden, emitUnauthorized } from '../lib/authEvents' import type { AuthConfig, Principal } from '../types/auth' @@ -191,6 +193,35 @@ describe('AuthProvider', () => { expect(mocked.logout).toHaveBeenCalled() await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/login')) expect(screen.getByTestId('location')).not.toHaveTextContent('?') + // The routed tree is hidden while the sign-out navigation is in flight. + await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('anonymous')) + expect(screen.getByTestId('status')).toHaveTextContent('ready') + }) + + it('signs out from a guarded route without letting the guard add a redirect', async () => { + mocked.me.mockResolvedValueOnce({ ...alice, permissions: ['access:manage'] }) + renderWithProviders( + + + + + + } + /> + } /> + + , + { route: '/admin/users' }, + ) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + mocked.me.mockResolvedValueOnce(loggedOutByServer) + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })) + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/login')) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('ready')) + expect(screen.getByTestId('location')).toHaveTextContent(/^\/login$/) expect(screen.getByTestId('user')).toHaveTextContent('anonymous') }) diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 7a925454..43e01c00 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -72,6 +72,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { // latest value through a ref so the listener is registered once. const locationRef = useRef(location) locationRef.current = location + // Set while a sign-out navigation is in flight; see logout(). + const pendingLogout = useRef(false) const reload = useCallback(async (): Promise => { if (isStaticMode) { @@ -129,14 +131,26 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (err) { me = principalAfterMeFailure(err) } - // Set the anonymous principal and navigate in the same synchronous - // block (no await between them) so React batches both: no protected - // page gets a chance to render with an anonymous principal and its own - // route guard's `/login?redirect=...`, only the plain `/login` below. + // Route changes are React transitions (v7_startTransition), so they + // commit after urgent state updates: a protected page would re-render + // with the anonymous principal first and its route guard would push its + // own `/login?redirect=...`. Unmount the routed tree (status loading) + // until the location has actually reached /login, then show it again. + if (locationRef.current.pathname !== '/login') { + pendingLogout.current = true + setStatus('loading') + } setPrincipal(me) navigate('/login', { replace: true }) }, [navigate]) + useEffect(() => { + if (pendingLogout.current && location.pathname === '/login') { + pendingLogout.current = false + setStatus('ready') + } + }, [location.pathname]) + const showToast = useCallback((message: string) => setToast(message), []) const closeToast = useCallback(() => setToast(null), []) From 720164f5211612da76e6f5119226df0e8c34f46b Mon Sep 17 00:00:00 2001 From: umignon Date: Mon, 28 Sep 2026 09:20:50 +0200 Subject: [PATCH 24/28] test(web): drop the react-router v6 future flags from test routers main moved to react-router 7 (#202), where the v7_* future flags are the default behaviour and no longer part of the MemoryRouter props. --- web/src/contexts/AuthContext.tsx | 2 +- web/src/pages/Login.test.tsx | 2 +- web/src/test/renderWithProviders.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 43e01c00..c3cd13e7 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -131,7 +131,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (err) { me = principalAfterMeFailure(err) } - // Route changes are React transitions (v7_startTransition), so they + // Route changes are React transitions (react-router 7), so they // commit after urgent state updates: a protected page would re-render // with the anonymous principal first and its route guard would push its // own `/login?redirect=...`. Unmount the routed tree (status loading) diff --git a/web/src/pages/Login.test.tsx b/web/src/pages/Login.test.tsx index a6f3a458..0be6a3d2 100644 --- a/web/src/pages/Login.test.tsx +++ b/web/src/pages/Login.test.tsx @@ -80,7 +80,7 @@ async function fillAndSubmit(username: string, password: string) { */ function renderLoginRoutes(entry: string) { return render( - + } /> Catalog page
} /> diff --git a/web/src/test/renderWithProviders.tsx b/web/src/test/renderWithProviders.tsx index bf96289b..5e0c7387 100644 --- a/web/src/test/renderWithProviders.tsx +++ b/web/src/test/renderWithProviders.tsx @@ -34,7 +34,7 @@ export function renderWithProviders(ui: ReactElement, { route = '/', ...options const queryClient = createTestQueryClient() const Wrapper = ({ children }: { children: ReactNode }) => ( - + Date: Mon, 28 Sep 2026 09:20:50 +0200 Subject: [PATCH 25/28] ci: bump checkout and setup-node to v7 in the web workflow Aligns with #205 while keeping the actions pinned to commit SHAs. --- .github/workflows/web.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 28a044c8..a2d15cdb 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -18,10 +18,10 @@ jobs: working-directory: web steps: - name: Check out code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm From 7ac94a1ab19e105479afe9aef88a18223a43cdb7 Mon Sep 17 00:00:00 2001 From: umignon Date: Fri, 2 Oct 2026 16:06:32 +0200 Subject: [PATCH 26/28] fix(web): use the Tailwind 4 utility names in the auth components main moved to Tailwind 4 (#206): rounded becomes rounded-sm and outline-none becomes outline-hidden in the user menu and the API key dialog. --- web/src/components/admin/ApiKeyCreateDialog.tsx | 2 +- web/src/components/auth/UserMenu.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/admin/ApiKeyCreateDialog.tsx b/web/src/components/admin/ApiKeyCreateDialog.tsx index 1d629c6c..b8f2e21f 100644 --- a/web/src/components/admin/ApiKeyCreateDialog.tsx +++ b/web/src/components/admin/ApiKeyCreateDialog.tsx @@ -20,7 +20,7 @@ interface ApiKeyCreateDialogProps { } const selectClass = - 'flex h-9 w-full rounded-md border border-hud-outline-var bg-hud-surface px-3 py-2 text-sm text-hud-on-surface focus:outline-none focus:border-hud-primary' + 'flex h-9 w-full rounded-md border border-hud-outline-var bg-hud-surface px-3 py-2 text-sm text-hud-on-surface focus:outline-hidden focus:border-hud-primary' export function ApiKeyCreateDialog({ open, teams, canCreateGlobal, pending, error, result, onSubmit, onClose }: ApiKeyCreateDialogProps) { const [name, setName] = useState('') diff --git a/web/src/components/auth/UserMenu.tsx b/web/src/components/auth/UserMenu.tsx index 12848ef9..19395e3d 100644 --- a/web/src/components/auth/UserMenu.tsx +++ b/web/src/components/auth/UserMenu.tsx @@ -85,16 +85,16 @@ export function UserMenu() {

{name}

{principal.username}

- + {principal.source || 'session'} {principal.isAdmin && ( - + admin )} {principal.teams.map((t) => ( - + {t.name} ))} From d49ca742f1c268c8e4c61d8789de32a9464a1ba2 Mon Sep 17 00:00:00 2001 From: umignon Date: Fri, 2 Oct 2026 16:07:06 +0200 Subject: [PATCH 27/28] docs: describe the SSO button and the web test runner The login page now carries the Single Sign-On button announced by the OpenID Connect documentation, and CLAUDE.md said no frontend test runner existed. --- CLAUDE.md | 3 ++- docs/AUTHENTICATION.md | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef660acb..59dd7912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ task generate # buf dep update + buf generate (regenerate gen npm run dev # Vite dev server on :3000, proxies /api to VITE_BACKEND_URL (default http://localhost:8080) npm run lint # eslint, --max-warnings 0 npm run build:check # tsc && vite build (type check) +npm test # vitest run (auth modules) npm run build # production build into web/dist npm run build:static # demo build, VITE_STATIC_MODE=true @@ -29,7 +30,7 @@ task build:all # frontend then Go binary into bin/ docker run -d -p 27017:27017 --name tracker-mongo mongo:7 # local DB ``` -There is **no frontend test runner configured** — `web/src/**/__tests__/` directories exist but are empty and no vitest/jest dependency is installed. Do not invent `npm test`. Load tests live in `tests/k6/` (`task k6:generate`, `task k6:test-locks`). +Frontend tests run with vitest and Testing Library: `npm test` from `web/` (`npm run test:watch` to watch, `npm run lint:auth` for the lint-clean auth modules). The suite covers the auth modules; most other pages have no tests yet. Load tests live in `tests/k6/` (`task k6:generate`, `task k6:test-locks`). Note: `task dev:all` prints "Frontend sur http://localhost:5173" but `web/vite.config.ts` sets port **3000**. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index ebbe9742..f0c9fd04 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -133,9 +133,8 @@ every claim it needs must be in the `id_token`. The local `admin` account keeps working next to SSO and is the way back in when the IdP is misconfigured or down. SSO is off unless `AUTH_OIDC_ISSUER` is set. When it is on, `GET /api/v1alpha1/auth/config` reports `oidcEnabled` and -`oidcButtonLabel`. The Single Sign-On button of the login page ships with the -web PR #201: until it is merged, start a login by opening -`/api/v1alpha1/auth/oidc/login` directly. +`oidcButtonLabel`, and the login page shows a Single Sign-On button that opens +`/api/v1alpha1/auth/oidc/login`. ### Configuration From e36de2c5f427e62f5ba0581b51e658cdca05e8a4 Mon Sep 17 00:00:00 2001 From: umignon Date: Sun, 4 Oct 2026 20:25:51 +0200 Subject: [PATCH 28/28] fix(web): keep the scope of a team when editing it The team dialog always sent scopeAll=true, so saving a team restricted through the API silently widened it to every service. The dialog now sends back the scope it loaded and shows it read-only; a new team still gets all services. Refs #196 --- web/src/components/admin/TeamFormDialog.tsx | 20 ++++++++++++++--- web/src/pages/admin/TeamsPage.test.tsx | 24 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/web/src/components/admin/TeamFormDialog.tsx b/web/src/components/admin/TeamFormDialog.tsx index ee0363df..80c262b0 100644 --- a/web/src/components/admin/TeamFormDialog.tsx +++ b/web/src/components/admin/TeamFormDialog.tsx @@ -59,7 +59,17 @@ export function TeamFormDialog({ open, team, members, pending, error, onSubmit, setLocalError('Name is required') return } - onSubmit({ name: name.trim(), description: description.trim(), permissions, scopeAll: true, scopeServices: [], oidcGroups }) + // The dialog has no scope editor yet: an existing team keeps the scope it + // has (it may have been restricted through the API), a new team gets all + // services. + onSubmit({ + name: name.trim(), + description: description.trim(), + permissions, + scopeAll: team?.scopeAll ?? true, + scopeServices: team?.scopeServices ?? [], + oidcGroups, + }) } return ( @@ -99,8 +109,12 @@ export function TeamFormDialog({ open, team, members, pending, error, onSubmit,

Scope

-

All services

-

Per-service scope arrives in a later release.

+

+ {team && !team.scopeAll ? team.scopeServices.join(', ') : 'All services'} +

+

+ The scope is managed through the API for now; saving this form keeps it unchanged. +

diff --git a/web/src/pages/admin/TeamsPage.test.tsx b/web/src/pages/admin/TeamsPage.test.tsx index cb8a55dd..f25361cf 100644 --- a/web/src/pages/admin/TeamsPage.test.tsx +++ b/web/src/pages/admin/TeamsPage.test.tsx @@ -11,6 +11,8 @@ const teams: Team[] = [ // 'legacy:custom' is not in the frontend's Permission union: it stands in // for a permission the backend knows about that this build does not yet. { id: 't-plat', name: 'Platform', description: 'Platform team', permissions: ['event:read', 'event:write', 'legacy:custom'], scopeAll: true, scopeServices: [], oidcGroups: ['platform-eng'], builtin: false }, + // A team restricted through the API: the dialog has no scope editor yet. + { id: 't-pay', name: 'Payments', description: 'Payments team', permissions: ['event:read'], scopeAll: false, scopeServices: ['payments-api', 'ledger'], oidcGroups: [], builtin: false }, ] const users: User[] = [ { id: 'u-admin', username: 'admin', email: '', displayName: 'admin', source: 'local', teamIds: ['t-admin'], disabled: false, mustChangePassword: false }, @@ -136,4 +138,26 @@ describe('TeamsPage', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.users }) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.apiKeys }) }) + + it('keeps the scope of a restricted team when editing it', async () => { + mocked.updateTeam.mockResolvedValue(teams[2]) + renderWithProviders(, { route: '/admin/teams' }) + const payRow = (await screen.findByText('Payments')).closest('tr') as HTMLElement + const user = userEvent.setup() + await user.click(within(payRow).getByRole('button', { name: 'Edit' })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('payments-api, ledger')).toBeInTheDocument() + await user.click(within(dialog).getByLabelText('event:write')) + await user.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(mocked.updateTeam).toHaveBeenCalledWith('t-pay', { + name: 'Payments', + description: 'Payments team', + permissions: ['event:read', 'event:write'], + scopeAll: false, + scopeServices: ['payments-api', 'ledger'], + oidcGroups: [], + }), + ) + }) })