diff --git a/admin-web/index.html b/admin-web/index.html index 59999fd..4e85ff2 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -4,11 +4,12 @@ + OSG 运营后台
- + diff --git a/admin-web/package-lock.json b/admin-web/package-lock.json index 2cb2119..70d5a45 100644 --- a/admin-web/package-lock.json +++ b/admin-web/package-lock.json @@ -8,11 +8,27 @@ "name": "osg-admin-web", "version": "0.1.0", "dependencies": { - "@awesome.me/webawesome": "^3.11.0" + "@base-ui/react": "^1.7.0", + "@tanstack/react-table": "^9.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "sonner": "^2.0.8", + "tailwind-merge": "^3.6.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^5.2.0", "jsdom": "^27.0.1", + "tailwindcss": "^4.3.3", "typescript": "^5.9.2", "vite": "^7.1.2", "vitest": "^3.2.4" @@ -53,120 +69,374 @@ "dev": true, "license": "MIT" }, - "node_modules/@awesome.me/webawesome": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@awesome.me/webawesome/-/webawesome-3.11.0.tgz", - "integrity": "sha512-WM8kyP+AdAT3/zwNpc/YzN3Nu5pRD6ksREFZAct/DIn6KMnQs8lPVfiPbJrOpKMk8OaSnQX19iiwFGjoswXVXw==", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, "license": "MIT", "dependencies": { - "@ctrl/tinycolor": "4.1.0", - "@floating-ui/dom": "^1.6.13", - "@konnorr/qr-creator": "^1.0.1", - "@lit-labs/ssr": "^4.1.0", - "@lit-labs/ssr-client": "^1.1.8", - "@lit/context": "^1.1.6", - "@lit/react": "^1.0.8", - "@shoelace-style/animations": "^1.2.0", - "@shoelace-style/localize": "^3.2.3", - "composed-offset-position": "^0.0.6", - "lit": "^3.2.1", - "marked": "^11.2.0", - "nanoid": "^5.1.5" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=14.17.0" + "node": ">=6.9.0" } }, - "node_modules/@awesome.me/webawesome/node_modules/@lit-labs/ssr": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-4.1.0.tgz", - "integrity": "sha512-m0zymVVlHB1ddJQ1lastsV8ROW3whFOiHJhVPQWd04MnGTkTlUUVLQctux1QlyD9BtLXNN6iASxv388vhgKMFg==", - "license": "BSD-3-Clause", + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", "dependencies": { - "@lit-labs/ssr-client": "^1.1.7", - "@lit-labs/ssr-dom-shim": "^1.6.0", - "@lit/reactive-element": "^2.0.4", - "@parse5/tools": "^0.3.0", - "enhanced-resolve": "^5.10.0", - "lit": "^3.1.2", - "lit-element": "^4.0.4", - "lit-html": "^3.1.2", - "node-fetch": "^3.2.8", - "parse5": "^7.1.1" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": ">=13.9.0" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@types/node": ">=20.0.0 <25.0.0" + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.7.0.tgz", + "integrity": "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.2", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" }, "peerDependenciesMeta": { - "@types/node": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { "optional": true } } }, - "node_modules/@awesome.me/webawesome/node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "node_modules/@base-ui/utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz", + "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@awesome.me/webawesome/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/@awesome.me/webawesome/node_modules/nanoid": { - "version": "5.1.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", - "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" } }, - "node_modules/@awesome.me/webawesome/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/@awesome.me/webawesome/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -307,15 +577,6 @@ "node": ">=20.19.0" } }, - "node_modules/@ctrl/tinycolor": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz", - "integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -777,12 +1038,57 @@ "@floating-ui/utils": "^0.2.12" } }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -790,54 +1096,15 @@ "dev": true, "license": "MIT" }, - "node_modules/@konnorr/qr-creator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@konnorr/qr-creator/-/qr-creator-1.1.0.tgz", - "integrity": "sha512-6Ae3AfG45MOqF9Qde+1YxIxKf/GUsRmcriV7iCb/HaR+Mk1YDFYP3RINDRMI8z8YMWW3BnSthOujX9RoOex/Kw==", - "license": "MIT" - }, - "node_modules/@lit-labs/ssr-client": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr-client/-/ssr-client-1.1.8.tgz", - "integrity": "sha512-PjGh81oKsoI64m3IDjTqqjhC7dr2uC/o0jrllUb5gRAyp/RlAHxapgJrjq9kWz97faCHLQ8jUlTi6tGm+8fgyA==", - "license": "BSD-3-Clause", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", "dependencies": { - "@lit/reactive-element": "^2.0.4", - "lit": "^3.1.2", - "lit-html": "^3.1.2" - } - }, - "node_modules/@lit-labs/ssr-dom-shim": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", - "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@lit/context": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz", - "integrity": "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^1.6.2 || ^2.1.0" - } - }, - "node_modules/@lit/react": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", - "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", - "license": "BSD-3-Clause", - "peerDependencies": { - "@types/react": "17 || 18 || 19" - } - }, - "node_modules/@lit/reactive-element": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", - "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { @@ -857,38 +1124,12 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@parse5/tools": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", - "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", - "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" - } - }, - "node_modules/@parse5/tools/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/@parse5/tools/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.4", @@ -1240,21 +1481,457 @@ "win32" ] }, - "node_modules/@shoelace-style/animations": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz", - "integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==", + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/claviska" + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" } }, - "node_modules/@shoelace-style/localize": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.3.tgz", - "integrity": "sha512-pWoZywizMilenLfQcpE9bjaqi7WKiQXJB42cxvmSPwYS76U776wJ7B5QQ0P28yVit1vpHuFZzq1CtfVFv32jaQ==", - "license": "MIT" + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "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/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "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", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1295,17 +1972,42 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } }, "node_modules/@vitest/expect": { "version": "3.2.7", @@ -1432,6 +2134,42 @@ "node": ">= 14" } }, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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", + "peer": true, + "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", @@ -1442,6 +2180,19 @@ "node": ">=12" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1452,6 +2203,40 @@ "require-from-string": "^2.0.2" } }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1462,6 +2247,27 @@ "node": ">=8" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1489,13 +2295,45 @@ "node": ">= 16" } }, - "node_modules/composed-offset-position": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", - "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", - "peerDependencies": { - "@floating-ui/utils": "^0.2.5" + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/css-tree": { @@ -1532,17 +2370,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } + "devOptional": true, + "license": "MIT" }, "node_modules/data-urls": { "version": "6.0.1", @@ -1603,10 +2432,47 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "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", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.411", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", + "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", + "dev": true, + "license": "ISC" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1678,6 +2544,16 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1716,41 +2592,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1766,10 +2607,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/html-encoding-sniffer": { @@ -1833,6 +2685,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1880,35 +2742,291 @@ } } }, - "node_modules/lit": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", - "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^2.1.0", - "lit-element": "^4.2.0", - "lit-html": "^3.3.0" + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" } }, - "node_modules/lit-element": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", - "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0", - "@lit/reactive-element": "^2.1.0", - "lit-html": "^3.3.0" + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, - "node_modules/lit-html": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", - "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", - "license": "BSD-3-Clause", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "@types/trusted-types": "^2.0.2" + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/loupe": { @@ -1928,6 +3046,26 @@ "node": "20 || >=22" } }, + "node_modules/lucide-react": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", + "integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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", + "peer": true, + "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", @@ -1938,18 +3076,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/marked": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz", - "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -1983,42 +3109,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "node": ">=18" } }, "node_modules/parse5": { @@ -2100,6 +3198,30 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "peer": true, + "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", + "peer": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2110,6 +3232,75 @@ "node": ">=6" } }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2120,6 +3311,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -2193,6 +3390,28 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2200,6 +3419,22 @@ "dev": true, "license": "ISC" }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2244,10 +3479,28 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2364,6 +3617,14 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2385,6 +3646,46 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2569,15 +3870,6 @@ "node": ">=18" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -2681,6 +3973,13 @@ "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", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" } } } diff --git a/admin-web/package.json b/admin-web/package.json index 8ae4cad..ceb6106 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -9,13 +9,29 @@ "test": "vitest run" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^5.2.0", "jsdom": "^27.0.1", + "tailwindcss": "^4.3.3", "typescript": "^5.9.2", "vite": "^7.1.2", "vitest": "^3.2.4" }, "dependencies": { - "@awesome.me/webawesome": "^3.11.0" + "@base-ui/react": "^1.7.0", + "@tanstack/react-table": "^9.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "sonner": "^2.0.8", + "tailwind-merge": "^3.6.0" } } diff --git a/admin-web/src/app.ts b/admin-web/src/app.ts deleted file mode 100644 index 33553c2..0000000 --- a/admin-web/src/app.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { adminApi, ApiError, setCsrfToken } from "./api/client"; -import type { AdminRole, AuthState } from "./api/types"; -import { renderLoading, showToast } from "./components/ui"; -import { escapeHtml } from "./lib/format"; -import { renderAudit } from "./pages/audit"; -import { renderPasswordLogin } from "./pages/auth"; -import { renderCredits } from "./pages/credits"; -import { renderOverview } from "./pages/overview"; -import { renderReferrals } from "./pages/referrals"; -import { renderSecurity } from "./pages/security"; -import { renderUsers } from "./pages/users"; - -type Route = - | "overview" - | "referrals" - | "users" - | "credits" - | "audit" - | "security"; - -interface RouteDefinition { - id: Route; - label: string; - icon: string; - roles: AdminRole[]; -} - -const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"]; -const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"]; -const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"]; - -const routes: RouteDefinition[] = [ - { - id: "overview", - label: "运营总览", - roles: ALL_ROLES, - icon: '', - }, - { - id: "referrals", - label: "裂变分析", - roles: ALL_ROLES, - icon: - '', - }, - { - id: "users", - label: "用户查询", - roles: SUPPORT_ROLES, - icon: - '', - }, - { - id: "credits", - label: "积分流水", - roles: SUPPORT_ROLES, - icon: - '', - }, - { - id: "audit", - label: "审计日志", - roles: SUPER_ADMIN_ONLY, - icon: - '', - }, - { - id: "security", - label: "安全中心", - roles: SUPER_ADMIN_ONLY, - icon: - '', - }, -]; - -export class AdminApp { - private auth: AuthState = { status: "anonymous" }; - private renderVersion = 0; - - constructor(private readonly root: HTMLElement) { - window.addEventListener("hashchange", () => void this.renderRoute()); - window.addEventListener("admin:unauthorized", () => { - setCsrfToken(); - this.auth = { status: "anonymous" }; - this.render(); - showToast("登录状态已失效,请重新登录", "error"); - }); - } - - async start(): Promise { - renderLoading(this.root, "检查登录状态"); - try { - const session = await adminApi.session(); - if (session.authenticated && session.operatorName && session.role) { - this.auth = { - status: "authenticated", - operatorName: session.operatorName, - role: session.role, - }; - } - } catch (error) { - if (!(error instanceof ApiError) || error.status !== 401) { - showToast("暂时无法确认登录状态", "error"); - } - } - this.render(); - } - - private render(): void { - if (this.auth.status === "anonymous") { - renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) }); - return; - } - this.renderShell(); - void this.renderRoute(); - } - - private setAuth(state: AuthState): void { - this.auth = state; - this.render(); - } - - private currentRoute(): Route { - const value = window.location.hash.replace(/^#\/?/, "") as Route; - return this.availableRoutes().some((route) => route.id === value) - ? value - : "overview"; - } - - private availableRoutes(): RouteDefinition[] { - if (this.auth.status !== "authenticated") return []; - const role = this.auth.role; - return routes.filter((route) => route.roles.includes(role)); - } - - private renderShell(): void { - if (this.auth.status !== "authenticated") return; - this.root.innerHTML = ` - -
- -
-
- - 运营总览 -
-
-
- -
- `; - - const sidebar = this.root.querySelector("[data-sidebar]"); - const menuButton = - this.root.querySelector("[data-menu]"); - const mobileLayout = window.matchMedia("(max-width: 1023px)"); - const syncSidebarState = () => { - if (!sidebar) return; - sidebar.inert = - mobileLayout.matches && !sidebar.classList.contains("sidebar--open"); - }; - const closeMenu = (restoreFocus = false) => { - sidebar?.classList.remove("sidebar--open"); - menuButton?.setAttribute("aria-expanded", "false"); - syncSidebarState(); - if (restoreFocus) menuButton?.focus(); - }; - const openMenu = () => { - sidebar?.classList.add("sidebar--open"); - menuButton?.setAttribute("aria-expanded", "true"); - syncSidebarState(); - sidebar - ?.querySelector('a[aria-current="page"], a') - ?.focus(); - }; - menuButton?.addEventListener("click", openMenu); - this.root - .querySelector("[data-scrim]") - ?.addEventListener("click", () => closeMenu(true)); - this.root.querySelectorAll("[data-route]").forEach((link) => { - link.addEventListener("click", () => closeMenu()); - }); - sidebar?.addEventListener("keydown", (event) => { - if (event.key === "Escape") closeMenu(true); - }); - mobileLayout.addEventListener("change", syncSidebarState); - syncSidebarState(); - this.root - .querySelector("[data-logout]") - ?.addEventListener("click", () => void this.logout()); - } - - private async renderRoute(): Promise { - if (this.auth.status !== "authenticated") return; - const route = this.currentRoute(); - this.root.querySelectorAll("[data-route]").forEach((link) => { - const active = link.dataset.route === route; - link.classList.toggle("active", active); - if (active) { - link.setAttribute("aria-current", "page"); - } else { - link.removeAttribute("aria-current"); - } - }); - const routeDefinition = routes.find((item) => item.id === route); - document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`; - const mobileTitle = - this.root.querySelector("[data-mobile-title]"); - if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心"; - - const content = this.root.querySelector("[data-content]"); - if (!content) return; - const version = ++this.renderVersion; - const stage = document.createElement("div"); - stage.className = "page-stage"; - renderLoading(content); - - if (route === "overview") await renderOverview(stage); - if (route === "referrals") await renderReferrals(stage); - if (route === "users") renderUsers(stage, this.auth.role); - if (route === "credits") renderCredits(stage); - if (route === "audit") await renderAudit(stage); - if (route === "security") { - await renderSecurity(stage, this.auth.operatorName); - } - - if (version === this.renderVersion) { - content.replaceChildren(stage); - const heading = content.querySelector("h1"); - if (heading) { - heading.tabIndex = -1; - heading.focus({ preventScroll: true }); - } else { - content.focus({ preventScroll: true }); - } - } - } - - private async logout(): Promise { - try { - await adminApi.logout(); - } catch { - // 即使服务端退出失败,也立即清理前端认证状态。 - } finally { - setCsrfToken(); - this.auth = { status: "anonymous" }; - window.history.replaceState(null, "", window.location.pathname); - this.render(); - } - } -} - -function roleLabel(role: AdminRole): string { - const labels: Record = { - SUPER_ADMIN: "超级管理员", - SUPPORT: "支持人员", - ANALYST: "分析员", - }; - return labels[role]; -} diff --git a/admin-web/src/app.tsx b/admin-web/src/app.tsx new file mode 100644 index 0000000..723dc56 --- /dev/null +++ b/admin-web/src/app.tsx @@ -0,0 +1,470 @@ +import { + Activity, + BookOpenCheck, + ChevronRight, + Coins, + GitBranch, + LogOut, + Menu, + Moon, + Search, + ShieldCheck, + Sun, + Users, + X, + type LucideIcon, +} from "lucide-react"; +import { + lazy, + Suspense, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { + HashRouter, + Navigate, + NavLink, + Route, + Routes, + useLocation, +} from "react-router-dom"; +import { Toaster, toast } from "sonner"; +import type { AdminRole } from "./api/types"; +import { ErrorBoundary } from "./components/error-boundary"; +import { Button, LoadingState } from "./components/primitives"; +import { AuthProvider, useAuth } from "./features/auth/auth-context"; +import { LoginPage } from "./features/auth/login-page"; +import { cn } from "./lib/utils"; + +const OverviewPage = lazy(() => + import("./features/overview/overview-page").then((module) => ({ + default: module.OverviewPage, + })), +); +const ReferralsPage = lazy(() => + import("./features/referrals/referrals-page").then((module) => ({ + default: module.ReferralsPage, + })), +); +const UsersPage = lazy(() => + import("./features/users/users-page").then((module) => ({ + default: module.UsersPage, + })), +); +const CreditsPage = lazy(() => + import("./features/credits/credits-page").then((module) => ({ + default: module.CreditsPage, + })), +); +const AuditPage = lazy(() => + import("./features/audit/audit-page").then((module) => ({ + default: module.AuditPage, + })), +); +const SecurityPage = lazy(() => + import("./features/security/security-page").then((module) => ({ + default: module.SecurityPage, + })), +); + +interface NavItem { + path: string; + label: string; + description: string; + icon: LucideIcon; + roles: AdminRole[]; +} + +const allRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"]; +const supportRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"]; + +const navigation: NavItem[] = [ + { + path: "/overview", + label: "运营总览", + description: "增长与消耗", + icon: Activity, + roles: allRoles, + }, + { + path: "/referrals", + label: "裂变分析", + description: "转化与排行", + icon: GitBranch, + roles: allRoles, + }, + { + path: "/users", + label: "用户查询", + description: "账户与使用", + icon: Users, + roles: supportRoles, + }, + { + path: "/credits", + label: "积分流水", + description: "不可变账本", + icon: Coins, + roles: supportRoles, + }, + { + path: "/audit", + label: "审计日志", + description: "操作追踪", + icon: BookOpenCheck, + roles: ["SUPER_ADMIN"], + }, + { + path: "/security", + label: "安全中心", + description: "权限与会话", + icon: ShieldCheck, + roles: ["SUPER_ADMIN"], + }, +]; + +export function App() { + return ( + + + + + + + ); +} + +function AppContent() { + const { auth, checking } = useAuth(); + + useEffect(() => { + function handleToast(event: Event) { + const detail = (event as CustomEvent<{ message: string; tone: "success" | "error" }>) + .detail; + if (!detail) return; + if (detail.tone === "error") toast.error(detail.message); + else toast.success(detail.message); + } + window.addEventListener("admin:toast", handleToast); + return () => window.removeEventListener("admin:toast", handleToast); + }, []); + + if (checking) { + return ( +
+ +
+ ); + } + if (auth.status === "anonymous") return ; + + return ; +} + +function AuthenticatedApp({ role }: { role: AdminRole }) { + const available = useMemo( + () => navigation.filter((item) => item.roles.includes(role)), + [role], + ); + + return ( + + + }> + + } /> + } /> + {supportRoles.includes(role) ? ( + <> + } /> + } /> + + ) : null} + {role === "SUPER_ADMIN" ? ( + <> + } /> + } /> + + ) : null} + } /> + + + + + ); +} + +function AppShell({ + navigationItems, + children, +}: { + navigationItems: NavItem[]; + children: ReactNode; +}) { + const { auth, logout } = useAuth(); + const location = useLocation(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [mobile, setMobile] = useState( + () => + typeof window.matchMedia === "function" && + window.matchMedia("(max-width: 1023px)").matches, + ); + const sidebarRef = useRef(null); + const menuButtonRef = useRef(null); + const [dark, setDark] = useState(() => { + const saved = localStorage.getItem("osg-admin-theme"); + return saved + ? saved === "dark" + : typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches; + }); + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + localStorage.setItem("osg-admin-theme", dark ? "dark" : "light"); + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute("content", dark ? "#0a0c10" : "#f5f7fb"); + }, [dark]); + + useEffect(() => { + setSidebarOpen(false); + const active = navigation.find((item) => item.path === location.pathname); + document.title = `${active?.label ?? "管理中心"} · OSG`; + const main = document.querySelector("#main-content"); + const focusHeading = () => { + const heading = main?.querySelector("h1"); + if (!heading) return false; + heading.tabIndex = -1; + heading.focus({ preventScroll: true }); + return true; + }; + if (focusHeading()) return; + const observer = new MutationObserver(() => { + if (focusHeading()) observer.disconnect(); + }); + if (main) observer.observe(main, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [location.pathname]); + + useEffect(() => { + if (!sidebarOpen) return; + function close(event: KeyboardEvent) { + if (event.key === "Escape") { + setSidebarOpen(false); + menuButtonRef.current?.focus(); + } + } + window.addEventListener("keydown", close); + return () => window.removeEventListener("keydown", close); + }, [sidebarOpen]); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const query = window.matchMedia("(max-width: 1023px)"); + const update = () => setMobile(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + useEffect(() => { + if (!sidebarOpen || !mobile) return; + requestAnimationFrame(() => { + sidebarRef.current + ?.querySelector('a[aria-current="page"], a[href]') + ?.focus(); + }); + }, [mobile, sidebarOpen]); + + if (auth.status !== "authenticated") return null; + + return ( +
+ + 跳到主要内容 + + + + +
+
+ +
+ OSG 管理中心 + + + {navigation.find((item) => item.path === location.pathname)?.label ?? "运营总览"} + +
+
+ + +
+
+ +
+
+ {children} +
+
+
+ + {sidebarOpen ? ( +
+ ); +} + +function roleLabel(role: AdminRole): string { + return { + SUPER_ADMIN: "超级管理员", + SUPPORT: "支持人员", + ANALYST: "分析员", + }[role]; +} diff --git a/admin-web/src/components/charts.ts b/admin-web/src/components/charts.ts deleted file mode 100644 index f11dd6c..0000000 --- a/admin-web/src/components/charts.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { FunnelStep, TrendPoint } from "../api/types"; -import { escapeHtml, formatNumber } from "../lib/format"; - -export function trendChart(points: TrendPoint[]): string { - if (points.length === 0) return '
暂无趋势数据
'; - - const width = 720; - const height = 240; - const padding = 28; - const values = points.map((point) => point.registrations); - const max = Math.max(...values, 1); - const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0; - const coordinates = points.map((point, index) => ({ - x: points.length === 1 ? width / 2 : padding + index * step, - y: height - padding - (point.registrations / max) * (height - padding * 2), - point, - })); - const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" "); - const midpoint = Math.ceil(max / 2); - - return ` -
- - 新增用户趋势 - 横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。 - - - - ${formatNumber(max)} - ${formatNumber(midpoint)} - 0 - - ${coordinates - .map( - ({ x, y, point }) => ` - - ${escapeHtml(point.date)}:${formatNumber(point.registrations)} 位新增用户 - - `, - ) - .join("")} - -
- - - - - ${points - .map( - (point) => - ``, - ) - .join("")} - -
新增用户与积分消耗趋势完整数据
UTC 日期新增用户消耗积分
${escapeHtml(point.date)}${formatNumber(point.registrations)}${formatNumber(point.creditsUsed)}
- `; -} - -export function funnelChart(steps: FunnelStep[]): string { - if (steps.length === 0) return '
暂无漏斗数据
'; - const max = Math.max(...steps.map((step) => step.count), 1); - - return ` -
- ${steps - .map((step, index) => { - return ` -
-
- ${escapeHtml(step.label)} - ${formatNumber(step.count)} -
- -
- `; - }) - .join("")} -
- `; -} diff --git a/admin-web/src/components/data-table.tsx b/admin-web/src/components/data-table.tsx new file mode 100644 index 0000000..9fc6de4 --- /dev/null +++ b/admin-web/src/components/data-table.tsx @@ -0,0 +1,80 @@ +import { flexRender } from "@tanstack/react-table"; +import type { RowData } from "@tanstack/table-core"; +import { + getCoreRowModel, + type LegacyColumnDef, + useLegacyTable, +} from "@tanstack/react-table/legacy"; +import { type ReactNode } from "react"; +import { cn } from "../lib/utils"; +import { EmptyState } from "./primitives"; + +export type DataColumn = LegacyColumnDef; + +export function DataTable({ + data, + columns, + caption, + emptyTitle = "暂无数据", + footer, + className, +}: { + data: T[]; + columns: DataColumn[]; + caption: string; + emptyTitle?: string; + footer?: ReactNode; + className?: string; +}) { + const table = useLegacyTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (data.length === 0) return ; + + return ( +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{caption}
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ {footer} +
+ ); +} diff --git a/admin-web/src/components/error-boundary.tsx b/admin-web/src/components/error-boundary.tsx new file mode 100644 index 0000000..d4fae7c --- /dev/null +++ b/admin-web/src/components/error-boundary.tsx @@ -0,0 +1,40 @@ +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { Button, Card } from "./primitives"; + +interface State { + failed: boolean; +} + +export class ErrorBoundary extends Component<{ children: ReactNode }, State> { + state: State = { failed: false }; + + static getDerivedStateFromError(): State { + return { failed: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo): void { + // 管理端禁止记录可能包含用户或运营数据的渲染上下文。 + } + + render() { + if (!this.state.failed) return this.props.children; + return ( + +
+ + + +

页面暂时无法显示

+

+ 页面渲染时出现异常。请重新加载,未提交的操作不会自动执行。 +

+ +
+
+ ); + } +} diff --git a/admin-web/src/components/primitives.tsx b/admin-web/src/components/primitives.tsx new file mode 100644 index 0000000..9d8add4 --- /dev/null +++ b/admin-web/src/components/primitives.tsx @@ -0,0 +1,308 @@ +import { Dialog as BaseDialog } from "@base-ui/react/dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + AlertTriangle, + Inbox, + LoaderCircle, + RefreshCw, + X, + type LucideIcon, +} from "lucide-react"; +import { + forwardRef, + type ButtonHTMLAttributes, + type HTMLAttributes, + type InputHTMLAttributes, + type ReactNode, +} from "react"; +import { cn } from "../lib/utils"; + +const buttonVariants = cva( + "inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]", + { + variants: { + variant: { + primary: + "bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90", + secondary: + "border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted", + ghost: "text-muted hover:bg-surface-muted hover:text-foreground", + danger: + "bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90", + }, + size: { + sm: "min-h-8 rounded-lg px-3 text-xs", + md: "min-h-10 px-4", + lg: "min-h-12 px-5", + icon: "size-10 px-0", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + }, +); + +type ButtonProps = ButtonHTMLAttributes & + VariantProps & { + loading?: boolean; + }; + +export const Button = forwardRef( + ({ className, variant, size, loading, children, disabled, ...props }, ref) => ( + + ), +); +Button.displayName = "Button"; + +export const Input = forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +Input.displayName = "Input"; + +export const Textarea = forwardRef< + HTMLTextAreaElement, + React.TextareaHTMLAttributes +>(({ className, ...props }, ref) => ( + - -
- - -
- - - - - `; -} - -function bindGrantDialog( - container: HTMLElement, - user: UserDetail, - role: AdminRole, -): void { - const dialog = container.querySelector("[data-grant-dialog]"); - const form = container.querySelector("[data-grant-form]"); - const inputs = container.querySelector("[data-grant-inputs]"); - const confirm = container.querySelector("[data-grant-confirm]"); - if (!dialog || !form || !inputs || !confirm) return; - let idempotencyKey = createIdempotencyKey(); - let outcomeUnknown = false; - - container - .querySelector("[data-open-grant]") - ?.addEventListener("click", () => { - idempotencyKey = createIdempotencyKey(); - outcomeUnknown = false; - form.reset(); - inputs.hidden = false; - confirm.hidden = true; - form.querySelectorAll(".form-error").forEach((node) => { - node.textContent = ""; - }); - dialog.showModal(); - form.querySelector('input[name="amount"]')?.focus(); - }); - - dialog.addEventListener("cancel", (event) => { - if (!outcomeUnknown) return; - event.preventDefault(); - const errorNode = form.querySelector("[data-confirm-error]"); - if (errorNode) { - errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试"; - } - }); - - container - .querySelector("[data-review-grant]") - ?.addEventListener("click", () => { - const data = new FormData(form); - const amount = Number(data.get("amount")); - const reason = data.get("reason")?.toString().trim() ?? ""; - const errorNode = form.querySelector("[data-error]"); - if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) { - if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数"; - return; - } - if (reason.length < 4) { - if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因"; - return; - } - if (errorNode) errorNode.textContent = ""; - const amountNode = form.querySelector("[data-confirm-amount]"); - const reasonNode = form.querySelector("[data-confirm-reason]"); - if (amountNode) amountNode.textContent = formatNumber(amount); - if (reasonNode) reasonNode.textContent = `原因:${reason}`; - inputs.hidden = true; - confirm.hidden = false; - form.querySelector("[data-confirm-title]")?.focus(); - }); - - container - .querySelector("[data-edit-grant]") - ?.addEventListener("click", () => { - if (outcomeUnknown) { - const errorNode = form.querySelector("[data-confirm-error]"); - if (errorNode) { - errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试"; - } - return; - } - confirm.hidden = true; - inputs.hidden = false; - form.querySelector('input[name="amount"]')?.focus(); - }); - - const submit = container.querySelector("[data-submit-grant]"); - submit?.addEventListener("click", async () => { - const data = new FormData(form); - const amount = Number(data.get("amount")); - const reason = data.get("reason")?.toString().trim() ?? ""; - const errorNode = form.querySelector("[data-confirm-error]"); - setButtonBusy(submit, true, "赠送中…"); - if (errorNode) errorNode.textContent = ""; - try { - await adminApi.grantCredits({ - userId: user.userId, - amount, - reason, - idempotencyKey, - }); - dialog.close(); - showToast("积分赠送成功,账本已更新", "success"); - await renderUserDetail(container, user.userId, role); - } catch (error) { - const message = - error instanceof ApiError ? error.message : "赠送失败,请重试"; - outcomeUnknown = - !(error instanceof ApiError) || error.status === 0 || error.status >= 500; - if (errorNode) { - errorNode.textContent = outcomeUnknown - ? "赠送结果尚未确认;重试会复用同一请求,不会重复到账" - : message; - } - setButtonBusy(submit, false); - } - }); -} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 9ffeec0..b28b3e1 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -1,50 +1,106 @@ -:root { - font-family: +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", "PingFang SC", system-ui, sans-serif; - color: #1d1d1f; - background: #f5f5f7; - color-scheme: light dark; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --color-page: var(--page); + --color-auth: var(--auth); + --color-sidebar: var(--sidebar); + --color-surface: var(--surface); + --color-surface-elevated: var(--surface-elevated); + --color-surface-muted: var(--surface-muted); + --color-input: var(--input); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-soft: var(--primary-soft); + --color-violet: var(--violet); + --color-violet-soft: var(--violet-soft); + --color-success: var(--success); + --color-success-soft: var(--success-soft); + --color-warning: var(--warning); + --color-warning-soft: var(--warning-soft); + --color-danger: var(--danger); + --color-danger-soft: var(--danger-soft); +} + +:root { + color-scheme: light; + font-family: var(--font-sans); font-synthesis: none; text-rendering: optimizeLegibility; - --primary: #0071e3; - --primary-dark: #0062c4; - --navy: #1d1d1f; - --muted: #6e6e73; - --tertiary: #8e8e93; - --border: rgb(60 60 67 / 18%); + --page: #f5f7fb; + --auth: #f4f7fc; + --sidebar: rgb(248 250 253 / 92%); --surface: #ffffff; - --surface-muted: #f2f2f7; --surface-elevated: #ffffff; - --input-background: #ffffff; - --sidebar-background: rgb(246 246 248 / 88%); - --hover-fill: rgb(0 0 0 / 5%); - --focus-ring: rgb(0 113 227 / 28%); - --danger: #d70015; - --success: #248a3d; - --wa-font-family-body: - -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", - "Helvetica Neue", "PingFang SC", system-ui, sans-serif; - --wa-font-family-heading: var(--wa-font-family-body); - --wa-color-brand-fill-loud: var(--primary); - --wa-color-brand-border-loud: var(--primary); - --wa-color-brand-on-loud: #fff; - --wa-color-focus: var(--primary); - --wa-form-control-border-radius: 12px; - --wa-panel-border-radius: 18px; - --wa-focus-ring-width: 3px; - --wa-focus-ring-offset: 2px; + --surface-muted: #f1f4f8; + --input: #ffffff; + --foreground: #111827; + --muted: #667085; + --border: rgb(15 23 42 / 9%); + --border-strong: rgb(15 23 42 / 17%); + --primary: #1769e0; + --primary-foreground: #ffffff; + --primary-soft: #eaf2ff; + --violet: #7655d8; + --violet-soft: #f1edff; + --success: #138653; + --success-soft: #e8f8ef; + --warning: #a76808; + --warning-soft: #fff4d9; + --danger: #d13a4b; + --danger-soft: #fff0f1; +} + +.dark { + color-scheme: dark; + --page: #0a0c10; + --auth: #090b10; + --sidebar: rgb(15 18 24 / 94%); + --surface: #12161d; + --surface-elevated: #171b23; + --surface-muted: #1b2029; + --input: #10141a; + --foreground: #f5f7fa; + --muted: #9ca6b5; + --border: rgb(255 255 255 / 9%); + --border-strong: rgb(255 255 255 / 16%); + --primary: #5b9cff; + --primary-foreground: #061124; + --primary-soft: rgb(91 156 255 / 14%); + --violet: #a78bfa; + --violet-soft: rgb(167 139 250 / 14%); + --success: #4ed69a; + --success-soft: rgb(78 214 154 / 13%); + --warning: #f5b94c; + --warning-soft: rgb(245 185 76 / 13%); + --danger: #ff6b7a; + --danger-soft: rgb(255 107 122 / 13%); } * { box-sizing: border-box; } +html { + min-width: 320px; + min-height: 100%; + background: var(--page); +} + body { min-width: 320px; min-height: 100vh; margin: 0; - background: #f5f5f7; + background: var(--page); } button, @@ -60,1450 +116,212 @@ select { -webkit-tap-highlight-color: transparent; } -.skip-link { - position: fixed; - z-index: 1000; - top: 10px; - left: 12px; - padding: 10px 14px; - color: #fff; - border-radius: 10px; - background: var(--primary); - text-decoration: none; - transform: translateY(-160%); -} - -.skip-link:focus { - transform: translateY(0); -} - -button:focus-visible, -a:focus-visible, -input:focus-visible, -textarea:focus-visible, -select:focus-visible { - outline: 3px solid var(--focus-ring); - outline-offset: 2px; -} - -[hidden] { - display: none !important; -} - -h1, -h2, -p { - margin-top: 0; -} - -h1 { - margin-bottom: 8px; - font-size: clamp(1.7rem, 3vw, 2.25rem); - letter-spacing: -0.035em; -} - -h2 { - margin-bottom: 4px; - font-size: 1.05rem; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.eyebrow { - margin-bottom: 8px; - color: var(--primary); - font-size: 0.72rem; - font-weight: 800; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.muted, -.page-heading p, -.panel-heading p { - color: var(--muted); -} - -.mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.82rem; -} - -.button { - min-height: 44px; - padding: 0 18px; - border: 1px solid transparent; - border-radius: 10px; +button { cursor: pointer; - font-weight: 700; - transition: - background 150ms ease, - border-color 150ms ease; } -.button:hover:not(:disabled) { - filter: brightness(0.97); +button:disabled { + cursor: not-allowed; } -.button:disabled { - cursor: wait; - opacity: 0.65; -} - -.button--primary { - color: #fff; +::selection { + color: var(--primary-foreground); background: var(--primary); } -.button--primary:hover:not(:disabled) { - background: var(--primary-dark); +*:focus-visible { + outline: none; } -.button--secondary { - color: var(--navy); - border-color: var(--border); - background: var(--surface-elevated); -} - -.button--danger { - color: #fff; - background: var(--danger); -} - -.button--text { - color: var(--muted); - background: transparent; -} - -.button--wide { - width: 100%; -} - -wa-button.button--wide { - display: block; - min-width: 0; - max-width: 100%; -} - -.button--small { - min-height: 34px; - padding: 0 12px; - font-size: 0.82rem; -} - -.icon-button { +.brand-symbol { display: grid; - width: 44px; - height: 44px; - padding: 0; - color: inherit; - border: 0; - border-radius: 10px; - background: transparent; - cursor: pointer; - place-items: center; -} - -.icon-button:hover { - background: var(--hover-fill); -} - -wa-button.icon-button::part(button), -wa-button.logout-button::part(button) { - min-height: 44px; - padding: 0; -} - -.icon-button svg, -.logout-button svg { - width: 21px; - height: 21px; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.8; -} - -.brand-mark { - display: grid; - width: 58px; - height: 58px; - color: #fff; - border-radius: 17px; - background: var(--primary); - font-size: 1rem; - font-weight: 900; + color: white; + background: + radial-gradient(circle at 25% 15%, rgb(255 255 255 / 44%), transparent 24%), + linear-gradient(145deg, #2e82ff 0%, #1769e0 46%, #7655d8 100%); + box-shadow: + 0 12px 30px -14px rgb(23 105 224 / 75%), + inset 0 1px 0 rgb(255 255 255 / 25%); + font-weight: 850; letter-spacing: -0.06em; place-items: center; } -.brand-mark--small { - width: 42px; - height: 42px; - border-radius: 12px; - box-shadow: none; - font-size: 0.8rem; -} - -.auth-shell { - display: grid; - min-height: 100vh; - padding: 24px; - grid-template-columns: minmax(280px, 0.9fr) minmax(380px, 1.1fr); - align-items: center; - gap: clamp(30px, 9vw, 140px); -} - -.auth-brand { - max-width: 460px; - margin-left: auto; -} - -.auth-brand p { - margin-top: 24px; - color: var(--muted); - font-size: 1.08rem; -} - -.auth-card { - width: min(100%, 460px); - min-width: 0; - padding: clamp(30px, 5vw, 52px); - border: 1px solid var(--border); - border-radius: 20px; - background: var(--surface-elevated); -} - -.form-stack { - display: grid; - min-width: 0; - margin-top: 30px; - grid-template-columns: minmax(0, 1fr); - gap: 18px; -} - -.form-stack > * { - min-width: 0; -} - -wa-input { - display: block; - width: 100%; - min-width: 0; - max-width: 100%; -} - -wa-input::part(form-control-label) { - color: var(--navy); - font-size: 0.88rem; - font-weight: 700; -} - -wa-input::part(input) { - min-height: 44px; -} - -label { - display: grid; - color: var(--navy); - font-size: 0.88rem; - font-weight: 700; - gap: 7px; -} - -input, -textarea, -select { - width: 100%; - color: var(--navy); - border: 1px solid var(--border); - border-radius: 10px; - background: var(--input-background); - transition: - border-color 150ms ease, - box-shadow 150ms ease; -} - -input, -select { - height: 44px; - padding: 0 13px; -} - -textarea { - min-height: 92px; - padding: 12px 13px; - resize: vertical; -} - -input:focus, -textarea:focus, -select:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--focus-ring); - outline: none; -} - -input:disabled { - color: var(--muted); - background: var(--surface-muted); -} - -.totp-input { - font-size: 1.45rem; - letter-spacing: 0.4em; - text-align: center; -} - -wa-input.totp-input { - font-size: inherit; - letter-spacing: normal; - text-align: initial; -} - -wa-input.totp-input::part(input) { - font-size: 1.45rem; - letter-spacing: 0.4em; - text-align: center; -} - -.form-error { - min-height: 20px; - margin: -4px 0 0; - color: var(--danger); - font-size: 0.84rem; -} - -.app-shell { - min-height: 100vh; -} - -.sidebar { - position: fixed; - z-index: 20; - top: 0; - bottom: 0; - left: 0; - display: flex; - width: 250px; - padding: 24px 18px; - color: var(--navy); - border-right: 1px solid var(--border); - background: var(--sidebar-background); - backdrop-filter: saturate(160%) blur(24px); - flex-direction: column; -} - -.sidebar-brand { - display: flex; - padding: 0 8px 28px; - align-items: center; - gap: 12px; -} - -.sidebar-brand strong, -.sidebar-brand span { - display: block; -} - -.sidebar-brand span { - margin-top: 3px; - color: var(--muted); - font-size: 0.72rem; - letter-spacing: 0.12em; -} - -.nav-list { - display: grid; - gap: 7px; -} - -.nav-list a { - display: flex; - min-height: 46px; - padding: 0 13px; - color: var(--muted); - border-radius: 11px; - font-size: 0.91rem; - font-weight: 650; - text-decoration: none; - align-items: center; - gap: 12px; - transition: - color 150ms ease, - background 150ms ease; -} - -.nav-list a:hover, -.nav-list a:focus-visible { - color: var(--navy); - background: var(--hover-fill); -} - -.nav-list a.active { - color: var(--primary); - background: color-mix(in srgb, var(--primary) 12%, var(--surface-muted)); - font-weight: 700; -} - -.nav-list a.active:hover { - background: color-mix(in srgb, var(--primary) 16%, var(--surface-muted)); -} - -.nav-icon { - width: 22px; - height: 22px; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.65; -} - -.sidebar-footer { - display: flex; - padding: 18px 4px 0; - margin-top: auto; - border-top: 1px solid var(--border); - align-items: center; - justify-content: space-between; -} - -.operator { - display: flex; - align-items: center; - gap: 10px; -} - -.operator-avatar { - display: grid; - width: 36px; - height: 36px; - color: var(--primary); - border: 1px solid var(--border); - border-radius: 50%; - background: var(--surface-muted); - place-items: center; -} - -.operator strong, -.operator span { - display: block; - max-width: 110px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.operator strong { - font-size: 0.82rem; -} - -.operator span { - margin-top: 2px; - color: var(--muted); - font-size: 0.75rem; -} - -.logout-button { - display: inline-flex; - min-width: 44px; - min-height: 44px; - padding: 0 10px; - color: var(--muted); - border: 0; - border-radius: 10px; - background: transparent; - cursor: pointer; - align-items: center; - gap: 6px; -} - -.logout-button:hover { - color: var(--navy); - background: var(--hover-fill); -} - -wa-button.logout-button::part(button) { - padding: 0 10px; -} - -.logout-button span { - font-size: 0.76rem; - font-weight: 650; -} - -.main-column { - min-height: 100vh; - margin-left: 250px; -} - -.mobile-header, -.sidebar-scrim { - display: none; -} - -.page-content { - width: min(100%, 1420px); - padding: clamp(28px, 4vw, 54px); - margin: 0 auto; -} - -.page-stage { - display: grid; - align-content: start; - gap: 20px; - animation: pageIn 180ms ease-out; -} - -@keyframes pageIn { - from { - opacity: 0; - transform: translateY(4px); - } -} - -.page-heading { - display: flex; - margin-bottom: 8px; - align-items: end; - justify-content: space-between; - gap: 24px; -} - -.page-heading p { - margin-bottom: 0; -} - -.page-heading--detail { - align-items: center; -} - -.back-link { - padding: 0; - margin-bottom: 20px; - color: var(--muted); - border: 0; - background: none; - cursor: pointer; -} - -.back-link:hover { - color: var(--primary); -} - -.select-field select { - min-width: 122px; -} - -.segmented-control { - display: inline-flex; - padding: 3px; - margin: 0; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--surface-muted); - gap: 2px; -} - -.segmented-control label { - display: block; - color: var(--muted); - font-size: 0.8rem; - font-weight: 650; -} - -.segmented-control input { +.auth-orb { position: absolute; - width: 1px; - height: 1px; - opacity: 0; + border-radius: 999px; + filter: blur(2px); + pointer-events: none; } -.segmented-control span { - display: grid; - min-width: 58px; - min-height: 34px; - padding: 0 10px; - border-radius: 7px; - cursor: pointer; - place-items: center; +.auth-orb--one { + top: -24rem; + left: -18rem; + width: 48rem; + height: 48rem; + background: radial-gradient(circle, rgb(91 156 255 / 18%), transparent 68%); } -.segmented-control input:checked + span { - color: var(--navy); - background: var(--surface-elevated); - box-shadow: 0 1px 2px rgb(0 0 0 / 8%); +.auth-orb--two { + right: -20rem; + bottom: -24rem; + width: 52rem; + height: 52rem; + background: radial-gradient(circle, rgb(167 139 250 / 16%), transparent 68%); } -.segmented-control input:focus-visible + span { - outline: 3px solid var(--focus-ring); - outline-offset: 1px; +.stat-glow { + position: absolute; + top: -5rem; + right: -5rem; + width: 10rem; + height: 10rem; + border-radius: 999px; + opacity: 0.13; + filter: blur(8px); + transition: + opacity 250ms ease, + transform 250ms ease; } -.metric-grid { - display: grid; - grid-template-columns: repeat(4, minmax(150px, 1fr)); - gap: 16px; +.group:hover .stat-glow { + opacity: 0.2; + transform: scale(1.08); } -.metric-grid--three { - grid-template-columns: repeat(3, minmax(150px, 1fr)); -} - -.metric-card, -.panel { - border: 1px solid var(--border); - background: var(--surface); -} - -.metric-card { - padding: 22px; - border-radius: 15px; -} - -.metric-card p, -.metric-card span { - color: var(--muted); -} - -.metric-card p { - margin-bottom: 18px; - font-size: 0.83rem; - font-weight: 700; -} - -.metric-card strong { - display: block; - margin-bottom: 8px; - font-size: clamp(1.65rem, 3vw, 2.1rem); - letter-spacing: -0.04em; -} - -.metric-card span { - font-size: 0.72rem; -} - -.panel { - padding: clamp(18px, 3vw, 26px); - border-radius: 16px; -} - -.panel-heading { - display: flex; - margin-bottom: 22px; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.panel-heading p { - margin-bottom: 0; - font-size: 0.79rem; -} - -.security-notice { - display: flex; - padding: 16px 18px; - color: var(--navy); - border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border)); - border-radius: 14px; - background: color-mix(in srgb, var(--primary) 8%, var(--surface)); - align-items: baseline; - gap: 12px; -} - -.security-notice span { - color: var(--muted); - font-size: 0.86rem; -} - -.row-actions { - display: flex; - min-width: 310px; - align-items: center; - flex-wrap: wrap; - gap: 6px; -} - -.legend { - color: var(--muted); - font-size: 0.74rem; -} - -.legend i { - display: inline-block; - width: 8px; - height: 8px; - margin-right: 6px; - border-radius: 50%; +.stat-glow--primary { background: var(--primary); } -.chart-scroll { - overflow-x: auto; +.stat-glow--success { + background: var(--success); } -.trend-chart { - display: block; - width: 100%; - min-width: 570px; - height: auto; - overflow: visible; +.stat-glow--violet { + background: var(--violet); } -.chart-axis { +.stat-glow--warning { + background: var(--warning); +} + +.stat-icon { + display: grid; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.75rem; + place-items: center; +} + +.stat-icon--primary { + color: var(--primary); + background: var(--primary-soft); +} + +.stat-icon--success { + color: var(--success); + background: var(--success-soft); +} + +.stat-icon--violet { + color: var(--violet); + background: var(--violet-soft); +} + +.stat-icon--warning { + color: var(--warning); + background: var(--warning-soft); +} + +.osg-toast { + border-color: var(--border) !important; + border-radius: 1rem !important; + background: var(--surface-elevated) !important; + color: var(--foreground) !important; + box-shadow: 0 18px 50px rgb(15 23 42 / 18%) !important; +} + +@keyframes page-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +@keyframes card-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } +} + +.animate-page-in { + animation: page-in 260ms ease-out both; +} + +.animate-card-in { + animation: card-in 320ms ease-out both; +} + +.chart-grid-line { stroke: var(--border); stroke-width: 1; + stroke-dasharray: 3 5; } .chart-label { fill: var(--muted); - font-size: 10px; + font-size: 11px; } .chart-line { fill: none; - stroke: var(--primary); stroke-linecap: round; stroke-linejoin: round; +} + +.chart-line--primary { + stroke: var(--primary); stroke-width: 3; } +.chart-line--violet { + stroke: var(--violet); + stroke-width: 2.5; +} + .chart-dot { - fill: #fff; + fill: var(--surface); +} + +.chart-dot--primary { stroke: var(--primary); stroke-width: 3; } -.two-column, -.detail-grid { - display: grid; - grid-template-columns: minmax(300px, 0.8fr) minmax(440px, 1.2fr); - gap: 20px; -} - -.detail-panel--wide { - grid-column: 1 / -1; -} - -.funnel { - display: grid; - padding: 8px 0; - gap: 22px; -} - -.funnel-label { - display: flex; - margin-bottom: 9px; - color: var(--muted); - font-size: 0.82rem; - justify-content: space-between; -} - -.funnel-label strong { - color: var(--navy); +.chart-dot--violet { + stroke: var(--violet); + stroke-width: 2.5; } .funnel-progress { - display: block; - width: 100%; - height: 13px; - overflow: hidden; border: 0; - border-radius: 99px; appearance: none; } .funnel-progress::-webkit-progress-bar { - border-radius: inherit; + border-radius: 999px; background: var(--surface-muted); } .funnel-progress::-webkit-progress-value { - border-radius: inherit; - background: var(--primary); + border-radius: 999px; + background: linear-gradient(90deg, var(--primary), var(--violet)); } .funnel-progress::-moz-progress-bar { - border-radius: inherit; - background: var(--primary); -} - -.table-wrap { - width: 100%; - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; - white-space: nowrap; -} - -th, -td { - padding: 13px 12px; - border-bottom: 1px solid var(--border); - font-size: 0.82rem; - text-align: left; -} - -th { - color: var(--muted); - font-size: 0.76rem; - letter-spacing: 0.03em; - text-transform: uppercase; -} - -tbody tr:last-child td { - border-bottom: 0; -} - -tbody tr:hover { - background: var(--hover-fill); -} - -td strong { - display: block; - margin-bottom: 5px; -} - -.subtle { - margin-top: 3px; - color: var(--muted); - font-size: 0.76rem; -} - -.rank { - display: grid; - width: 25px; - height: 25px; - border-radius: 8px; - background: var(--surface-muted); - font-weight: 800; - place-items: center; -} - -.rank--1 { - color: #8a5b00; - background: #fff3bf; -} - -.rank--2 { - color: #475467; - background: #eaecf0; -} - -.rank--3 { - color: #9c4f21; - background: #ffe8cc; -} - -.badge { - display: inline-flex; - padding: 4px 8px; - color: var(--muted); - border-radius: 99px; - background: var(--surface-muted); - font-size: 0.75rem; - font-weight: 750; -} - -.badge--active, -.badge--success { - color: #067647; - background: #ecfdf3; -} - -.badge--suspended, -.badge--rejected { - color: #b42318; - background: #fef3f2; -} - -.badge--closed { - color: #475467; - background: #eaecf0; -} - -.usage-badge--polish { - color: #6941c6; - background: #f4f3ff; -} - -.usage-badge--asr { - color: #026aa2; - background: #f0f9ff; -} - -.usage-badge--ai { - color: #175cd3; - background: #eff8ff; -} - -.usage-badge--agent { - color: #067647; - background: #ecfdf3; -} - -.usage-badge--hotword { - color: #b54708; - background: #fffaeb; -} - -.search-form { - display: flex; - max-width: 760px; - margin-bottom: 24px; - align-items: center; - gap: 10px; -} - -.search-box { - position: relative; - display: block; - min-width: 0; - flex: 1; -} - -.search-box > span[aria-hidden="true"] { - position: absolute; - top: 50%; - left: 14px; - color: var(--muted); - font-size: 1.2rem; - transform: translateY(-50%); -} - -.search-box input { - display: block; - padding-left: 42px; - margin: 0; -} - -.search-form > wa-button { - flex: 0 0 auto; -} - -.empty-state { - display: grid; - min-height: 150px; - color: var(--muted); - text-align: center; - place-items: center; -} - -.empty-state p { - margin: 0; -} - -.state-card { - display: grid; - min-height: 320px; - color: var(--muted); - text-align: center; - align-content: center; - justify-items: center; -} - -.state-card--error { - max-width: 480px; - padding: 36px; - margin: 80px auto; - border: 1px solid #fee4e2; - border-radius: 16px; - background: #fff; -} - -wa-callout.state-card::part(message) { - display: grid; - text-align: center; - align-content: center; - justify-items: center; -} - -.state-icon, -.confirm-icon { - display: grid; - width: 42px; - height: 42px; - margin-bottom: 15px; - color: var(--danger); - border-radius: 50%; - background: #fee4e2; - font-weight: 900; - place-items: center; -} - -.spinner { - width: 28px; - height: 28px; - margin-bottom: 12px; - border: 3px solid #dbe4ff; - border-top-color: var(--primary); - border-radius: 50%; - animation: spin 700ms linear infinite; -} - -.state-spinner { - margin-bottom: 12px; - font-size: 28px; - --indicator-color: var(--primary); - --track-color: color-mix(in srgb, var(--primary) 18%, transparent); -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.profile-card { - height: fit-content; -} - -.profile-header { - display: flex; - padding-bottom: 22px; - margin-bottom: 12px; - border-bottom: 1px solid var(--border); - align-items: center; - gap: 13px; -} - -.profile-header strong, -.profile-header .badge { - display: block; - width: fit-content; -} - -.profile-header .badge { - margin-top: 6px; -} - -.avatar { - display: grid; - width: 48px; - height: 48px; - color: var(--primary-dark); - border-radius: 14px; - background: #edf2ff; - font-size: 1.15rem; - font-weight: 850; - place-items: center; -} - -.detail-list { - margin: 0; -} - -.detail-list div { - display: flex; - padding: 12px 0; - border-bottom: 1px solid #f0f2f5; - justify-content: space-between; - gap: 20px; -} - -.detail-list div:last-child { - border: 0; -} - -.detail-list dt { - color: var(--muted); - font-size: 0.78rem; -} - -.detail-list dd { - margin: 0; - font-size: 0.8rem; - text-align: right; -} - -.detail-list .credit-value { - color: var(--primary-dark); - font-size: 1.3rem; - font-weight: 850; -} - -.positive { - color: var(--success); - font-weight: 750; -} - -.negative { - color: var(--danger); - font-weight: 750; -} - -.result-summary { - padding: 10px 12px 18px; - color: var(--muted); - font-size: 0.8rem; -} - -.pagination-actions { - display: flex; - padding: 18px 22px 22px; - border-top: 1px solid var(--border); - justify-content: center; -} - -.dialog { - width: min(92vw, 520px); - padding: 0; - border: 0; - border-radius: 20px; - box-shadow: 0 30px 90px rgb(16 24 40 / 28%); -} - -.dialog--wide { - width: min(92vw, 640px); -} - -.dialog::backdrop { - background: rgb(16 24 40 / 58%); - backdrop-filter: blur(3px); -} - -.dialog-card { - display: grid; - padding: clamp(24px, 5vw, 36px); - gap: 18px; -} - -.provisioning-card { - max-width: 600px; -} - -.secret-panel { - display: grid; - padding: 16px; - border: 1px solid var(--border); - border-radius: 12px; - background: var(--surface-muted); - gap: 10px; -} - -.secret-panel > span { - color: var(--muted); - font-size: 0.78rem; - font-weight: 700; -} - -.secret-panel code { - display: block; - padding: 12px; - overflow-wrap: anywhere; - font-size: 0.92rem; - letter-spacing: 0.08em; - user-select: all; -} - -.dialog-card label { - margin-top: 15px; -} - -.dialog-actions { - display: flex; - margin-top: 26px; - justify-content: flex-end; - gap: 10px; -} - -.dialog-actions--spread { - justify-content: space-between; -} - -.confirmation-check { - display: flex; - min-height: 44px; - color: var(--navy); - align-items: center; - gap: 10px; -} - -.confirmation-check input { - width: 20px; - height: 20px; - flex: 0 0 auto; -} - -.confirm-reason { - padding: 12px; - color: var(--muted); - border-radius: 10px; - background: #f7f8fa; - font-size: 0.84rem; -} - -.toast { - position: fixed; - z-index: 100; - right: 22px; - bottom: 22px; - max-width: min(390px, calc(100vw - 44px)); - box-shadow: 0 15px 40px rgb(16 24 40 / 25%); - opacity: 0; - transform: translateY(10px); - transition: - opacity 180ms ease, - transform 180ms ease; -} - -.toast--visible { - opacity: 1; - transform: translateY(0); -} - -code { - padding: 3px 6px; - color: #364fc7; - border-radius: 5px; - background: #edf2ff; - font-size: 0.74rem; -} - -@media (prefers-color-scheme: dark) { - :root { - color: #f5f5f7; - background: #000; - --primary: #0a84ff; - --primary-dark: #409cff; - --navy: #f5f5f7; - --muted: #a1a1a6; - --tertiary: #8e8e93; - --border: rgb(235 235 245 / 20%); - --surface: #1c1c1e; - --surface-muted: #2c2c2e; - --surface-elevated: #242426; - --input-background: #1c1c1e; - --sidebar-background: rgb(28 28 30 / 88%); - --hover-fill: rgb(255 255 255 / 8%); - --focus-ring: rgb(10 132 255 / 38%); - --danger: #ff453a; - --success: #30d158; - } - - body { - background: #000; - } - - .chart-axis { - stroke: var(--border); - } - - .chart-dot { - fill: var(--surface); - } - - .badge--active, - .badge--success { - color: #30d158; - background: rgb(48 209 88 / 14%); - } - - .badge--suspended, - .badge--rejected { - color: #ff6961; - background: rgb(255 69 58 / 14%); - } - - .badge--closed, - .rank--2 { - color: var(--muted); - background: var(--surface-muted); - } - - .usage-badge--polish { - color: #bf5af2; - background: rgb(191 90 242 / 14%); - } - - .usage-badge--asr, - .usage-badge--ai { - color: #64d2ff; - background: rgb(100 210 255 / 14%); - } - - .usage-badge--agent { - color: #30d158; - background: rgb(48 209 88 / 14%); - } - - .usage-badge--hotword { - color: #ffd60a; - background: rgb(255 214 10 / 14%); - } - - .rank--1 { - color: #ffd60a; - background: rgb(255 214 10 / 14%); - } - - .rank--3 { - color: #ff9f0a; - background: rgb(255 159 10 / 14%); - } - - .state-card--error, - .dialog, - .auth-card { - background: var(--surface-elevated); - } - - .confirm-reason, - code { - color: var(--primary-dark); - background: var(--surface-muted); - } -} - -@media (max-width: 980px) { - .metric-grid { - grid-template-columns: repeat(2, 1fr); - } - - .two-column, - .detail-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 1023px) { - .auth-shell { - display: flex; - padding: 18px; - align-items: center; - justify-content: center; - } - - .auth-brand { - display: none; - } - - .sidebar { - width: min(82vw, 280px); - transform: translateX(-105%); - transition: transform 200ms ease; - } - - .sidebar--open { - transform: translateX(0); - } - - .sidebar--open ~ .main-column + .sidebar-scrim { - display: block; - } - - .sidebar-scrim { - position: fixed; - z-index: 15; - inset: 0; - width: 100%; - height: 100%; - padding: 0; - border: 0; - background: rgb(16 24 40 / 45%); - } - - .main-column { - margin-left: 0; - } - - .mobile-header { - position: sticky; - z-index: 10; - top: 0; - display: flex; - height: 58px; - padding: 0 16px; - border-bottom: 1px solid var(--border); - background: var(--sidebar-background); - backdrop-filter: blur(12px); - align-items: center; - gap: 10px; - } - - .mobile-header .icon-button:hover { - background: var(--hover-fill); - } - - .page-content { - padding: 26px 16px 46px; - } - - .page-heading { - align-items: start; - flex-direction: column; - } - - .page-heading--detail { - align-items: stretch; - } - - .select-field, - .select-field select { - width: 100%; - } - - .segmented-control { - width: 100%; - } - - .segmented-control label { - flex: 1; - } - - .segmented-control span { - width: 100%; - } - - .search-form { - align-items: stretch; - flex-direction: column; - } - - .security-notice { - align-items: start; - flex-direction: column; - gap: 4px; - } - - .row-actions { - min-width: 240px; - } -} - -@media (max-width: 480px) { - .auth-card { - padding: 28px 22px; - } - - .metric-grid { - grid-template-columns: 1fr; - } - - .dialog-actions { - align-items: stretch; - flex-direction: column-reverse; - } - - .dialog-actions .button { - width: 100%; - } + border-radius: 999px; + background: linear-gradient(90deg, var(--primary), var(--violet)); } @media (prefers-reduced-motion: reduce) { @@ -1519,26 +337,21 @@ code { @media (prefers-contrast: more) { :root { - --border: rgb(60 60 67 / 52%); - --muted: #4c4c50; + --border: rgb(15 23 42 / 38%); + --border-strong: rgb(15 23 42 / 58%); + --muted: #414b5a; + } + + .dark { + --border: rgb(255 255 255 / 40%); + --border-strong: rgb(255 255 255 / 65%); + --muted: #c0c7d1; } } @media (forced-colors: active) { - .button, - .icon-button, - .logout-button, - input, - select, - textarea, - .panel, - .metric-card, - .segmented-control { + .brand-symbol, + .stat-icon { border: 1px solid ButtonText; } - - .nav-list a.active { - outline: 2px solid Highlight; - outline-offset: -2px; - } } diff --git a/admin-web/src/test/charts.test.ts b/admin-web/src/test/charts.test.ts deleted file mode 100644 index aa918ab..0000000 --- a/admin-web/src/test/charts.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { funnelChart, trendChart } from "../components/charts"; - -describe("accessible charts", () => { - it("provides a titled SVG and complete data table for trends", () => { - const html = trendChart([ - { date: "2026-08-16", registrations: 12, creditsUsed: 345 }, - ]); - - expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"'); - expect(html).toContain("新增用户与积分消耗趋势完整数据"); - expect(html).toContain("345"); - }); - - it("renders the funnel without CSP-sensitive inline styles", () => { - const html = funnelChart([ - { label: "访问", count: 20 }, - { label: "注册", count: 10 }, - ]); - - expect(html).toContain(" { - vi.restoreAllMocks(); - document.body.replaceChildren(); -}); - -describe("用户页", () => { - it("进入页面自动显示积分、注册时间并按游标加载更多用户", async () => { - const secondUser: UserSummary = { - ...userSummary, - userId: "22222222-2222-4222-8222-222222222222", - displayName: "第二位用户", - creditBalance: 80, - consumedCredits: 40, - }; - vi.spyOn(adminApi, "users") - .mockResolvedValueOnce({ items: [userSummary], nextCursor: "user-next" }) - .mockResolvedValueOnce({ items: [secondUser] }); - const container = document.createElement("main"); - document.body.append(container); - - renderUsers(container, "SUPPORT"); - await vi.waitFor(() => { - expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(1); - }); - expect(container.textContent).toContain("累计使用积分"); - expect(container.textContent).toContain("当前积分"); - expect(container.textContent).toContain("18"); - expect(container.textContent).toContain("120"); - - container.querySelector("[data-user-more]")?.click(); - await vi.waitFor(() => { - expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next"); - expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(2); - }); - expect(container.querySelector("[data-user-more]")).toBeNull(); - }); - - it("支持人员可查看详情但不显示人工赠送", async () => { - mockUserRequests(); - vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] }); - const container = document.createElement("main"); - document.body.append(container); - - await openUserDetail(container, "SUPPORT"); - - expect(container.querySelector("[data-open-grant]")).toBeNull(); - expect(container.querySelector("[data-grant-dialog]")).toBeNull(); - }); - - it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => { - mockUserRequests(); - vi.spyOn(adminApi, "ledger") - .mockResolvedValueOnce({ - items: [ - { - entryId: "ledger-1", - userId, - type: "grant", - amount: 100, - balanceAfter: 100, - reasonCode: "SIGNUP_TRIAL", - createdAt: "2026-08-01T08:00:00Z", - }, - ], - nextCursor: "ledger-next", - }) - .mockResolvedValueOnce({ - items: [ - { - entryId: "ledger-2", - userId, - type: "settle", - amount: -18, - balanceAfter: 82, - reasonCode: "USAGE_SETTLE", - usageType: "asr", - createdAt: "2026-08-02T08:00:00Z", - }, - ], - }); - const container = document.createElement("main"); - document.body.append(container); - - await openUserDetail(container, "SUPER_ADMIN"); - - expect(container.querySelector("[data-open-grant]")).not.toBeNull(); - expect(container.textContent).toContain("OSG-TEST"); - expect(container.textContent).toContain("3 次请求"); - expect(container.textContent).toContain("18 积分"); - expect(container.textContent).toContain("2 / 4 已奖励"); - expect(container.textContent).toContain( - "22222222-2222-4222-8222-222222222222", - ); - - container.querySelector("[data-ledger-more]")?.click(); - await vi.waitFor(() => { - expect(adminApi.ledger).toHaveBeenNthCalledWith( - 2, - userId, - "ledger-next", - ); - expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2); - }); - expect(container.textContent).toContain("ASR"); - expect(container.querySelector("[data-ledger-more]")).toBeNull(); - expect(container.querySelector("[data-ledger-status]")?.textContent).toContain( - "全部记录已加载", - ); - }); -}); - -describe("积分流水页", () => { - it("进入页面自动显示最新流水并支持继续加载", async () => { - vi.spyOn(adminApi, "latestLedger") - .mockResolvedValueOnce({ - items: [ - { - entryId: "latest-ledger-1", - userId, - type: "settle", - amount: -18, - balanceAfter: 102, - reasonCode: "USAGE_SETTLE", - usageType: "hotword", - createdAt: "2026-08-19T09:00:00Z", - }, - ], - nextCursor: "latest-next", - }) - .mockResolvedValueOnce({ - items: [ - { - entryId: "latest-ledger-2", - userId: "22222222-2222-4222-8222-222222222222", - type: "grant", - amount: 100, - balanceAfter: 100, - reasonCode: "SIGNUP_TRIAL", - createdAt: "2026-08-19T08:00:00Z", - }, - ], - }); - const container = document.createElement("main"); - document.body.append(container); - - renderCredits(container); - await vi.waitFor(() => { - expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(1); - }); - expect(container.textContent).toContain(userId); - expect(container.textContent).toContain("USAGE_SETTLE"); - expect(container.textContent).toContain("热词"); - - container.querySelector("[data-ledger-more]")?.click(); - await vi.waitFor(() => { - expect(adminApi.latestLedger).toHaveBeenNthCalledWith(2, "latest-next"); - expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2); - }); - expect(container.querySelector("[data-ledger-more]")).toBeNull(); - }); -}); - -describe("安全中心", () => { - it("按后端 nextCursor 加载更多管理员", async () => { - const firstOperator = operator("operator-1", "owner"); - const secondOperator = operator("operator-2", "support"); - vi.spyOn(adminApi, "operators") - .mockResolvedValueOnce({ - items: [firstOperator], - nextCursor: "operator-next", - }) - .mockResolvedValueOnce({ items: [secondOperator] }); - vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary()); - const container = document.createElement("main"); - document.body.append(container); - - await renderSecurity(container, "owner"); - container.querySelector("[data-operator-more]")?.click(); - - await vi.waitFor(() => { - expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next"); - expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2); - }); - expect(container.querySelector("[data-operator-count]")?.textContent).toBe( - "已加载 2 个账户", - ); - expect(container.querySelector("[data-operator-more]")).toBeNull(); - expect( - container.querySelector("[data-operator-status]")?.textContent, - ).toContain("全部账户已加载"); - }); -}); - -function mockUserRequests(): void { - vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] }); - vi.spyOn(adminApi, "user").mockResolvedValue(userDetail); -} - -async function openUserDetail( - container: HTMLElement, - role: "SUPER_ADMIN" | "SUPPORT", -): Promise { - renderUsers(container, role); - const input = container.querySelector('input[name="query"]'); - const form = container.querySelector("[data-search-form]"); - if (!input || !form) throw new Error("用户查询表单未渲染"); - input.value = userId; - form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - await vi.waitFor(() => { - expect(container.querySelector("[data-user-id]")).not.toBeNull(); - }); - container.querySelector("[data-user-id]")?.click(); - await vi.waitFor(() => { - expect(container.querySelector("h1")?.textContent).toBe("测试用户"); - }); -} - -function operator(operatorId: string, username: string): AdminOperator { - return { - operatorId, - username, - role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT", - enabled: true, - failedLoginCount: 0, - createdAt: "2026-08-01T08:00:00Z", - updatedAt: "2026-08-01T08:00:00Z", - }; -} - -function summary(): AdminSecuritySummary { - return { - enabledOperators: 2, - lockedOperators: 0, - activeSessions: 1, - }; -} diff --git a/admin-web/src/test/pages.test.tsx b/admin-web/src/test/pages.test.tsx new file mode 100644 index 0000000..0f43667 --- /dev/null +++ b/admin-web/src/test/pages.test.tsx @@ -0,0 +1,127 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "../app"; +import { adminApi } from "../api/client"; +import type { + AdminOperator, + AdminSecuritySummary, + LedgerEntry, + UserSummary, +} from "../api/types"; + +const userId = "11111111-1111-4111-8111-111111111111"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.location.hash = ""; +}); + +describe("React 管理页面", () => { + it("用户列表保留游标分页与角色权限", async () => { + mockSession("SUPPORT"); + const first = user("测试用户", userId); + const second = user("第二位用户", "22222222-2222-4222-8222-222222222222"); + vi.spyOn(adminApi, "users") + .mockResolvedValueOnce({ items: [first], nextCursor: "user-next" }) + .mockResolvedValueOnce({ items: [second] }); + window.location.hash = "#/users"; + + render(); + + expect(await screen.findByRole("heading", { name: "用户列表" })).toBeTruthy(); + expect(await screen.findByText("测试用户")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "加载更多用户" })); + + await waitFor(() => { + expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next"); + }); + expect(await screen.findByText("第二位用户")).toBeTruthy(); + expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull(); + }); + + it("积分页自动显示最新不可变流水", async () => { + mockSession("SUPPORT"); + const entry: LedgerEntry = { + entryId: "ledger-1", + userId, + type: "settle", + amount: -18, + balanceAfter: 102, + reasonCode: "USAGE_SETTLE", + usageType: "hotword", + createdAt: "2026-08-19T09:00:00Z", + }; + vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] }); + window.location.hash = "#/credits"; + + render(); + + expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy(); + expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy(); + expect(screen.getByText("热词")).toBeTruthy(); + expect(screen.getByText("-18")).toBeTruthy(); + }); + + it("安全中心保留管理员游标分页", async () => { + mockSession("SUPER_ADMIN"); + const first = operator("operator-1", "owner"); + const second = operator("operator-2", "support"); + vi.spyOn(adminApi, "operators") + .mockResolvedValueOnce({ items: [first], nextCursor: "operator-next" }) + .mockResolvedValueOnce({ items: [second] }); + vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary()); + window.location.hash = "#/security"; + + render(); + + expect(await screen.findByRole("heading", { name: "安全中心" })).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" })); + + await waitFor(() => { + expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next"); + }); + expect(await screen.findByText("support")).toBeTruthy(); + expect(screen.getByText("已加载 2 个账户")).toBeTruthy(); + }); +}); + +function mockSession(role: "SUPER_ADMIN" | "SUPPORT") { + vi.spyOn(adminApi, "session").mockResolvedValue({ + authenticated: true, + operatorName: "owner", + role, + }); +} + +function user(displayName: string, id: string): UserSummary { + return { + userId: id, + displayName, + status: "active", + creditBalance: 120, + consumedCredits: 18, + createdAt: "2026-08-01T08:00:00Z", + }; +} + +function operator(operatorId: string, username: string): AdminOperator { + return { + operatorId, + username, + role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT", + enabled: true, + failedLoginCount: 0, + createdAt: "2026-08-01T08:00:00Z", + updatedAt: "2026-08-01T08:00:00Z", + }; +} + +function summary(): AdminSecuritySummary { + return { + enabledOperators: 2, + lockedOperators: 0, + activeSessions: 1, + }; +} diff --git a/admin-web/src/test/risk-flows.test.tsx b/admin-web/src/test/risk-flows.test.tsx new file mode 100644 index 0000000..a1c8d4e --- /dev/null +++ b/admin-web/src/test/risk-flows.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { adminApi, ApiError } from "../api/client"; +import type { UserDetail } from "../api/types"; +import { TrendChart } from "../features/overview/overview-page"; +import { GrantDialog } from "../features/users/users-page"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("高风险交互与 CSP", () => { + it("趋势图不生成内联样式并提供完整辅助数据表", () => { + const { container } = render( + , + ); + + expect(screen.getByRole("img", { name: /新增用户与积分消耗趋势/ })).toBeTruthy(); + expect(container.querySelector("[style]")).toBeNull(); + expect(screen.getByText("新增用户与积分消耗趋势完整数据")).toBeTruthy(); + expect(screen.getByText("35")).toBeTruthy(); + }); + + it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => { + const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue( + new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0), + ); + const user = userDetail(); + render( + , + ); + + await userEvent.type(screen.getByLabelText("赠送积分"), "100"); + await userEvent.type(screen.getByLabelText("赠送原因"), "运营补偿"); + await userEvent.click(screen.getByRole("button", { name: "下一步" })); + await userEvent.click(screen.getByRole("button", { name: "确认赠送" })); + + expect( + await screen.findByText("赠送结果尚未确认;重试会复用同一请求,不会重复到账"), + ).toBeTruthy(); + const firstKey = grant.mock.calls[0]?.[0].idempotencyKey; + + await userEvent.click(screen.getByRole("button", { name: "确认赠送" })); + await waitFor(() => expect(grant).toHaveBeenCalledTimes(2)); + expect(grant.mock.calls[1]?.[0].idempotencyKey).toBe(firstKey); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); + +function userDetail(): UserDetail { + return { + userId: "11111111-1111-4111-8111-111111111111", + displayName: "测试用户", + status: "active", + creditBalance: 120, + consumedCredits: 18, + createdAt: "2026-08-01T08:00:00Z", + qualifiedUsage: true, + }; +} diff --git a/admin-web/src/test/ui.test.ts b/admin-web/src/test/ui.test.ts deleted file mode 100644 index 83a6e3a..0000000 --- a/admin-web/src/test/ui.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js"; -import { describe, expect, it, vi } from "vitest"; -import { renderError, renderLoading, setButtonBusy } from "../components/ui"; - -describe("Web Awesome UI states", () => { - it("renders accessible loading and retry components", () => { - const container = document.createElement("div"); - const retry = vi.fn(); - - renderLoading(container, "读取用户"); - expect(container.querySelector("wa-spinner")).not.toBeNull(); - expect(container.textContent).toContain("读取用户"); - - renderError(container, new Error("failed"), retry); - const retryButton = container.querySelector("wa-button[data-retry]"); - expect(container.querySelector('wa-callout[variant="danger"]')).not.toBeNull(); - retryButton?.click(); - expect(retry).toHaveBeenCalledOnce(); - }); - - it("uses the Web Awesome loading state and restores the label", () => { - const button = document.createElement("wa-button") as WaButton; - button.textContent = "提交"; - - setButtonBusy(button, true, "提交中…"); - expect(button.loading).toBe(true); - expect(button.disabled).toBe(true); - expect(button.textContent).toBe("提交中…"); - - setButtonBusy(button, false); - expect(button.loading).toBe(false); - expect(button.disabled).toBe(false); - expect(button.textContent).toBe("提交"); - }); -}); diff --git a/admin-web/tsconfig.json b/admin-web/tsconfig.json index 4f1e293..ac01851 100644 --- a/admin-web/tsconfig.json +++ b/admin-web/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["node"], + "jsx": "react-jsx", "strict": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, diff --git a/admin-web/vite.config.ts b/admin-web/vite.config.ts index 432774e..273bd11 100644 --- a/admin-web/vite.config.ts +++ b/admin-web/vite.config.ts @@ -1,7 +1,10 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ base: "/admin/", + plugins: [react(), tailwindcss()], server: { proxy: { "/v1/admin": { @@ -12,6 +15,21 @@ export default defineConfig({ }, build: { target: "es2022", + rollupOptions: { + output: { + manualChunks(id) { + if ( + id.includes("/node_modules/react/") || + id.includes("/node_modules/react-dom/") || + id.includes("/node_modules/react-router") + ) { + return "react-vendor"; + } + if (id.includes("/node_modules/@base-ui/")) return "base-ui"; + return undefined; + }, + }, + }, }, test: { environment: "jsdom", diff --git a/docs/STOREKIT_CREDITS.md b/docs/STOREKIT_CREDITS.md index 12f9e59..a5924d0 100644 --- a/docs/STOREKIT_CREDITS.md +++ b/docs/STOREKIT_CREDITS.md @@ -47,3 +47,14 @@ mix and require ongoing margin monitoring. transaction. A client retry returns the original grant. - The app finishes the StoreKit transaction only after server acknowledgement. - Signed transaction bodies and Apple certificate contents are never logged. + +## Purchase history + +- `GET /v1/storekit/transactions` requires the existing Bearer access token. +- The account comes only from the authenticated session; clients never submit it. +- History is read from the existing purchase audit and immutable credit ledger, + so purchases credited before the endpoint was deployed are included. +- Results are ordered by `purchasedAt` and then `transactionId`, both descending, + with opaque cursor pagination (`limit` defaults to 50 and is bounded to 1–100). +- Responses omit JWS data, Apple identifiers, account IDs, and internal record IDs. +- History queries never call the App Store. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3b58cbb..cd65f0b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -177,6 +177,28 @@ paths: items: { $ref: "#/components/schemas/StoreKitProduct" } default: { $ref: "#/components/responses/Error" } /v1/storekit/transactions: + get: + summary: Return credited StoreKit purchase history for the current account + description: | + Returns only App Store transactions that were previously verified and + committed with their immutable credit-ledger entries. Results include + purchases credited before this endpoint was introduced and never call + the App Store at query time. The opaque cursor follows the stable + descending order of `purchasedAt` and `transactionId`. + parameters: + - $ref: "#/components/parameters/Limit" + - name: cursor + in: query + description: Opaque cursor returned by the preceding page. + schema: { type: string, minLength: 1, maxLength: 256 } + responses: + "200": + description: Credited StoreKit purchases for the authenticated account + content: + application/json: + schema: { $ref: "#/components/schemas/StoreKitTransactionHistory" } + "400": { $ref: "#/components/responses/Error" } + default: { $ref: "#/components/responses/Error" } post: summary: Verify an App Store transaction and idempotently grant credits description: | @@ -1306,6 +1328,29 @@ components: creditsGranted: { type: integer, format: int64, minimum: 1 } balanceAfter: { type: integer, format: int64, minimum: 0 } replayed: { type: boolean } + StoreKitTransactionHistoryItem: + type: object + additionalProperties: false + required: + [transactionId, productId, creditsGranted, balanceAfter, purchasedAt, status] + properties: + transactionId: { type: string, pattern: "^[0-9]{1,64}$" } + productId: { type: string, minLength: 3, maxLength: 128 } + creditsGranted: { type: integer, format: int64, minimum: 1 } + balanceAfter: { type: integer, format: int64, minimum: 0 } + purchasedAt: { type: string, format: date-time } + status: { type: string, enum: [credited] } + StoreKitTransactionHistory: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: + type: array + items: { $ref: "#/components/schemas/StoreKitTransactionHistoryItem" } + nextCursor: + type: ["string", "null"] + maxLength: 256 AppleAppSiteAssociation: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index 861495c..763577e 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -295,7 +295,7 @@ fun Application.module() { accountRoutes(koin.get()) creditRoutes(koin.get(), koin.get()) referralRoutes(koin.get(), koin.get()) - storeKitRoutes(koin.get(), koin.get()) + storeKitRoutes(koin.get()) } rateLimit(GATEWAY_RATE_LIMIT) { configureGatewayRoutes( diff --git a/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt b/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt index e3062f4..f917396 100644 --- a/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt +++ b/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt @@ -55,6 +55,24 @@ data class StoreKitPurchaseResult( val replayed: Boolean, ) +data class StoreKitTransactionCursor( + val purchasedAt: Instant, + val transactionId: String, +) + +data class CreditedStoreKitTransaction( + val transactionId: String, + val productId: String, + val creditsGranted: Long, + val balanceAfter: Long, + val purchasedAt: Instant, +) + +data class StoreKitTransactionPage( + val items: List, + val nextCursor: String?, +) + sealed class StoreKitException(message: String) : RuntimeException(message) class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable") diff --git a/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt b/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt index b25df15..6a92dcd 100644 --- a/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt @@ -1,8 +1,13 @@ package com.osglab.account.features.storekit.models +import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction import com.osglab.account.features.storekit.domain.StoreKitProduct import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult +import com.osglab.account.features.storekit.domain.StoreKitTransactionPage import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive @Serializable data class StoreKitProductDto( @@ -40,3 +45,41 @@ data class StoreKitPurchaseResponse( } } +@Serializable +data class StoreKitTransactionHistoryItemResponse( + val transactionId: String, + val productId: String, + val creditsGranted: Long, + val balanceAfter: Long, + val purchasedAt: String, + val status: String, +) { + companion object { + fun fromDomain( + transaction: CreditedStoreKitTransaction, + ): StoreKitTransactionHistoryItemResponse = + StoreKitTransactionHistoryItemResponse( + transactionId = transaction.transactionId, + productId = transaction.productId, + creditsGranted = transaction.creditsGranted, + balanceAfter = transaction.balanceAfter, + purchasedAt = transaction.purchasedAt.toString(), + status = "credited", + ) + } +} + +@Serializable +data class StoreKitTransactionHistoryResponse( + val items: List, + val nextCursor: JsonElement, +) { + companion object { + fun fromDomain(page: StoreKitTransactionPage): StoreKitTransactionHistoryResponse = + StoreKitTransactionHistoryResponse( + items = page.items.map(StoreKitTransactionHistoryItemResponse::fromDomain), + nextCursor = page.nextCursor?.let(::JsonPrimitive) ?: JsonNull, + ) + } +} + diff --git a/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt b/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt index cdd47a2..1a3714e 100644 --- a/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt @@ -1,16 +1,26 @@ package com.osglab.account.features.storekit.repositories +import com.osglab.account.features.credits.domain.LedgerEntryType +import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase import com.osglab.account.features.storekit.domain.StoreKitEnvironment +import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor import java.util.UUID import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll interface StoreKitRepository { fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? + fun listCreditedTransactions( + userId: UUID, + limit: Int, + before: StoreKitTransactionCursor?, + ): List + fun insert(purchase: StoreKitCreditPurchase) } @@ -22,6 +32,60 @@ object ExposedStoreKitRepository : StoreKitRepository { .singleOrNull() ?.toStoreKitCreditPurchase() + override fun listCreditedTransactions( + userId: UUID, + limit: Int, + before: StoreKitTransactionCursor?, + ): List { + val accountId = userId.toString() + val query = StoreKitCreditPurchases + .innerJoin( + otherTable = StoreKitPurchaseLedger, + onColumn = { ledgerEntryId }, + otherColumn = { id }, + ) + .select( + StoreKitCreditPurchases.transactionId, + StoreKitCreditPurchases.productId, + StoreKitCreditPurchases.creditsGranted, + StoreKitCreditPurchases.purchasedAt, + StoreKitPurchaseLedger.balanceAfter, + ) + query.where { + val accountAndCredited = + (StoreKitCreditPurchases.userId eq accountId) and + (StoreKitPurchaseLedger.userId eq accountId) and + (StoreKitPurchaseLedger.entryType eq LedgerEntryType.STOREKIT_PURCHASE) and + ( + StoreKitPurchaseLedger.amountDelta eq + StoreKitCreditPurchases.creditsGranted + ) and + (StoreKitPurchaseLedger.referenceId eq StoreKitCreditPurchases.id) + if (before == null) { + accountAndCredited + } else { + accountAndCredited and + ( + (StoreKitCreditPurchases.purchasedAt less before.purchasedAt) or + ( + (StoreKitCreditPurchases.purchasedAt eq before.purchasedAt) and + ( + StoreKitCreditPurchases.transactionId less + before.transactionId + ) + ) + ) + } + } + return query + .orderBy( + StoreKitCreditPurchases.purchasedAt to SortOrder.DESC, + StoreKitCreditPurchases.transactionId to SortOrder.DESC, + ) + .limit(limit) + .map(ResultRow::toCreditedStoreKitTransaction) + } + override fun insert(purchase: StoreKitCreditPurchase) { StoreKitCreditPurchases.insert { it[id] = purchase.id.toString() @@ -59,6 +123,17 @@ private object StoreKitCreditPurchases : Table("storekit_credit_purchases") { override val primaryKey = PrimaryKey(id) } +private object StoreKitPurchaseLedger : Table("credit_ledger") { + val id = varchar("id", 36) + val userId = varchar("user_id", 36) + val entryType = enumerationByName("entry_type", 32) + val amountDelta = long("amount_delta") + val balanceAfter = long("balance_after") + val referenceId = varchar("reference_id", 36).nullable() + + override val primaryKey = PrimaryKey(id) +} + private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase = StoreKitCreditPurchase( id = UUID.fromString(this[StoreKitCreditPurchases.id]), @@ -75,3 +150,12 @@ private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase = signedAt = this[StoreKitCreditPurchases.signedAt], createdAt = this[StoreKitCreditPurchases.createdAt], ) + +private fun ResultRow.toCreditedStoreKitTransaction(): CreditedStoreKitTransaction = + CreditedStoreKitTransaction( + transactionId = this[StoreKitCreditPurchases.transactionId], + productId = this[StoreKitCreditPurchases.productId], + creditsGranted = this[StoreKitCreditPurchases.creditsGranted], + balanceAfter = this[StoreKitPurchaseLedger.balanceAfter], + purchasedAt = this[StoreKitCreditPurchases.purchasedAt], + ) diff --git a/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt b/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt index 4a4f2e5..7297ce1 100644 --- a/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt @@ -2,6 +2,7 @@ package com.osglab.account.features.storekit.routes import com.osglab.account.common.api.ApiError import com.osglab.account.common.api.ApiErrorResponse +import com.osglab.account.common.security.SESSION_AUTH_NAME import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor @@ -12,8 +13,10 @@ import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed import com.osglab.account.features.storekit.models.StoreKitProductDto import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse import com.osglab.account.features.storekit.models.StoreKitSubmitRequest +import com.osglab.account.features.storekit.models.StoreKitTransactionHistoryResponse import com.osglab.account.features.storekit.services.StoreKitService import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -25,64 +28,102 @@ fun Route.storeKitRoutes( service: StoreKitService, authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor, ) { - route("/v1/storekit") { - get("/products") { - val userId = authenticatedUser.extract(call) - if (userId == null) { - call.respond( - HttpStatusCode.Unauthorized, - ApiErrorResponse(ApiError("unauthorized", "Authentication required")), - ) - return@get + authenticate(SESSION_AUTH_NAME) { + route("/v1/storekit") { + get("/products") { + val userId = authenticatedUser.extract(call) + if (userId == null) { + call.respond( + HttpStatusCode.Unauthorized, + ApiErrorResponse(ApiError("unauthorized", "Authentication required")), + ) + return@get + } + call.respond(service.products().map(StoreKitProductDto::fromDomain)) } - call.respond(service.products().map(StoreKitProductDto::fromDomain)) - } - post("/transactions") { - val userId = authenticatedUser.extract(call) - if (userId == null) { - call.respond( - HttpStatusCode.Unauthorized, - ApiErrorResponse(ApiError("unauthorized", "Authentication required")), - ) - return@post + get("/transactions") { + val userId = authenticatedUser.extract(call) + if (userId == null) { + call.respond( + HttpStatusCode.Unauthorized, + ApiErrorResponse(ApiError("unauthorized", "Authentication required")), + ) + return@get + } + try { + if (call.request.queryParameters.contains("accountId")) { + throw InvalidStoreKitRequest("accountId is not accepted") + } + val rawLimit = call.request.queryParameters["limit"] + val limit = rawLimit?.toIntOrNull() + ?: if (rawLimit == null) { + 50 + } else { + throw InvalidStoreKitRequest("limit must be an integer") + } + call.respond( + StoreKitTransactionHistoryResponse.fromDomain( + service.listTransactions( + userId = userId, + limit = limit, + cursor = call.request.queryParameters["cursor"], + ) + ) + ) + } catch (_: InvalidStoreKitRequest) { + call.respond( + HttpStatusCode.BadRequest, + ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")), + ) + } } - val request = call.receive() - try { - call.respond( - HttpStatusCode.OK, - StoreKitPurchaseResponse.fromDomain( - service.submit(userId, request.signedTransaction) - ), - ) - } catch (_: StoreKitUnavailable) { - call.respond( - HttpStatusCode.ServiceUnavailable, - ApiErrorResponse( - ApiError("external_service_unavailable", "Credit purchases are unavailable") - ), - ) - } catch (_: InvalidStoreKitRequest) { - call.respond( - HttpStatusCode.BadRequest, - ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")), - ) - } catch (_: StoreKitVerificationFailed) { - call.respond( - HttpStatusCode.UnprocessableEntity, - ApiErrorResponse( - ApiError("transaction_invalid", "The App Store transaction is invalid") - ), - ) - } catch (_: StoreKitPurchaseConflict) { - call.respond( - HttpStatusCode.Conflict, - ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")), - ) - } catch (_: CreditConflict) { - call.respond( - HttpStatusCode.Conflict, - ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")), - ) + post("/transactions") { + val userId = authenticatedUser.extract(call) + if (userId == null) { + call.respond( + HttpStatusCode.Unauthorized, + ApiErrorResponse(ApiError("unauthorized", "Authentication required")), + ) + return@post + } + val request = call.receive() + try { + call.respond( + HttpStatusCode.OK, + StoreKitPurchaseResponse.fromDomain( + service.submit(userId, request.signedTransaction) + ), + ) + } catch (_: StoreKitUnavailable) { + call.respond( + HttpStatusCode.ServiceUnavailable, + ApiErrorResponse( + ApiError("external_service_unavailable", "Credit purchases are unavailable") + ), + ) + } catch (_: InvalidStoreKitRequest) { + call.respond( + HttpStatusCode.BadRequest, + ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")), + ) + } catch (_: StoreKitVerificationFailed) { + call.respond( + HttpStatusCode.UnprocessableEntity, + ApiErrorResponse( + ApiError("transaction_invalid", "The App Store transaction is invalid") + ), + ) + } catch (_: StoreKitPurchaseConflict) { + call.respond( + HttpStatusCode.Conflict, + ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")), + ) + } catch (_: CreditConflict) { + call.respond( + HttpStatusCode.Conflict, + ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")), + ) + } } } } diff --git a/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt b/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt index 9864978..9008a81 100644 --- a/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt +++ b/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt @@ -9,13 +9,17 @@ import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase import com.osglab.account.features.storekit.domain.StoreKitProduct import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult +import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor +import com.osglab.account.features.storekit.domain.StoreKitTransactionPage import com.osglab.account.features.storekit.domain.StoreKitUnavailable import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier +import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.time.Clock import java.time.Duration import java.time.Instant +import java.util.Base64 import java.util.UUID class StoreKitService( @@ -33,6 +37,33 @@ class StoreKitService( fun products(): List = productsById.values.sortedBy(StoreKitProduct::credits) + suspend fun listTransactions( + userId: UUID, + limit: Int = DEFAULT_PAGE_SIZE, + cursor: String? = null, + ): StoreKitTransactionPage { + if (limit !in 1..MAX_PAGE_SIZE) { + throw InvalidStoreKitRequest("limit must be between 1 and 100") + } + val decodedCursor = cursor?.let(StoreKitTransactionCursorCodec::decode) + val results = transactions.inTransaction { unit -> + unit.storeKit.listCreditedTransactions(userId, limit + 1, decodedCursor) + } + val items = results.take(limit) + return StoreKitTransactionPage( + items = items, + nextCursor = if (results.size > limit) { + items.lastOrNull()?.let { + StoreKitTransactionCursorCodec.encode( + StoreKitTransactionCursor(it.purchasedAt, it.transactionId) + ) + } + } else { + null + }, + ) + } + suspend fun submit( userId: UUID, signedTransaction: String, @@ -155,6 +186,40 @@ class StoreKitService( private companion object { const val MIN_SIGNED_TRANSACTION_LENGTH = 100 const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768 + const val DEFAULT_PAGE_SIZE = 50 + const val MAX_PAGE_SIZE = 100 val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5) } } + +internal object StoreKitTransactionCursorCodec { + private const val MAX_CURSOR_LENGTH = 256 + private const val INVALID_CURSOR_MESSAGE = "cursor is invalid" + private val TRANSACTION_ID = Regex("[0-9]{1,64}") + + fun encode(cursor: StoreKitTransactionCursor): String { + val value = "${cursor.purchasedAt}|${cursor.transactionId}" + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + } + + fun decode(value: String): StoreKitTransactionCursor { + if (value.length !in 1..MAX_CURSOR_LENGTH || value != value.trim()) { + throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE) + } + return try { + val decoded = String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8, + ) + val parts = decoded.split('|') + require(parts.size == 2 && TRANSACTION_ID.matches(parts[1])) + StoreKitTransactionCursor( + purchasedAt = Instant.parse(parts[0]), + transactionId = parts[1], + ) + } catch (_: IllegalArgumentException) { + throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE) + } + } +} diff --git a/src/main/resources/db/migration/V15__storekit_purchase_history_index.sql b/src/main/resources/db/migration/V15__storekit_purchase_history_index.sql new file mode 100644 index 0000000..36f12e9 --- /dev/null +++ b/src/main/resources/db/migration/V15__storekit_purchase_history_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_storekit_purchase_user_purchased_transaction + ON storekit_credit_purchases (user_id, purchased_at, transaction_id); diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index 672e718..36fa02e 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -77,6 +77,26 @@ class DeploymentConsistencyTest : FunSpec({ rates shouldContain "1,\n 1000,\n 1,\n 400," } + test("StoreKit history remains ledger backed, indexed, and privacy minimized") { + val historyIndex = root.read( + "src/main/resources/db/migration/V15__storekit_purchase_history_index.sql", + ) + historyIndex shouldContain + "ON storekit_credit_purchases (user_id, purchased_at, transaction_id)" + + val openApi = root.read("docs/openapi.yaml") + val historySchema = openApi + .substringAfter(" StoreKitTransactionHistoryItem:") + .substringBefore(" AppleAppSiteAssociation:") + historySchema shouldContain "purchasedAt" + historySchema shouldContain "status" + historySchema shouldContain "nextCursor" + historySchema shouldNotContain "signedTransaction" + historySchema shouldNotContain "appAccountToken" + historySchema shouldNotContain "userId" + historySchema shouldNotContain "accountId" + } + test("account profiles cascade on deletion and grants stay aligned") { val profileMigration = root.read( "src/main/resources/db/migration/V11__account_profiles.sql", diff --git a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt index 5abfd05..3823388 100644 --- a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt +++ b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt @@ -21,7 +21,9 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget import com.osglab.account.features.referrals.domain.ReferralCode import com.osglab.account.features.referrals.domain.ReferralRewardStatus import com.osglab.account.features.referrals.repositories.ReferralsRepository +import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase +import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor import com.osglab.account.features.storekit.repositories.StoreKitRepository import java.time.Instant import java.util.UUID @@ -292,6 +294,48 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? = storeKitPurchases[transactionId] + override fun listCreditedTransactions( + userId: UUID, + limit: Int, + before: StoreKitTransactionCursor?, + ): List = + storeKitPurchases.values + .asSequence() + .filter { purchase -> + purchase.userId == userId && + ( + before == null || + purchase.purchasedAt < before.purchasedAt || + ( + purchase.purchasedAt == before.purchasedAt && + purchase.transactionId < before.transactionId + ) + ) + } + .mapNotNull { purchase -> + ledger.singleOrNull { + it.id == purchase.ledgerEntryId && + it.userId == userId && + it.type == LedgerEntryType.STOREKIT_PURCHASE && + it.amountDelta == purchase.creditsGranted && + it.referenceId == purchase.id + }?.let { ledgerEntry -> + CreditedStoreKitTransaction( + transactionId = purchase.transactionId, + productId = purchase.productId, + creditsGranted = purchase.creditsGranted, + balanceAfter = ledgerEntry.balanceAfter, + purchasedAt = purchase.purchasedAt, + ) + } + } + .sortedWith( + compareByDescending { it.purchasedAt } + .thenByDescending { it.transactionId } + ) + .take(limit) + .toList() + override fun insert(purchase: StoreKitCreditPurchase) { check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null) } diff --git a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRepositoryIntegrationTest.kt new file mode 100644 index 0000000..3c26817 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRepositoryIntegrationTest.kt @@ -0,0 +1,175 @@ +package com.osglab.account.features.storekit + +import com.osglab.account.config.DatabaseConfig +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner +import com.osglab.account.features.storekit.domain.StoreKitEnvironment +import com.osglab.account.features.storekit.domain.StoreKitProduct +import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction +import com.osglab.account.features.storekit.services.StoreKitService +import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import java.sql.DriverManager +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import org.opentest4j.TestAbortedException +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.MySQLContainer + +class StoreKitRepositoryIntegrationTest : FunSpec({ + test("existing credited rows are account isolated and use stable cursor pagination") { + withStoreKitDatabase { config, databaseFactory -> + val now = Instant.parse("2026-08-19T14:00:00Z") + val userId = UUID.fromString("10000000-0000-0000-0000-000000000010") + val otherUserId = UUID.fromString("10000000-0000-0000-0000-000000000011") + val product = StoreKitProduct("3000tks", 3_000) + insertAccounts(config, listOf(userId, otherUserId), now) + val transactions = mapOf( + "a".repeat(100) to verified("2000000000001", userId, now.minusSeconds(30)), + "b".repeat(100) to verified("2000000000002", userId, now.minusSeconds(20)), + "c".repeat(100) to verified("2000000000003", userId, now.minusSeconds(20)), + "d".repeat(100) to verified("2000000000009", otherUserId, now.minusSeconds(10)), + ) + val runner = ExposedBillingTransactionRunner(databaseFactory.database) + val purchaseService = StoreKitService( + products = listOf(product), + verifier = StoreKitTransactionVerifier(transactions::getValue), + transactions = runner, + clock = Clock.fixed(now, ZoneOffset.UTC), + ) + transactions.keys.take(3).forEach { purchaseService.submit(userId, it) } + purchaseService.submit(otherUserId, "d".repeat(100)) + purchaseService.submit(userId, "c".repeat(100)) + + // A fresh service instance proves the query reads persisted V9 audit/ledger rows. + val historyService = StoreKitService( + products = listOf(product), + verifier = StoreKitTransactionVerifier { error("history must not call Apple") }, + transactions = runner, + clock = Clock.fixed(now.plusSeconds(60), ZoneOffset.UTC), + ) + val firstPage = historyService.listTransactions(userId, limit = 2) + val secondPage = historyService.listTransactions( + userId = userId, + limit = 2, + cursor = firstPage.nextCursor, + ) + + firstPage.items.map { it.transactionId } shouldContainExactly listOf( + "2000000000003", + "2000000000002", + ) + secondPage.items.map { it.transactionId } shouldContainExactly + listOf("2000000000001") + secondPage.nextCursor shouldBe null + + storeKitHistoryIndexColumns(config) shouldContainExactly listOf( + "user_id", + "purchased_at", + "transaction_id", + ) + } + } +}) + +private fun verified( + transactionId: String, + userId: UUID, + purchasedAt: Instant, +) = VerifiedStoreKitTransaction( + transactionId = transactionId, + originalTransactionId = transactionId, + appAccountToken = userId, + productId = "3000tks", + environment = StoreKitEnvironment.SANDBOX, + purchasedAt = purchasedAt, + signedAt = purchasedAt.plusSeconds(1), + revokedAt = null, +) + +private suspend fun withStoreKitDatabase( + block: suspend (DatabaseConfig, DatabaseFactory) -> Unit, +) { + val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank) + if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) { + throw TestAbortedException("Docker is unavailable; MySQL integration test skipped") + } + val mysql = if (externalJdbcUrl == null) { + StoreKitMySqlContainer("mysql:8.4") + .withDatabaseName("osg_storekit_history_test") + .withUsername("test") + .withPassword("test") + .also(StoreKitMySqlContainer::start) + } else { + null + } + val config = DatabaseConfig( + jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl, + username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank) + ?: mysql?.username + ?: "root", + password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "", + maximumPoolSize = 4, + ) + val databaseFactory = DatabaseFactory(config) + try { + databaseFactory.database + block(config, databaseFactory) + } finally { + databaseFactory.close() + mysql?.stop() + } +} + +private fun storeKitHistoryIndexColumns(config: DatabaseConfig): List = + DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection -> + connection.prepareStatement( + """ + SELECT column_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'storekit_credit_purchases' + AND index_name = 'idx_storekit_purchase_user_purchased_transaction' + ORDER BY seq_in_index + """.trimIndent() + ).use { statement -> + statement.executeQuery().use { result -> + buildList { + while (result.next()) { + add(result.getString("column_name")) + } + } + } + } + } + +private fun insertAccounts( + config: DatabaseConfig, + accountIds: List, + now: Instant, +) { + DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection -> + connection.prepareStatement( + """ + INSERT INTO accounts (id, apple_sub, created_at, updated_at) + VALUES (?, ?, ?, ?) + """.trimIndent() + ).use { statement -> + accountIds.forEach { accountId -> + statement.setString(1, accountId.toString()) + statement.setString(2, "test-$accountId") + statement.setTimestamp(3, java.sql.Timestamp.from(now)) + statement.setTimestamp(4, java.sql.Timestamp.from(now)) + statement.addBatch() + } + statement.executeBatch() + } + } +} + +private class StoreKitMySqlContainer(image: String) : + MySQLContainer(image) diff --git a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt index 78e7b2c..09482fd 100644 --- a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt @@ -1,7 +1,9 @@ package com.osglab.account.features.storekit +import com.osglab.account.common.api.installApiStatusPages +import com.osglab.account.common.security.AccountPrincipal +import com.osglab.account.common.security.installSessionAuthentication import com.osglab.account.features.credits.TestBillingStore -import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor import com.osglab.account.features.storekit.domain.StoreKitEnvironment import com.osglab.account.features.storekit.domain.StoreKitProduct import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction @@ -10,6 +12,8 @@ import com.osglab.account.features.storekit.services.StoreKitService import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.client.request.bearerAuth import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -32,6 +36,8 @@ import kotlin.test.Test class StoreKitRoutesTest { private val now = Instant.parse("2026-08-18T08:00:00Z") private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010") + private val sessionId = UUID.fromString("50000000-0000-0000-0000-000000000010") + private val accessToken = "valid-access-token" private val signedTransaction = "s".repeat(100) @Test @@ -39,20 +45,31 @@ class StoreKitRoutesTest { val service = service() application { install(ContentNegotiation) { json(Json { explicitNulls = false }) } + installApiStatusPages() + installSessionAuthentication { token -> + token.takeIf { it == accessToken }?.let { AccountPrincipal(userId, sessionId) } + } routing { - storeKitRoutes(service, AuthenticatedUserExtractor { userId }) + storeKitRoutes(service) } } - val catalog = client.get("/v1/storekit/products") + val catalog = client.get("/v1/storekit/products") { + bearerAuth(accessToken) + } val first = client.post("/v1/storekit/transactions") { + bearerAuth(accessToken) contentType(ContentType.Application.Json) setBody("""{"signedTransaction":"$signedTransaction"}""") } val replay = client.post("/v1/storekit/transactions") { + bearerAuth(accessToken) contentType(ContentType.Application.Json) setBody("""{"signedTransaction":"$signedTransaction"}""") } + val history = client.get("/v1/storekit/transactions") { + bearerAuth(accessToken) + } catalog.status shouldBe HttpStatusCode.OK catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500""" @@ -63,25 +80,70 @@ class StoreKitRoutesTest { first.bodyAsText() shouldContain """"replayed":false""" replay.status shouldBe HttpStatusCode.OK replay.bodyAsText() shouldContain """"replayed":true""" + history.status shouldBe HttpStatusCode.OK + history.bodyAsText() shouldContain + """"transactionId":"2000000000001","productId":"3000tks","creditsGranted":3000""" + history.bodyAsText() shouldContain """"purchasedAt":"2026-08-18T07:59:50Z"""" + history.bodyAsText() shouldContain """"status":"credited"""" + history.bodyAsText() shouldContain """"nextCursor":null""" + history.bodyAsText() shouldNotContain "signedTransaction" + history.bodyAsText() shouldNotContain "appAccountToken" + history.bodyAsText() shouldNotContain userId.toString() } @Test - fun `product catalog and transaction submission require authentication`() = testApplication { + fun `StoreKit endpoints require bearer authentication`() = testApplication { val service = service() application { install(ContentNegotiation) { json(Json { explicitNulls = false }) } + installApiStatusPages() + installSessionAuthentication { null } routing { - storeKitRoutes(service, AuthenticatedUserExtractor { null }) + storeKitRoutes(service) } } client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized + client.get("/v1/storekit/transactions").status shouldBe HttpStatusCode.Unauthorized client.post("/v1/storekit/transactions") { contentType(ContentType.Application.Json) setBody("""{"signedTransaction":"$signedTransaction"}""") }.status shouldBe HttpStatusCode.Unauthorized } + @Test + fun `empty history and invalid pagination use the public response contract`() = testApplication { + val service = service() + application { + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + installApiStatusPages() + installSessionAuthentication { AccountPrincipal(userId, sessionId) } + routing { + storeKitRoutes(service) + } + } + + val empty = client.get("/v1/storekit/transactions") { + bearerAuth(accessToken) + } + empty.status shouldBe HttpStatusCode.OK + empty.bodyAsText() shouldBe """{"items":[],"nextCursor":null}""" + + listOf( + "limit=0", + "limit=101", + "limit=abc", + "cursor=not-base64!", + "accountId=$userId", + ).forEach { query -> + val response = client.get("/v1/storekit/transactions?$query") { + bearerAuth(accessToken) + } + response.status shouldBe HttpStatusCode.BadRequest + response.bodyAsText() shouldContain """"code":"invalid_request"""" + } + } + private fun service(): StoreKitService { val verified = VerifiedStoreKitTransaction( transactionId = "2000000000001", diff --git a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt index cc3981c..3591155 100644 --- a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt @@ -1,8 +1,10 @@ package com.osglab.account.features.storekit import com.osglab.account.features.credits.TestBillingStore +import com.osglab.account.features.credits.domain.LedgerEntry import com.osglab.account.features.credits.domain.LedgerEntryType import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest +import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase import com.osglab.account.features.storekit.domain.StoreKitEnvironment import com.osglab.account.features.storekit.domain.StoreKitProduct import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict @@ -11,6 +13,7 @@ import com.osglab.account.features.storekit.services.StoreKitService import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.longs.shouldBeExactly import io.kotest.matchers.shouldBe @@ -78,6 +81,7 @@ class StoreKitServiceTest : FunSpec({ replay.replayed shouldBe true replay.balanceAfter shouldBeExactly 3_000 store.ledger shouldHaveSize 1 + service.listTransactions(userId).items shouldHaveSize 1 } test("concurrent transaction replay grants credits exactly once") { @@ -159,4 +163,116 @@ class StoreKitServiceTest : FunSpec({ } revokedStore.ledger shouldHaveSize 0 } + + test("history is account isolated, stably sorted, and cursor paginated") { + val store = TestBillingStore() + val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011") + val transactionsByJws = mapOf( + "a".repeat(100) to transaction( + transactionId = "2000000000001", + purchasedAt = now.minusSeconds(30), + ), + "b".repeat(100) to transaction( + transactionId = "2000000000002", + purchasedAt = now.minusSeconds(20), + ), + "c".repeat(100) to transaction( + transactionId = "2000000000003", + purchasedAt = now.minusSeconds(20), + ), + "d".repeat(100) to transaction( + transactionId = "2000000000009", + accountToken = otherUser, + purchasedAt = now.minusSeconds(10), + ), + ) + val historyService = StoreKitService( + products = listOf(product), + verifier = StoreKitTransactionVerifier(transactionsByJws::getValue), + transactions = store, + clock = Clock.fixed(now, ZoneOffset.UTC), + ) + transactionsByJws.keys.take(3).forEach { historyService.submit(userId, it) } + historyService.submit(otherUser, "d".repeat(100)) + historyService.submit(userId, "c".repeat(100)) + + val firstPage = historyService.listTransactions(userId, limit = 2) + val secondPage = historyService.listTransactions( + userId = userId, + limit = 2, + cursor = firstPage.nextCursor, + ) + + firstPage.items.map { it.transactionId } shouldContainExactly listOf( + "2000000000003", + "2000000000002", + ) + firstPage.items.map { it.balanceAfter } shouldContainExactly listOf(9_000, 6_000) + (firstPage.nextCursor != null) shouldBe true + secondPage.items.map { it.transactionId } shouldContainExactly listOf("2000000000001") + secondPage.nextCursor shouldBe null + store.storeKitPurchases.values shouldHaveSize 4 + } + + test("history reads purchases credited before the history endpoint exists") { + val store = TestBillingStore() + val purchaseId = UUID.fromString("30000000-0000-0000-0000-000000000001") + val ledgerEntryId = UUID.fromString("40000000-0000-0000-0000-000000000001") + val purchasedAt = Instant.parse("2026-01-01T01:02:03Z") + store.inTransaction { unit -> + unit.credits.insertLedgerEntry( + LedgerEntry( + id = ledgerEntryId, + userId = userId, + type = LedgerEntryType.STOREKIT_PURCHASE, + amountDelta = 3_000, + balanceAfter = 4_500, + idempotencyKey = "storekit:2000000000001", + referenceId = purchaseId, + createdAt = purchasedAt.plusSeconds(5), + ) + ) + unit.storeKit.insert( + StoreKitCreditPurchase( + id = purchaseId, + transactionId = "2000000000001", + originalTransactionId = "2000000000001", + userId = userId, + appAccountToken = userId, + productId = product.productId, + environment = StoreKitEnvironment.PRODUCTION, + creditsGranted = 3_000, + ledgerEntryId = ledgerEntryId, + signedTransactionSha256 = "a".repeat(64), + purchasedAt = purchasedAt, + signedAt = purchasedAt.plusSeconds(1), + createdAt = purchasedAt.plusSeconds(5), + ) + ) + } + + val history = service(store).listTransactions(userId) + + history.items.single().run { + transactionId shouldBe "2000000000001" + balanceAfter shouldBeExactly 4_500 + this.purchasedAt shouldBe purchasedAt + } + } + + test("history validates limit boundaries and rejects malformed cursors") { + val historyService = service(TestBillingStore()) + + shouldThrow { + historyService.listTransactions(userId, limit = 0) + } + shouldThrow { + historyService.listTransactions(userId, limit = 101) + } + shouldThrow { + historyService.listTransactions(userId, cursor = "not-base64!") + } + historyService.listTransactions(userId, limit = 1).items shouldHaveSize 0 + historyService.listTransactions(userId, limit = 100).items shouldHaveSize 0 + } })