diff --git a/package-lock.json b/package-lock.json index c0b41671..227659f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,15 @@ "dependencies": { "@google/genai": "^1.29.1", "@supabase/supabase-js": "^2.81.1", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.394", + "pg": "^8.16.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.5", @@ -21,12 +29,21 @@ "@tailwindcss/postcss": "4.1.17", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^5.0.5", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.1", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "@types/pg": "^8.15.6", "@typescript-eslint/eslint-plugin": "8.46.4", "@typescript-eslint/parser": "8.46.4", "@vitejs/plugin-react": "5.1.1", "@vitest/coverage-v8": "^4.0.8", "autoprefixer": "^10.4.22", + "dotenv": "^17.2.3", "eslint": "9.39.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -36,6 +53,7 @@ "jsdom": "^27.1.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.17", + "ts-node-dev": "^2.0.0", "typescript": "~5.9.3", "typescript-eslint": "8.46.4", "vite": "7.2.2", @@ -426,6 +444,30 @@ "node": ">=18" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2392,6 +2434,34 @@ } } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2445,6 +2515,27 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2456,6 +2547,26 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2533,6 +2644,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2540,6 +2683,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -2549,12 +2717,129 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/phoenix": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2982,6 +3267,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3005,6 +3303,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3055,6 +3366,40 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3339,6 +3684,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -3374,6 +3733,39 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3436,6 +3828,22 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3459,7 +3867,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3473,7 +3880,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3560,6 +3966,44 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3612,6 +4056,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.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", @@ -3628,6 +4094,50 @@ "node": ">=18" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3949,6 +4459,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3969,6 +4488,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3990,11 +4519,23 @@ "license": "MIT", "peer": true }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4005,6 +4546,16 @@ "node": ">= 0.4" } }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4020,6 +4571,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.249", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz", @@ -4033,6 +4590,15 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -4133,7 +4699,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4143,7 +4708,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4188,7 +4752,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4306,6 +4869,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4630,6 +5199,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -4646,6 +5224,57 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4780,6 +5409,23 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4862,6 +5508,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4876,6 +5531,22 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4895,7 +5566,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4985,7 +5655,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5010,7 +5679,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5039,15 +5707,15 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -5152,7 +5820,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5244,7 +5911,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5273,7 +5939,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5319,6 +5984,31 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5350,7 +6040,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5415,6 +6104,24 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5439,6 +6146,15 @@ "node": ">=12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5493,6 +6209,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -5702,6 +6431,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5966,9 +6701,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6074,6 +6809,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6412,6 +7202,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6419,6 +7245,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6504,11 +7336,17 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6521,6 +7359,27 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6558,6 +7417,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -6583,6 +7463,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -6604,6 +7494,19 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6636,6 +7539,24 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -6674,6 +7595,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6681,6 +7613,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -6714,7 +7656,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6808,6 +7749,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6908,6 +7870,62 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6918,6 +7936,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6961,6 +7989,16 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6968,6 +8006,11 @@ "dev": true, "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pdfjs-dist": { "version": "5.4.394", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz", @@ -6980,6 +8023,95 @@ "@napi-rs/canvas": "^0.1.81" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7046,6 +8178,45 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7133,6 +8304,19 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7143,6 +8327,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7164,6 +8363,46 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -7272,6 +8511,32 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/recharts": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.4.1.tgz", @@ -7446,9 +8711,9 @@ } }, "node_modules/rimraf/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -7544,6 +8809,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7647,7 +8928,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -7679,6 +8959,43 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -7734,6 +9051,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7759,7 +9082,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7779,7 +9101,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7796,7 +9117,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7815,7 +9135,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7850,6 +9169,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7860,6 +9189,26 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7867,6 +9216,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8082,6 +9440,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -8286,6 +9654,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -8312,6 +9689,16 @@ "node": ">=20" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -8325,6 +9712,189 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/ts-node-dev/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-node-dev/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ts-node-dev/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ts-node-dev/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tsconfig/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8344,6 +9914,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -8485,6 +10069,15 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -8535,6 +10128,31 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -9001,6 +10619,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/write-file-atomic": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", @@ -9052,6 +10676,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -9059,6 +10692,16 @@ "dev": true, "license": "ISC" }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a16a4cb3..3ab6ff1b 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,21 @@ "build": "DEBUG=\"tailwindcss:*\" vite build --debug", "preview": "vite preview", "test": "vitest run --coverage", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "seed": "ts-node-dev scripts/seed.ts" }, "dependencies": { "@google/genai": "^1.29.1", "@supabase/supabase-js": "^2.81.1", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.394", + "pg": "^8.16.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.5", @@ -24,12 +33,21 @@ "@tailwindcss/postcss": "4.1.17", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^5.0.5", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.1", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "@types/pg": "^8.15.6", "@typescript-eslint/eslint-plugin": "8.46.4", "@typescript-eslint/parser": "8.46.4", "@vitejs/plugin-react": "5.1.1", "@vitest/coverage-v8": "^4.0.8", "autoprefixer": "^10.4.22", + "dotenv": "^17.2.3", "eslint": "9.39.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -39,6 +57,7 @@ "jsdom": "^27.1.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.17", + "ts-node-dev": "^2.0.0", "typescript": "~5.9.3", "typescript-eslint": "8.46.4", "vite": "7.2.2", diff --git a/server.ts b/server.ts new file mode 100644 index 00000000..eed99985 --- /dev/null +++ b/server.ts @@ -0,0 +1,354 @@ +import express, { Request, Response, NextFunction } from 'express'; +import passport from 'passport'; +import { Strategy as LocalStrategy } from 'passport-local'; +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import dotenv from 'dotenv'; +import cookieParser from 'cookie-parser'; +import crypto from 'crypto'; +import fs from 'fs/promises'; +import { findUserByEmail, createUser, findUserById, findUserProfileById, updateUserPreferences, updateUserPassword, deleteUserById, checkTablesExist, getPoolStatus, saveRefreshToken, findUserByRefreshToken } from './src/services/db'; +import { logger } from './src/services/logger'; + +// Load environment variables from a .env file at the root of your project +dotenv.config(); + +const app = express(); +app.use(express.json()); // Middleware to parse JSON request bodies +app.use(cookieParser()); // Middleware to parse cookies +app.use(passport.initialize()); // Initialize Passport + +// --- Configuration --- +// IMPORTANT: Use a strong, randomly generated secret key and store it securely +// in your .env file, not hardcoded here. +const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this'; +if (JWT_SECRET === 'your_super_secret_jwt_key_change_this') { + logger.warn('Security Warning: JWT_SECRET is using a default, insecure value. Please set a strong secret in your .env file.'); +} + +// --- Passport Local Strategy (for email/password login) --- +passport.use(new LocalStrategy( + { usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username' + async (email, password, done) => { + try { + // 1. Find the user in your PostgreSQL database by email. + const user = await findUserByEmail(email); + + if (!user) { + // User not found + logger.warn(`Login attempt failed for non-existent user: ${email}`); + return done(null, false, { message: 'Incorrect email or password.' }); + } + + // 2. Compare the submitted password with the hashed password in your DB. + const isMatch = await bcrypt.compare(password, user.password_hash); + + if (!isMatch) { + // Password does not match + logger.warn(`Login attempt failed for user ${email} due to incorrect password.`); + return done(null, false, { message: 'Incorrect email or password.' }); + } + + // 3. Success! Return the user object (without password_hash for security). + const { password_hash, ...userWithoutHash } = user; + logger.info(`User successfully authenticated: ${email}`); + return done(null, userWithoutHash); + } catch (err) { + logger.error('Error during local authentication strategy:', { error: err }); + return done(err); + } + } +)); + +// --- Passport JWT Strategy (for protecting API routes) --- +const jwtOptions = { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Expect JWT in 'Authorization: Bearer ' header + secretOrKey: JWT_SECRET, +}; + +passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => { + try { + // The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }). + // We re-fetch the user from the database here to ensure they are still active and valid. + const user = await findUserById(jwt_payload.id); + + if (user) { + return done(null, user); // User object will be available as req.user in protected routes + } else { + logger.warn(`JWT authentication failed: user with ID ${jwt_payload.id} not found.`); + return done(null, false); // User not found or invalid token + } + } catch (err) { + logger.error('Error during JWT authentication strategy:', { error: err }); + return done(err, false); + } +})); + +// --- API Routes --- + +// --- Health & System Check Routes --- +app.get('/api/health/ping', (req: Request, res: Response) => { + res.status(200).send('pong'); +}); + +app.get('/api/health/db-schema', async (req: Request, res: Response) => { + try { + const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores']; + const missingTables = await checkTablesExist(requiredTables); + + if (missingTables.length > 0) { + return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` }); + } + return res.status(200).json({ success: true, message: 'All required database tables exist.' }); + } catch (error) { + logger.error('Error during DB schema check:', { error }); + return res.status(500).json({ success: false, message: 'An error occurred while checking the database schema.' }); + } +}); + +app.get('/api/health/storage', async (req: Request, res: Response) => { + // This path should be an absolute path on your server, configured via an environment variable. + const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets'; + try { + await fs.access(storagePath, fs.constants.W_OK); // Check for write access + return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` }); + } catch (error) { + logger.error(`Storage check failed for path: ${storagePath}`, { error }); + return res.status(500).json({ success: false, message: `Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.` }); + } +}); + +app.get('/api/health/db-pool', (req: Request, res: Response) => { + try { + const status = getPoolStatus(); + // A healthy pool should ideally have 0 waiting clients. A small number might be acceptable under load. + const isHealthy = status.waitingCount < 5; // Arbitrary threshold for "healthy" + const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`; + + if (isHealthy) { + return res.status(200).json({ success: true, message }); + } else { + logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`); + return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` }); + } + } catch (error) { + logger.error('Error during DB pool health check:', { error }); + return res.status(500).json({ success: false, message: 'An error occurred while checking the database pool status.' }); + } +}); + +// --- Authentication Routes --- + +// Registration Route +app.post('/api/auth/register', async (req: Request, res: Response, next: NextFunction) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ message: 'Email and password are required.' }); + } + + try { + const existingUser = await findUserByEmail(email); + if (existingUser) { + logger.warn(`Registration attempt for existing email: ${email}`); + return res.status(409).json({ message: 'User with that email already exists.' }); + } + + // Hash the password before storing it + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + logger.info(`Hashing password for new user: ${email}`); + + const newUser = await createUser(email, hashedPassword); + logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`); + + // Immediately log in the user by issuing a JWT + const payload = { id: newUser.id, email: newUser.email }; + const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); // Token expires in 1 hour + + // Generate and save a refresh token + const refreshToken = crypto.randomBytes(64).toString('hex'); + await saveRefreshToken(newUser.id, refreshToken); + + // Send the refresh token in a secure, HttpOnly cookie + res.cookie('refreshToken', refreshToken, { + httpOnly: true, // The cookie is not accessible via client-side script + secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + return res.status(201).json({ message: 'User registered successfully!', user: payload, token }); + } catch (err) { + logger.error('Error during /register route handling:', { error: err }); + return next(err); // Pass error to Express error handler + } +}); + +// Login Route +app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) => { + // Use passport.authenticate with the 'local' strategy + // { session: false } because we're using JWTs, not server-side sessions + passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false, info: { message: string }) => { + if (err) { + logger.error('Login authentication error in /login route:', { error: err }); + return next(err); // Pass server errors to the error handler + } + if (!user) { + // Authentication failed (e.g., incorrect credentials) + return res.status(401).json({ message: info ? info.message : 'Login failed' }); + } + + // User is authenticated, create and sign a JWT + // The user object here is what was returned from the LocalStrategy's `done` callback + const typedUser = user as { id: string; email: string }; + const payload = { id: typedUser.id, email: typedUser.email }; + const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); // Short-lived access token + + // Generate and save a refresh token + const refreshToken = crypto.randomBytes(64).toString('hex'); + saveRefreshToken(typedUser.id, refreshToken).then(() => { + logger.info(`JWT and refresh token issued for user: ${typedUser.email}`); + + // Send the refresh token in a secure, HttpOnly cookie + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + + return res.json({ user: payload, token: accessToken }); + }).catch(tokenErr => { + logger.error('Failed to save refresh token during login:', { error: tokenErr }); + return next(tokenErr); + }); + })(req, res, next); // This is crucial for Passport middleware to work correctly +}); + +// New Route to refresh the access token +app.post('/api/auth/refresh-token', async (req: Request, res: Response) => { + const { refreshToken } = req.cookies; + if (!refreshToken) { + return res.status(401).json({ message: 'Refresh token not found.' }); + } + + const user = await findUserByRefreshToken(refreshToken); + if (!user) { + return res.status(403).json({ message: 'Invalid refresh token.' }); + } + + const payload = { id: user.id, email: user.email }; + const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); + + res.json({ token: newAccessToken }); +}); + +// Example Protected Route to get user profile +// The frontend can call this route on startup to validate a stored JWT +app.get('/api/users/profile', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { + // If JWT authentication is successful, req.user will contain the user object + // from the JwtStrategy's `done` callback. + const authenticatedUser = req.user as { id: string; email: string }; + logger.info(`Profile requested for user: ${authenticatedUser.email}`); + + try { + const profile = await findUserProfileById(authenticatedUser.id); + if (!profile) { + logger.warn(`No profile found for authenticated user ID: ${authenticatedUser.id}`); + return res.status(404).json({ message: 'Profile not found for this user.' }); + } + res.json(profile); // Return the full profile object + } catch (error) { + logger.error('Error fetching profile in /api/users/profile:', { error }); + res.status(500).json({ message: 'Failed to retrieve user profile.' }); + } +}); + +// Protected Route to update user preferences +app.put('/api/users/profile/preferences', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { + const authenticatedUser = req.user as { id: string; email: string }; + const newPreferences = req.body; + + if (!newPreferences || typeof newPreferences !== 'object') { + return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' }); + } + + logger.info(`Preferences update requested for user: ${authenticatedUser.email}`, { newPreferences }); + + try { + const updatedProfile = await updateUserPreferences(authenticatedUser.id, newPreferences); + res.json(updatedProfile); + } catch (error) { + logger.error('Error updating preferences in /api/users/profile/preferences:', { error }); + res.status(500).json({ message: 'Failed to update user preferences.' }); + } +}); + +// Protected Route to update user password +app.put('/api/users/profile/password', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { + const authenticatedUser = req.user as { id: string; email: string }; + const { newPassword } = req.body; + + if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) { + return res.status(400).json({ message: 'Password must be a string of at least 6 characters.' }); + } + + try { + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(newPassword, saltRounds); + logger.info(`Hashing new password for user: ${authenticatedUser.email}`); + + await updateUserPassword(authenticatedUser.id, hashedPassword); + + logger.info(`Successfully updated password for user: ${authenticatedUser.email}`); + res.status(200).json({ message: 'Password updated successfully.' }); + } catch (error) { + logger.error('Error during password update:', { error }); + res.status(500).json({ message: 'Failed to update password.' }); + } +}); + +// Protected Route to delete a user account +app.delete('/api/users/account', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { + const authenticatedUser = req.user as { id: string; email: string }; + const { password } = req.body; + + if (!password) { + return res.status(400).json({ message: 'Password is required for account deletion.' }); + } + + try { + // 1. Fetch the user from DB to get their current password hash for verification + const userWithHash = await findUserByEmail(authenticatedUser.email); + if (!userWithHash) { + return res.status(404).json({ message: 'User not found.' }); + } + + // 2. Compare the submitted password with the stored hash + const isMatch = await bcrypt.compare(password, userWithHash.password_hash); + if (!isMatch) { + logger.warn(`Account deletion failed for user ${authenticatedUser.email} due to incorrect password.`); + return res.status(403).json({ message: 'Incorrect password.' }); + } + + // 3. If password matches, delete the user. The `ON DELETE CASCADE` in your schema will clean up related data. + await deleteUserById(authenticatedUser.id); + logger.warn(`User account deleted successfully: ${authenticatedUser.email}`); + res.status(200).json({ message: 'Account deleted successfully.' }); + } catch (error) { + logger.error('Error during account deletion:', { error }); + res.status(500).json({ message: 'Failed to delete account.' }); + } +}); + +// --- Error Handling and Server Startup --- + +// Basic error handling middleware +app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + logger.error('Unhandled application error:', { error: err.stack }); + res.status(500).send('Something broke!'); +}); + +const PORT = process.env.PORT || 3001; +app.listen(PORT, () => { + logger.info(`Authentication server started on port ${PORT}`); +}); \ No newline at end of file diff --git a/sql/delete_all_tables.sql.txt b/sql/delete_all_tables.sql.txt index e1f42799..2a5a454c 100644 --- a/sql/delete_all_tables.sql.txt +++ b/sql/delete_all_tables.sql.txt @@ -32,6 +32,7 @@ DROP TABLE IF EXISTS public.master_grocery_items CASCADE; DROP TABLE IF EXISTS public.stores CASCADE; DROP TABLE IF EXISTS public.categories CASCADE; DROP TABLE IF EXISTS public.profiles CASCADE; +DROP TABLE IF EXISTS public.users CASCADE; /* -- The delete_all_tables.sql.txt script does not and cannot remove the auth.users table - Go to your Supabase Project Dashboard -> Authentication -> Users. diff --git a/sql/schema.sql.txt b/sql/schema.sql.txt index b7689cfa..3698cf28 100644 --- a/sql/schema.sql.txt +++ b/sql/schema.sql.txt @@ -25,6 +25,24 @@ -- pg_trgm: For trigram-based fuzzy string matching (improving item searches). CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- For generating UUIDs + +-- ============================================================================ +-- PART 0.5: USER AUTHENTICATION TABLE +-- ============================================================================ +-- This replaces the Supabase `auth.users` table. +CREATE TABLE IF NOT EXISTS public.users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + refresh_token TEXT, -- Stores the long-lived refresh token for re-authentication. + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); +COMMENT ON TABLE public.users IS 'Stores user authentication information, replacing Supabase auth.'; + +-- Add an index on the refresh_token for faster lookups when refreshing tokens. +CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token); + -- ============================================================================ -- PART 1: TABLE CREATION @@ -113,7 +131,7 @@ CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING -- 0. Create a table for public user profiles. -- This table is linked to the auth.users table and stores non-sensitive user data. CREATE TABLE IF NOT EXISTS public.profiles ( - id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, updated_at TIMESTAMPTZ, full_name TEXT, avatar_url TEXT, @@ -125,7 +143,7 @@ COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to t -- 5. Create the 'user_watched_items' table. This links to the master list. CREATE TABLE IF NOT EXISTS public.user_watched_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, UNIQUE(user_id, master_item_id) @@ -149,7 +167,7 @@ COMMENT ON COLUMN public.user_alerts.threshold_value IS 'The numeric threshold f -- 8. Create a table to store notifications for users. CREATE TABLE IF NOT EXISTS public.notifications ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, content TEXT NOT NULL, link_url TEXT, is_read BOOLEAN DEFAULT false NOT NULL, @@ -188,7 +206,7 @@ COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g. -- 11. Create tables for user shopping lists. CREATE TABLE IF NOT EXISTS public.shopping_lists ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -215,7 +233,7 @@ COMMENT ON COLUMN public.shopping_list_items.is_purchased IS 'Lets users check i CREATE TABLE IF NOT EXISTS public.suggested_corrections ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id), + user_id UUID NOT NULL REFERENCES public.users(id), correction_type TEXT NOT NULL, suggested_value TEXT NOT NULL, status TEXT DEFAULT 'pending' NOT NULL, @@ -232,7 +250,7 @@ COMMENT ON COLUMN public.suggested_corrections.status IS 'The moderation status -- 13. Create a table for prices submitted directly by users from in-store. CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES auth.users(id), + user_id UUID NOT NULL REFERENCES public.users(id), master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id), store_id BIGINT NOT NULL REFERENCES public.stores(id), price_in_cents INTEGER NOT NULL, @@ -303,7 +321,7 @@ COMMENT ON TABLE public.flyer_locations IS 'A linking table associating a single -- A table to store recipes, which can be user-created or pre-populated. CREATE TABLE IF NOT EXISTS public.recipes ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID REFERENCES auth.users(id), + user_id UUID REFERENCES public.users(id), name TEXT NOT NULL, description TEXT, instructions TEXT, @@ -354,7 +372,7 @@ COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple ta CREATE TABLE IF NOT EXISTS public.recipe_ratings ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), comment TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -365,7 +383,7 @@ COMMENT ON TABLE public.recipe_ratings IS 'Stores individual user ratings for re -- A table to store a user's collection of planned meals for a date range. CREATE TABLE IF NOT EXISTS public.menu_plans ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, name TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, @@ -390,7 +408,7 @@ COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the -- A table to track the grocery items a user currently has in their pantry. CREATE TABLE IF NOT EXISTS public.pantry_items ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE, quantity NUMERIC NOT NULL, unit TEXT, @@ -464,141 +482,18 @@ END $$; -- ============================================================================ -- PART 3: STORAGE -- ============================================================================ --- Create the storage bucket for flyers if it doesn't exist. -INSERT INTO storage.buckets (id, name, public) -VALUES ('flyers', 'flyers', true) -ON CONFLICT (id) DO NOTHING; +-- The `storage.buckets` table is a Supabase-specific feature. +-- This section is removed. You will need to implement your own file storage +-- solution (e.g., local filesystem, S3-compatible service) and store +-- URLs/paths in the `flyers.image_url` column. -- ============================================================================ -- PART 4: ROW LEVEL SECURITY (RLS) -- ============================================================================ --- Enable RLS on all tables. -ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.stores ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.flyers ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.flyer_items ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.master_grocery_items ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_watched_items ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_alerts ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.item_price_history ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.master_item_aliases ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.shopping_lists ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.shopping_list_items ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.suggested_corrections ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_submitted_prices ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.brands ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.products ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.store_locations ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.flyer_locations ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.recipes ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.recipe_ingredients ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.menu_plans ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.planned_meals ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.tags ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.recipe_tags ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.recipe_ratings ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.pantry_items ENABLE ROW LEVEL SECURITY; - --- Create policies. -DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON public.profiles; -CREATE POLICY "Public profiles are viewable by everyone." ON public.profiles FOR SELECT USING (true); -DROP POLICY IF EXISTS "Users can update their own profile." ON public.profiles; -CREATE POLICY "Users can update their own profile." ON public.profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id); --- **FIX**: Add policy to allow the user creation trigger (running as 'postgres' owner) to insert new profiles. -DROP POLICY IF EXISTS "Allow service_role to insert new profiles" ON public.profiles; -- Cleanup old policy -DROP POLICY IF EXISTS "Allow postgres to insert new profiles" ON public.profiles; -- Cleanup in case of re-runs -CREATE POLICY "Allow postgres to insert new profiles" ON public.profiles FOR INSERT TO postgres WITH CHECK (true); - - --- Public read access for most public data -DROP POLICY IF EXISTS "Public read access for price history" ON public.item_price_history; CREATE POLICY "Public read access for price history" ON public.item_price_history FOR SELECT USING (true); -DROP POLICY IF EXISTS "Aliases are publicly viewable." ON public.master_item_aliases; CREATE POLICY "Aliases are publicly viewable." ON public.master_item_aliases FOR SELECT USING (true); -DROP POLICY IF EXISTS "Public read access" ON public.store_locations; CREATE POLICY "Public read access" ON public.store_locations FOR SELECT USING (true); -DROP POLICY IF EXISTS "Public read access" ON public.flyer_locations; CREATE POLICY "Public read access" ON public.flyer_locations FOR SELECT USING (true); -DROP POLICY IF EXISTS "Submitted prices are publicly viewable." ON public.user_submitted_prices; CREATE POLICY "Submitted prices are publicly viewable." ON public.user_submitted_prices FOR SELECT USING (true); -DROP POLICY IF EXISTS "Recipes are publicly viewable." ON public.recipes; CREATE POLICY "Recipes are publicly viewable." ON public.recipes FOR SELECT USING (true); -DROP POLICY IF EXISTS "Recipe ingredients are publicly viewable." ON public.recipe_ingredients; CREATE POLICY "Recipe ingredients are publicly viewable." ON public.recipe_ingredients FOR SELECT USING (true); -DROP POLICY IF EXISTS "Tags are publicly viewable." ON public.tags; CREATE POLICY "Tags are publicly viewable." ON public.tags FOR SELECT USING (true); -DROP POLICY IF EXISTS "Recipe-tag links are publicly viewable." ON public.recipe_tags; CREATE POLICY "Recipe-tag links are publicly viewable." ON public.recipe_tags FOR SELECT USING (true); -DROP POLICY IF EXISTS "Recipe ratings are publicly viewable." ON public.recipe_ratings; CREATE POLICY "Recipe ratings are publicly viewable." ON public.recipe_ratings FOR SELECT USING (true); - --- Allow FULL public access (read & write) for core data tables for demo purposes. --- This allows the "fake user" (anon role) to write flyer data. -DROP POLICY IF EXISTS "Allow full public access" ON public.stores; -- REMOVED for security -CREATE POLICY "Public read access" ON public.stores FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Allow full public access" ON public.categories; -- REMOVED for security -CREATE POLICY "Public read access" ON public.categories FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Allow full public access" ON public.flyers; -- REMOVED for security -CREATE POLICY "Public read access" ON public.flyers FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Allow full public access" ON public.flyer_items; -- REMOVED for security -CREATE POLICY "Public read access" ON public.flyer_items FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Allow full public access" ON public.master_grocery_items; -- REMOVED for security -CREATE POLICY "Public read access" ON public.master_grocery_items FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Allow full public access" ON public.brands; -- REMOVED for security -CREATE POLICY "Public read access" ON public.brands FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Allow full public access" ON public.products; -- REMOVED for security -CREATE POLICY "Public read access" ON public.products FOR SELECT USING (true); - --- User-specific policies (these remain locked down to authenticated users) -DROP POLICY IF EXISTS "Users can manage their own watched items." ON public.user_watched_items; -CREATE POLICY "Users can manage their own watched items." ON public.user_watched_items FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage their own alerts" ON public.user_alerts; -CREATE POLICY "Users can manage their own alerts" ON public.user_alerts FOR ALL USING (auth.uid() = (SELECT user_id FROM public.user_watched_items WHERE id = user_watched_item_id)); - -DROP POLICY IF EXISTS "Users can manage their own notifications" ON public.notifications; -CREATE POLICY "Users can manage their own notifications" ON public.notifications FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage their own shopping lists." ON public.shopping_lists; -CREATE POLICY "Users can manage their own shopping lists." ON public.shopping_lists FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); --- **FIX**: Add policy to allow the user creation trigger (running as 'postgres' owner) to insert new shopping lists. -DROP POLICY IF EXISTS "Allow service_role to insert new shopping lists" ON public.shopping_lists; -- Cleanup old policy -DROP POLICY IF EXISTS "Allow postgres to insert new shopping lists" ON public.shopping_lists; -- Cleanup in case of re-runs -CREATE POLICY "Allow postgres to insert new shopping lists" ON public.shopping_lists FOR INSERT TO postgres WITH CHECK (true); - -DROP POLICY IF EXISTS "Users can manage items in their own shopping lists." ON public.shopping_list_items; -CREATE POLICY "Users can manage items in their own shopping lists." ON public.shopping_list_items FOR ALL USING (auth.uid() = (SELECT user_id FROM public.shopping_lists WHERE id = shopping_list_id)); - -DROP POLICY IF EXISTS "Users can manage their own suggestions." ON public.suggested_corrections; -CREATE POLICY "Users can manage their own suggestions." ON public.suggested_corrections FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage their own submitted prices." ON public.user_submitted_prices; -CREATE POLICY "Users can manage their own submitted prices." ON public.user_submitted_prices FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage their own recipes." ON public.recipes; -CREATE POLICY "Users can manage their own recipes." ON public.recipes FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage their own menu plans." ON public.menu_plans; -CREATE POLICY "Users can manage their own menu plans." ON public.menu_plans FOR ALL USING (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage meals in their own menu plans." ON public.planned_meals; -CREATE POLICY "Users can manage meals in their own menu plans." ON public.planned_meals FOR ALL USING (auth.uid() = (SELECT user_id FROM public.menu_plans WHERE id = menu_plan_id)); - -DROP POLICY IF EXISTS "Users can manage their own recipe ratings." ON public.recipe_ratings; -CREATE POLICY "Users can manage their own recipe ratings." ON public.recipe_ratings FOR ALL USING (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage their own pantry items." ON public.pantry_items; -CREATE POLICY "Users can manage their own pantry items." ON public.pantry_items FOR ALL USING (auth.uid() = user_id); - --- Policies for the 'flyers' storage bucket --- Allow public read access to the 'flyers' bucket. -DROP POLICY IF EXISTS "Allow public read access to flyers" ON storage.objects; -CREATE POLICY "Allow public read access to flyers" ON storage.objects FOR SELECT USING (bucket_id = 'flyers'); - --- Allow anyone to upload to the 'flyers' bucket. -DROP POLICY IF EXISTS "Allow all access to flyers" ON storage.objects; -CREATE POLICY "Allow all access to flyers" ON storage.objects FOR ALL USING (bucket_id = 'flyers'); - --- Note: This is a simplified policy. A real app would link storage objects to user IDs. - +-- Row Level Security (RLS) policies are removed as they rely on Supabase-specific +-- functions like `auth.uid()`. In a self-hosted environment, authorization +-- should be handled at the application layer (e.g., your backend API ensures +-- a user can only query their own data). -- ============================================================================ -- PART 5: DATABASE FUNCTIONS @@ -618,7 +513,7 @@ RETURNS TABLE ( flyer_valid_to DATE ) LANGUAGE plpgsql -SECURITY INVOKER -- Runs with the privileges of the calling user. RLS policies will apply. +SECURITY INVOKER -- Runs with the privileges of the calling user. AS $$ BEGIN RETURN QUERY @@ -667,7 +562,7 @@ RETURNS TABLE ( unit TEXT ) LANGUAGE plpgsql -SECURITY INVOKER -- Runs with the privileges of the calling user. RLS policies will apply. +SECURITY INVOKER -- Runs with the privileges of the calling user. AS $$ BEGIN RETURN QUERY @@ -954,47 +849,11 @@ $$; -- ============================================================================ -- PART 6: SYSTEM CHECK HELPER FUNCTIONS -- These functions are called by the 'system-check' Edge Function to inspect --- the database state without exposing schema details to the client. They are --- defined as `SECURITY DEFINER` to bypass RLS for inspection purposes. +-- the database state. They are Supabase-specific and not needed for a +-- self-hosted setup. -- ============================================================================ -DROP FUNCTION IF EXISTS public.check_schema(); -CREATE OR REPLACE FUNCTION public.check_schema() -RETURNS json -LANGUAGE sql -SECURITY DEFINER -AS $$ - SELECT json_build_object( - 'tables', (SELECT array_agg(table_name) FROM information_schema.tables WHERE table_schema = 'public') - ); -$$; - -DROP FUNCTION IF EXISTS public.check_rls(); -CREATE OR REPLACE FUNCTION public.check_rls() -RETURNS TABLE(table_name text, policy_name text) -LANGUAGE sql -SECURITY DEFINER -AS $$ - SELECT - tablename::text, - policyname::text - FROM pg_policies - WHERE schemaname = 'public'; -$$; - -DROP FUNCTION IF EXISTS public.check_trigger_security(); -CREATE OR REPLACE FUNCTION public.check_trigger_security() -RETURNS TABLE(function_name text, is_security_definer boolean, owner_role text) -LANGUAGE sql -SECURITY DEFINER -AS $$ - SELECT - p.proname::text, - p.prosecdef, - r.rolname::text - FROM pg_proc p - JOIN pg_roles r ON p.proowner = r.oid - WHERE p.proname = 'handle_new_user'; -$$; +-- The functions `check_schema`, `check_rls`, and `check_trigger_security` +-- have been removed. -- ============================================================================ @@ -1002,29 +861,27 @@ $$; -- ============================================================================ -- 1. Set up the trigger to automatically create a profile when a new user signs up. --- This function will be called by the trigger. --- It is set to SECURITY DEFINER to ensure it can insert into public tables. --- The owner will be 'postgres' (the user running this script), which has an --- RLS policy allowing it to insert. +-- This function is adapted to work with the new `public.users` table. +-- It no longer needs to read from `new.raw_user_meta_data`. CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER AS $$ DECLARE new_profile_id UUID; BEGIN - INSERT INTO public.profiles (id, full_name, avatar_url, role) - VALUES (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url', 'user') + INSERT INTO public.profiles (id, role) + VALUES (new.id, 'user') RETURNING id INTO new_profile_id; -- Also create a default shopping list for the new user. INSERT INTO public.shopping_lists (user_id, name) VALUES (new_profile_id, 'Main Shopping List'); RETURN new; END; -$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql; -- This trigger calls the function after a new user is created. -DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +DROP TRIGGER IF EXISTS on_auth_user_created ON public.users; CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users + AFTER INSERT ON public.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); -- 2. Create a reusable function to automatically update 'updated_at' columns. @@ -1034,7 +891,7 @@ BEGIN NEW.updated_at = now(); RETURN NEW; END; -$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql; -- Apply the trigger to the 'profiles' table. DROP TRIGGER IF EXISTS on_profile_updated ON public.profiles; @@ -1086,7 +943,7 @@ BEGIN RETURN NEW; END; -$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql; -- Create the trigger on the flyer_items table for insert. DROP TRIGGER IF EXISTS trigger_update_price_history ON public.flyer_items; @@ -1150,7 +1007,7 @@ BEGIN RETURN OLD; END; -$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql; -- Create the trigger on the flyer_items table for DELETE operations. DROP TRIGGER IF EXISTS trigger_recalculate_price_history_on_delete ON public.flyer_items; @@ -1178,7 +1035,7 @@ BEGIN RETURN NULL; -- The result is ignored since this is an AFTER trigger. END; -$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql; -- Trigger to call the function after any change to recipe_ratings. DROP TRIGGER IF EXISTS on_recipe_rating_change ON public.recipe_ratings; diff --git a/src/App.tsx b/src/App.tsx index 307ce123..a1963f8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,10 +8,10 @@ import * as pdfjsLib from 'pdfjs-dist'; import { ErrorDisplay } from './components/ErrorDisplay'; import { Header } from './components/Header'; import { logger } from './services/logger'; -import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extractLogoFromImage } from "./services/geminiService"; -import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem } from './types'; +import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extractLogoFromImage } from './services/geminiService'; // prettier-ignore +import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types'; import { BulkImporter } from './components/BulkImporter'; -import { PriceHistoryChart } from './components/PriceHistoryChart'; +import { PriceHistoryChart } from './components/PriceHistoryChart'; // This import seems to have a supabase dependency, but the component is not provided. Assuming it will be updated separately. import { supabase, uploadFlyerImage, createFlyerRecord, saveFlyerItems, getFlyers, getFlyerItems, findFlyerByChecksum, getWatchedItems, addWatchedItem, getAllMasterItems, getFlyerItemsForFlyers, countFlyerItemsForFlyers, getUserProfile, updateUserPreferences, removeWatchedItem, getShoppingLists, createShoppingList, addShoppingListItem, updateShoppingListItem, removeShoppingListItem, deleteShoppingList, uploadLogoAndUpdateStore } from './services/supabaseClient'; import { FlyerList } from './components/FlyerList'; import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer'; @@ -19,16 +19,16 @@ import { ProcessingStatus } from './components/ProcessingStatus'; import { generateFileChecksum } from './utils/checksum'; import { convertPdfToImageFiles } from './utils/pdfConverter'; import { BulkImportSummary } from './components/BulkImportSummary'; -import { WatchedItemsList } from './components/WatchedItemsList'; import { withTimeout } from './utils/timeout'; -import { Session } from '@supabase/supabase-js'; import { ProfileManager } from './components/ProfileManager'; import { ShoppingListComponent } from './components/ShoppingList'; import { VoiceAssistant } from './components/VoiceAssistant'; import { AdminPage } from './pages/AdminPage'; import { AdminRoute } from './components/AdminRoute'; -import { LoginPage } from './components/LoginPage'; import { CorrectionsPage } from './pages/CorrectionsPage'; +import { WatchedItemsList } from './components/WatchedItemsList'; +import { ResetPasswordPage } from './pages/ResetPasswordPage'; +import { getAuthenticatedUserProfile } from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx // Define a more descriptive type for the authentication status. type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; @@ -65,15 +65,15 @@ function App() { const [isReady, setIsReady] = useState(false); const [isDarkMode, setIsDarkMode] = useState(false); const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial'); - const [session, setSession] = useState(null); const [profile, setProfile] = useState(null); const [authStatus, setAuthStatus] = useState('SIGNED_OUT'); - const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); + const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); // This will now control the login modal as well const [isVoiceAssistantOpen, setIsVoiceAssistantOpen] = useState(false); const [processingStages, setProcessingStages] = useState([]); const [estimatedTime, setEstimatedTime] = useState(0); const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null); + const [user, setUser] = useState(null); // Moved here for clarity, already existed. const [shoppingLists, setShoppingLists] = useState([]); const [activeListId, setActiveListId] = useState(null); @@ -119,52 +119,27 @@ function App() { } }, [isDbConnected]); - // This is the login handler that will be passed to the LoginPage component. - const handleLogin = async (email: string, pass: string) => { - if (!supabase) return; + // This is the login handler that will be passed to the ProfileManager component. + const handleLoginSuccess = async (loggedInUser: User, token: string) => { setError(null); // Clear previous errors on a new attempt try { - const { error } = await supabase.auth.signInWithPassword({ email, password: pass }); - if (error) throw error; - // The onAuthStateChange listener will handle the session update. + localStorage.setItem('authToken', token); // Store token + setUser(loggedInUser); + setAuthStatus('AUTHENTICATED'); + // After successful login, fetch the detailed profile from our new backend endpoint. + const userProfile = await getAuthenticatedUserProfile(); + setProfile(userProfile); + logger.info('Login successful', { user: loggedInUser }); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); - logger.error('Login failed', { error: errorMessage }); + logger.error('Failed to fetch profile after login', { error: errorMessage }); setError(errorMessage); } }; - const toggleDarkMode = async () => { - const newMode = !isDarkMode; - setIsDarkMode(newMode); - document.documentElement.classList.toggle('dark', newMode); - - if (session) { - const newPreferences = { ...profile?.preferences, darkMode: newMode }; - setProfile(p => p ? {...p, preferences: newPreferences} : null); - await updateUserPreferences(session.user.id, newPreferences); - } else { - localStorage.setItem('darkMode', String(newMode)); - } - }; - - const toggleUnitSystem = async () => { - const newSystem = unitSystem === 'metric' ? 'imperial' : 'metric'; - setUnitSystem(newSystem); - - if (session) { - // FIX: Explicitly type `newPreferences` to prevent TypeScript from incorrectly widening `newSystem` to a generic `string`. - // This ensures compatibility with the `Profile` type definition. - const newPreferences: Profile['preferences'] = { - ...profile?.preferences, unitSystem: newSystem - }; - setProfile(p => p ? {...p, preferences: newPreferences} : null); - await updateUserPreferences(session.user.id, newPreferences); - } else { - localStorage.setItem('unitSystem', newSystem); - } - }; - + // The toggleDarkMode and toggleUnitSystem functions are now handled within ProfileManager.tsx. + // App.tsx will react to changes in profile.preferences via its useEffects. + // The Header component will no longer receive these props directly. const fetchFlyers = useCallback(async () => { if (!supabase) return; @@ -175,7 +150,7 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(errorMessage); } - }, []); + }, []); // No dependencies on user or profile, as this is general data const fetchWatchedItems = useCallback(async (userId: string | undefined) => { if (!supabase || !userId) { @@ -189,7 +164,7 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not fetch watched items: ${errorMessage}`); } - }, []); + }, []); // No dependencies on user or profile, as this is general data const fetchShoppingLists = useCallback(async (userId: string | undefined) => { if (!supabase || !userId) { @@ -209,7 +184,7 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not fetch shopping lists: ${errorMessage}`); } - }, [activeListId]); + }, [activeListId]); // activeListId is a dependency for managing the active list const fetchMasterItems = useCallback(async () => { if (!supabase) return; @@ -220,65 +195,44 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not fetch master item list: ${errorMessage}`); } - }, []); + }, []); // No dependencies on user or profile, as this is general data - // Effect to handle authentication state changes. + // Effect to check for an existing token on initial app load. useEffect(() => { - if (!isDbConnected || !supabase) return; - - // This function fetches all user-specific data when a session is available. - const fetchRealUserSessionData = async (session: Session | null) => { - setSession(session); - if (session) { - // Differentiate between an anonymous guest and a fully authenticated user. - if (session.user.is_anonymous) { - setAuthStatus('ANONYMOUS'); - logger.info('Auth state change: ANONYMOUS session active.', { userId: session.user.id }); - // For anonymous users, we don't fetch profiles or user-specific data. - setProfile(null); - setWatchedItems([]); - setShoppingLists([]); - } else { - setAuthStatus('AUTHENTICATED'); - logger.info('Auth state change: AUTHENTICATED session active.', { userId: session.user.id }); - const userProfile = await getUserProfile(session.user.id); - setProfile(userProfile); - fetchWatchedItems(session.user.id); - fetchShoppingLists(session.user.id); - } - } else { - setAuthStatus('SIGNED_OUT'); - logger.info('Auth state change: Session is NULL (SIGNED_OUT). Clearing user data.'); - setProfile(null); - setWatchedItems([]); - setShoppingLists([]); + const checkAuthToken = async () => { + const token = localStorage.getItem('authToken'); + if (token) { + logger.info('Found auth token in local storage. Validating...'); + try { + // Call the protected backend route to validate the token and get the full user profile. + const userProfile = await getAuthenticatedUserProfile(); + // The user object is nested within the UserProfile object. + setUser(userProfile.user); + setProfile(userProfile); + setAuthStatus('AUTHENTICATED'); + logger.info('Token validated successfully.', { user: userProfile.user }); + } catch (e) { + logger.warn('Auth token validation failed. Clearing token.', { error: e }); + localStorage.removeItem('authToken'); + setUser(null); + setAuthStatus('SIGNED_OUT'); } + } else { + logger.info('No auth token found. User is signed out.'); + setAuthStatus('SIGNED_OUT'); + } }; - - // The onAuthStateChange listener provides a single, reliable stream for all auth events. - // It correctly handles the initial session, sign-ins, and sign-outs. - const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { - logger.info(`Supabase auth event received: ${event}`, { hasSession: !!session }); - if (event === "SIGNED_OUT") { - setIsProfileManagerOpen(false); - } - fetchRealUserSessionData(session); - }); - - return () => subscription.unsubscribe(); - }, [isDbConnected, fetchWatchedItems, fetchShoppingLists]); - - // Effect to handle the post-signup redirect. - // This is more reliable than checking inside onAuthStateChange, which can re-run. - useEffect(() => { - const hash = window.location.hash; - // If the URL contains '#...' and 'type=signup', it's a confirmation link click. - if (hash && hash.includes('type=signup')) { - logger.info("New user confirmed email, opening profile manager."); - setIsProfileManagerOpen(true); - } + checkAuthToken(); }, []); + // Effect to fetch user-specific data once authenticated. + useEffect(() => { + if (authStatus === 'AUTHENTICATED' && user) { + fetchWatchedItems(user.id); + fetchShoppingLists(user.id); + } + }, [authStatus, user, fetchWatchedItems, fetchShoppingLists]); + useEffect(() => { if (isReady && isDbConnected) { @@ -669,9 +623,9 @@ function App() { }, [resetState, fetchFlyers, masterItems, fetchMasterItems]); const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => { - if (!supabase || !session) return; + if (!supabase || !user) return; try { - const updatedOrNewItem = await addWatchedItem(session.user.id, itemName, category); + const updatedOrNewItem = await addWatchedItem(user.id, itemName, category); setWatchedItems(prevItems => { const itemExists = prevItems.some(item => item.id === updatedOrNewItem.id); if (!itemExists) { @@ -683,37 +637,36 @@ function App() { } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not add watched item: ${errorMessage}`); - await fetchWatchedItems(session?.user?.id); + await fetchWatchedItems(user?.id); } - }, [session, fetchWatchedItems]); + }, [user, fetchWatchedItems]); const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => { - if (!supabase || !session) return; + if (!supabase || !user) return; try { - await removeWatchedItem(session.user.id, masterItemId); + await removeWatchedItem(user.id, masterItemId); setWatchedItems(prevItems => prevItems.filter(item => item.id !== masterItemId)); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not remove watched item: ${errorMessage}`); } - }, [session]); + }, [user]); // --- Shopping List Handlers --- const handleCreateList = useCallback(async (name: string) => { - if (!session) return; + if (!user) return; try { - const newList = await createShoppingList(session.user.id, name); + const newList = await createShoppingList(user.id, name); setShoppingLists(prev => [...prev, newList]); setActiveListId(newList.id); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not create list: ${errorMessage}`); } - }, [session]); - + }, [user]); // Changed dependency from `session` to `user` const handleDeleteList = useCallback(async (listId: number) => { - if (!session) return; - try { + if (!user) return; // `user` is correctly used here + try { // The user variable is not used here, but the check is important for authorization context await deleteShoppingList(listId); const newLists = shoppingLists.filter(l => l.id !== listId); setShoppingLists(newLists); @@ -724,11 +677,11 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not delete list: ${errorMessage}`); } - }, [session, shoppingLists, activeListId]); + }, [user, shoppingLists, activeListId]); const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => { - if (!session) return; - try { + if (!user) return; // `user` is correctly used here + try { // The user variable is not used here, but the check is important for authorization context const newItem = await addShoppingListItem(listId, item); setShoppingLists(prevLists => prevLists.map(list => { if (list.id === listId) { @@ -743,11 +696,11 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not add item to list: ${errorMessage}`); } - }, [session]); + }, [user, activeListId]); // Added activeListId to dependencies const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial) => { - if (!session || !activeListId) return; - try { + if (!user || !activeListId) return; // `user` is correctly used here + try { // The user variable is not used here, but the check is important for authorization context const updatedItem = await updateShoppingListItem(itemId, updates); setShoppingLists(prevLists => prevLists.map(list => { if (list.id === activeListId) { @@ -759,11 +712,12 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not update list item: ${errorMessage}`); } - }, [session, activeListId]); + }, [user, activeListId]); // Changed dependency from `session` to `user` const handleRemoveShoppingListItem = useCallback(async (itemId: number) => { - if (!session || !activeListId) return; + if (!user || !activeListId) return; // Changed check from `session` to `user` try { + // The user variable is not used here, but the check is important for authorization context await removeShoppingListItem(itemId); setShoppingLists(prevLists => prevLists.map(list => { if (list.id === activeListId) { @@ -775,13 +729,13 @@ function App() { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not remove list item: ${errorMessage}`); } - }, [session, activeListId]); + }, [user, activeListId]); // Changed dependency from `session` to `user` const handleSignOut = () => { - if (supabase) { - supabase.auth.signOut(); - } - setSession(null); + localStorage.removeItem('authToken'); // Remove the JWT token + setUser(null); // Clear the user state + setProfile(null); // Clear the profile state + setAuthStatus('SIGNED_OUT'); // Update auth status }; @@ -790,30 +744,32 @@ function App() { return (
setIsProfileManagerOpen(true)} onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)} onSignOut={handleSignOut} /> - {/* CRITICAL FIX: Only render the ProfileManager if a session AND a profile exist. This prevents crashes on initial load when profile is null. */} - {isProfileManagerOpen && session && profile && ( + {/* The ProfileManager is now always available to be opened, handling both login and profile management. */} + {isProfileManagerOpen && ( setIsProfileManagerOpen(false)} - session={session} + user={user} authStatus={authStatus} profile={profile} - onProfileUpdate={(updatedProfile) => setProfile(updatedProfile)} + onProfileUpdate={setProfile} + onLoginSuccess={handleLoginSuccess} + onSignOut={handleSignOut} // Pass the signOut handler /> )} - {session && ( + {user && ( setIsVoiceAssistantOpen(false)} @@ -865,7 +821,7 @@ function App() { watchedItems={watchedItems} masterItems={masterItems} unitSystem={unitSystem} - session={session} + user={user} onAddItem={handleAddWatchedItem} shoppingLists={shoppingLists} activeListId={activeListId} @@ -888,8 +844,8 @@ function App() {
{isDbConnected && ( <> - handleAddShoppingListItem(activeListId!, { masterItemId })} /> @@ -911,7 +867,7 @@ function App() { deals={activeDeals} isLoading={activeDealsLoading} unitSystem={unitSystem} - session={session} + user={user} /> @@ -924,10 +880,7 @@ function App() { } /> } /> - {/* Add a dedicated login route for when there is no session */} - {!session && ( - setError(null)} error={error} />} /> - )} + } />
); diff --git a/src/components/AdminPage.tsx b/src/components/AdminPage.tsx index 435d712d..a722fcb2 100644 --- a/src/components/AdminPage.tsx +++ b/src/components/AdminPage.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; export const AdminPage: React.FC = () => { return ( -
+
← Back to Main App

Admin Dashboard

diff --git a/src/components/DatabaseControls.tsx b/src/components/DatabaseControls.tsx deleted file mode 100644 index a8d6da80..00000000 --- a/src/components/DatabaseControls.tsx +++ /dev/null @@ -1,108 +0,0 @@ - -import React, { useState, useEffect, useCallback } from 'react'; -import { supabase, runDatabaseSelfTest, testStorageConnection } from '../services/supabaseClient'; -import { ServerIcon } from './icons/ServerIcon'; -import { LoadingSpinner } from './LoadingSpinner'; - -type TestStatus = 'idle' | 'testing' | 'success' | 'error'; - -interface DatabaseControlsProps { - onReady: () => void; -} - -export const DatabaseControls: React.FC = ({ onReady }) => { - const [status, setStatus] = useState('idle'); - const [message, setMessage] = useState(''); - const [hasRunAutoTest, setHasRunAutoTest] = useState(false); - - const handleTestConnection = useCallback(async () => { - setStatus('testing'); - setMessage(''); - - try { - // Test 1: Full Database CRUD Self-Test - const dbResult = await runDatabaseSelfTest(); - if (!dbResult.success) { - setStatus('error'); - setMessage(dbResult.error || 'An unknown database error occurred.'); - return; - } - - // Test 2: Storage Write/Delete - const storageResult = await testStorageConnection(); - if (!storageResult.success) { - setStatus('error'); - setMessage(storageResult.error || 'An unknown storage error occurred.'); - return; - } - - // All tests passed - setStatus('success'); - setMessage('Connection successful! Database and Storage are working correctly.'); - - // Reset after a few seconds if it was a manual test - setTimeout(() => { - if (status !== 'testing') { - setStatus('idle'); - setMessage(''); - } - }, 8000); - } finally { - // This is the crucial step: always signal readiness after the test sequence completes. - onReady(); - } - }, [status, onReady]); - - // Auto-run the test once on initial connection - useEffect(() => { - if (supabase && !hasRunAutoTest) { - setHasRunAutoTest(true); - handleTestConnection(); - } - }, [supabase, hasRunAutoTest, handleTestConnection]); - - - if (!supabase) { - return null; // Don't render anything if Supabase is not configured - } - - const statusText = status === 'success' ? 'OK' : status === 'error' ? 'Error' : status === 'testing' ? 'Testing...' : 'Connected'; - const statusColor = status === 'success' ? 'text-green-600 dark:text-green-400' - : status === 'error' ? 'text-red-600 dark:text-red-400' - : 'text-gray-600 dark:text-gray-400'; - - return ( -
-

- - Backend Status -

-

- Status: {statusText}. The self-test checks all database permissions. -

- - {message && ( -
- {message} -
- )} -
- ); -}; diff --git a/src/components/DatabaseSeeder.tsx b/src/components/DatabaseSeeder.tsx deleted file mode 100644 index bb7f7f76..00000000 --- a/src/components/DatabaseSeeder.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useState } from 'react'; -import { invokeSeedDatabaseFunction } from '../services/supabaseClient'; -import { SparklesIcon } from './icons/SparklesIcon'; -import { LoadingSpinner } from './LoadingSpinner'; - -interface DatabaseSeederProps { - onSuccess: () => void; -} - -export const DatabaseSeeder: React.FC = ({ onSuccess }) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [message, setMessage] = useState(null); - - const handleSeed = async () => { - setIsLoading(true); - setError(null); - setMessage(null); - - try { - const result = await invokeSeedDatabaseFunction(); - setMessage(result.message); - // Wait a moment for the success message to be readable, then trigger re-check - setTimeout(() => { - onSuccess(); - }, 2500); - } catch (e) { - // This is a type-safe way to handle errors. We check if the caught - // object is an instance of Error before accessing its message property. - const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred during database seeding.'; - setError(errorMessage); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-
-
-
-

- It looks like the development users are missing. Use this tool to create them. -

-
- - {error && ( -

{error}

- )} - {message && ( -

{message}

- )} -
-
-
-
- ); -}; diff --git a/src/components/DevTestRunner.tsx b/src/components/DevTestRunner.tsx deleted file mode 100644 index 753c050e..00000000 --- a/src/components/DevTestRunner.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useState } from 'react'; -import { supabase } from '../services/supabaseClient'; -import { BeakerIcon } from './icons/BeakerIcon'; -import { LoadingSpinner } from './LoadingSpinner'; -import { CheckCircleIcon } from './icons/CheckCircleIcon'; -import { XCircleIcon } from './icons/XCircleIcon'; - -type TestStatus = 'idle' | 'running' | 'pass' | 'fail'; - -interface TestResult { - name: string; - status: TestStatus; - message: string; -} - -const initialTests: TestResult[] = [ - { name: 'Test Admin Login', status: 'idle', message: 'Verifies the seeded admin user can log in.' }, -]; - -export const DevTestRunner: React.FC = () => { - const [testResults, setTestResults] = useState(initialTests); - const [isRunning, setIsRunning] = useState(false); - - const runTests = async () => { - setIsRunning(true); - // Reset statuses to running - setTestResults(prev => prev.map(t => ({ ...t, status: 'running' }))); - - // --- Test Case 1: Admin Login --- - try { - const { error } = await supabase.auth.signInWithPassword({ - email: 'admin@example.com', - password: 'password123', - }); - - if (error) { - throw new Error(error.message); - } - - // IMPORTANT: Sign out immediately so the test doesn't affect the app's state - await supabase.auth.signOut(); - - setTestResults(prev => prev.map(t => t.name === 'Test Admin Login' - ? { ...t, status: 'pass', message: 'Successfully logged in and out.' } - : t - )); - } catch (e) { - // This is a type-safe way to handle errors. We check if the caught - // object is an instance of Error before accessing its message property. - const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred during the admin login test.'; - setTestResults(prev => prev.map(t => t.name === 'Test Admin Login' - ? { ...t, status: 'fail', message: `Failed: ${errorMessage}` } - : t - )); - } - - setIsRunning(false); - }; - - const getStatusIndicator = (status: TestStatus) => { - switch (status) { - case 'running': return
; - case 'pass': return ; - case 'fail': return ; - case 'idle': return
; - } - }; - - if (!supabase) { - return null; - } - - return ( -
-

- - Development Test Runner -

-

- Run integration tests to verify your backend setup. -

- -
    - {testResults.map(test => ( -
  • -
    {getStatusIndicator(test.status)}
    -
    -

    {test.name}

    -

    - {test.message} -

    -
    -
  • - ))} -
- - -
- ); -}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 7e17cac6..8cc11800 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,25 +1,17 @@ import React, { useState } from 'react'; import { ShoppingCartIcon } from './icons/ShoppingCartIcon'; -import { DarkModeToggle } from './DarkModeToggle'; -import { UnitSystemToggle } from './UnitSystemToggle'; -import { Session } from '@supabase/supabase-js'; -import { supabase } from '../services/supabaseClient'; -import { AuthModal } from './AuthModal'; -import { SignUpModal } from './SignUpModal'; import { UserIcon } from './icons/UserIcon'; import { Cog8ToothIcon } from './icons/Cog8ToothIcon'; import { MicrophoneIcon } from './icons/MicrophoneIcon'; import { Link } from 'react-router-dom'; import { ShieldCheckIcon } from './icons/ShieldCheckIcon'; -import { Profile } from '../types'; +import { Profile, User } from '../types'; type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; interface HeaderProps { isDarkMode: boolean; - toggleDarkMode: () => void; unitSystem: 'metric' | 'imperial'; - toggleUnitSystem: () => void; - session: Session | null; + user: User | null; authStatus: AuthStatus; profile: Profile | null; onOpenProfile: () => void; @@ -27,21 +19,8 @@ interface HeaderProps { onSignOut: () => void; } -export const Header: React.FC = ({ isDarkMode, toggleDarkMode, unitSystem, toggleUnitSystem, session, authStatus, profile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => { - const [isSignInModalOpen, setIsSignInModalOpen] = useState(false); - const [isSignUpModalOpen, setIsSignUpModalOpen] = useState(false); - - const openSignIn = () => { - setIsSignUpModalOpen(false); - setIsSignInModalOpen(true); - }; - - const openSignUp = () => { - setIsSignInModalOpen(false); - setIsSignUpModalOpen(true); - }; - - +export const Header: React.FC = ({ isDarkMode, unitSystem, user, authStatus, profile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => { + // The state and handlers for the old AuthModal and SignUpModal have been removed. return ( <>
@@ -54,7 +33,7 @@ export const Header: React.FC = ({ isDarkMode, toggleDarkMode, unit
- {session && ( + {user && ( )} - - + {/* The toggles have been removed. The display of the current state is now shown textually. */} +
+ {unitSystem} + | + {isDarkMode ? 'Dark' : 'Light'} Mode +
+
- {session ? ( + {user ? ( // This ternary was missing a 'null' or alternative rendering path for when 'user' is not present.
{authStatus === 'AUTHENTICATED' ? ( - {session.user.email} + // Use the user object from the new auth system + {user.email} ) : ( Guest )} @@ -90,14 +75,6 @@ export const Header: React.FC = ({ isDarkMode, toggleDarkMode, unit )} -
) : ( -
- - -
+ // When no user is logged in, show a Login button. + )}
- {isSignInModalOpen && supabase && ( - setIsSignInModalOpen(false)} - onSwitchToSignUp={openSignUp} - /> - )} - {isSignUpModalOpen && supabase && ( - setIsSignUpModalOpen(false)} onSwitchToSignIn={openSignIn} /> - )} ); }; \ No newline at end of file diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx deleted file mode 100644 index 2d7f30ed..00000000 --- a/src/components/LoginPage.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useState } from 'react'; -import { ShoppingCartIcon } from './icons/ShoppingCartIcon'; -import { LoadingSpinner } from './LoadingSpinner'; - -import { EyeIcon } from './icons/EyeIcon'; -import { EyeSlashIcon } from './icons/EyeSlashIcon'; -interface LoginPageProps { - onLogin: (email: string, pass: string) => void; - onClearError: () => void; - error: string | null; -} - -export const LoginPage: React.FC = ({ onLogin, onClearError, error }) => { - const [email, setEmail] = useState('test@test.com'); - const [password, setPassword] = useState('pass123'); - const [isLoading, setIsLoading] = useState(false); - const [showPassword, setShowPassword] = useState(false); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - // Simulate network delay - setTimeout(() => { - onLogin(email, password); - setIsLoading(false); - }, 500); - }; - - const handleEmailChange = (e: React.ChangeEvent) => { - // When the user starts typing, clear any previous login errors. - onClearError(); - setEmail(e.target.value); - }; - - const handlePasswordChange = (e: React.ChangeEvent) => { - // When the user starts typing, clear any previous login errors. - onClearError(); - setPassword(e.target.value); - }; - - return ( -
-
-
- -
-

- Sign in to Flyer Crawler -

-

- Use test@test.com and pass123 -

-
- -
-
-
- -
- -
-
- -
-
- -
-
- - -
-
- - {error && ( -
- {error} -
- )} - -
- -
-
-
-
- ); -}; diff --git a/src/components/ProfileManager.tsx b/src/components/ProfileManager.tsx index 8fbf2c41..e8ae0788 100644 --- a/src/components/ProfileManager.tsx +++ b/src/components/ProfileManager.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { Session } from '@supabase/supabase-js'; import type { Profile } from '../types'; -import { supabase, updateUserProfile, updateUserPassword, exportUserData, deleteUserAccount } from '../services/supabaseClient'; +import { supabase, updateUserProfile, exportUserData } from '../services/supabaseClient'; +import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset } from '../services/apiClient'; import { logger } from '../services/logger'; import { LoadingSpinner } from './LoadingSpinner'; import { XMarkIcon } from './icons/XMarkIcon'; @@ -10,18 +10,21 @@ import { GithubIcon } from './icons/GithubIcon'; import { ConfirmationModal } from './ConfirmationModal'; import { EyeIcon } from './icons/EyeIcon'; import { EyeSlashIcon } from './icons/EyeSlashIcon'; +import { User } from '../types'; // Import User type for props type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; interface ProfileManagerProps { isOpen: boolean; onClose: () => void; - session: Session; + user: User | null; // Can be null for login/register authStatus: AuthStatus; - profile: Profile; + profile: Profile | null; // Can be null for login/register onProfileUpdate: (updatedProfile: Profile) => void; + onSignOut: () => void; + onLoginSuccess: (user: User, token: string) => void; // Add login handler } -export const ProfileManager: React.FC = ({ isOpen, onClose, session, profile, onProfileUpdate }) => { +export const ProfileManager: React.FC = ({ isOpen, onClose, user, authStatus, profile, onProfileUpdate, onSignOut, onLoginSuccess }) => { const [activeTab, setActiveTab] = useState('profile'); // Profile state @@ -46,11 +49,20 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(''); + // Login/Register State + const [isRegistering, setIsRegistering] = useState(false); + const [authEmail, setAuthEmail] = useState(''); + const [authPassword, setAuthPassword] = useState(''); + const [authLoading, setAuthLoading] = useState(false); + const [authError, setAuthError] = useState(''); + const [isForgotPassword, setIsForgotPassword] = useState(false); + const [resetMessage, setResetMessage] = useState(''); + useEffect(() => { // Only reset state when the modal is opened. // Do not reset on profile changes, which can happen during sign-out. - if (isOpen) { + if (isOpen && profile) { // Ensure profile exists before setting state setFullName(profile?.full_name || ''); setAvatarUrl(profile?.avatar_url || ''); setActiveTab('profile'); @@ -60,24 +72,33 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, setPasswordError(''); setPasswordMessage(''); setShowPassword(false); + setAuthEmail(''); + setAuthPassword(''); + setAuthError(''); + setIsRegistering(false); + setIsForgotPassword(false); + setResetMessage(''); } - }, [isOpen]); // Only depend on isOpen + }, [isOpen, profile]); // Depend on isOpen and profile const handleProfileSave = async (e: React.FormEvent) => { e.preventDefault(); setProfileLoading(true); setProfileMessage(''); try { - const updatedProfile = await updateUserProfile(session.user.id, { + if (!user) { + throw new Error("Cannot save profile, no user is logged in."); + } + const updatedProfile = await updateUserProfile(user.id, { // Use user.id from props full_name: fullName, avatar_url: avatarUrl }); onProfileUpdate(updatedProfile); - logger.info('User profile updated successfully.', { userId: session.user.id, fullName, avatarUrl }); + logger.info('User profile updated successfully.', { userId: user.id, fullName, avatarUrl }); setProfileMessage('Profile updated successfully!'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; - logger.error('Failed to update user profile.', { userId: session.user.id, error: errorMessage }); + logger.error('Failed to update user profile.', { userId: user.id, error: errorMessage }); setProfileMessage(errorMessage); } finally { setProfileLoading(false); @@ -87,8 +108,11 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const handleOAuthLink = async (provider: 'google' | 'github') => { // This will redirect the user to the OAuth provider to link the account. + if (!user) { + return; // Should not be possible to see this button if not logged in + } // After successful linking, they will be redirected back to the app. - const { error } = await supabase.auth.linkIdentity({ + const { error } = await supabase?.auth.linkIdentity({ // Use optional chaining for supabase provider, options: { redirectTo: window.location.href, @@ -96,7 +120,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, }); if (error) { // This error will be shown if the user cancels or if there's a config issue. - logger.error(`Could not link ${provider} account.`, { userId: session.user.id, error: error.message }); + logger.error(`Could not link ${provider} account.`, { userId: user.id, error: error.message }); setPasswordError(`Could not link ${provider} account: ${error.message}`); } }; @@ -115,14 +139,17 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, setPasswordError(''); setPasswordMessage(''); try { - await updateUserPassword(password); - logger.info('User password updated successfully.', { userId: session.user.id }); + if (!user) { + throw new Error("Cannot update password, no user is logged in."); + } + await updateUserPassword(password); // This now uses the new apiClient function + logger.info('User password updated successfully.', { userId: user.id }); setPasswordMessage("Password updated successfully!"); setPassword(''); setConfirmPassword(''); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; - logger.error('Failed to update user password.', { userId: session.user.id, error: errorMessage }); + logger.error('Failed to update user password.', { userId: user.id, error: errorMessage }); setPasswordError(errorMessage); } finally { setPasswordLoading(false); @@ -136,8 +163,11 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const handleExportData = async () => { setExportLoading(true); try { - logger.info('User initiated data export.', { userId: session.user.id }); - const userData = await exportUserData(session.user.id); + if (!user) { + throw new Error("Cannot export data, no user is logged in."); + } + logger.info('User initiated data export.', { userId: user.id }); + const userData = await exportUserData(user.id); const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`; const link = document.createElement("a"); link.href = jsonString; @@ -145,7 +175,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, link.click(); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; - logger.error("Failed to export user data:", { userId: session.user.id, error: errorMessage }); + logger.error("Failed to export user data:", { userId: user.id, error: errorMessage }); alert(`Error exporting data: ${errorMessage}`); } finally { setExportLoading(false); @@ -158,30 +188,139 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, setDeleteError(''); // CRITICAL: Prevent anonymous users from attempting to delete their account. - if (session.user.is_anonymous) { + // Note: The `is_anonymous` property is specific to Supabase's auth. + // For a Passport.js setup, you might check for a specific role or if the user object is a guest. + // Assuming `user` from props represents a full user if authStatus is AUTHENTICATED. The `authStatus` prop is correctly destructured from props. + if (authStatus === 'ANONYMOUS') { // Using authStatus from props setDeleteError("Cannot delete an anonymous guest account. Please sign up for a full account first."); setDeleteLoading(false); return; } setDeleteLoading(true); try { - logger.warn('ProfileManager: handleDeleteAccount function has been called.'); - logger.warn('User initiated account deletion.', { userId: session.user.id }); + if (!user) { + throw new Error("Cannot delete account, no user is logged in."); + } + logger.warn('ProfileManager: handleDeleteAccount function has been called for user:', { userId: user.id }); + logger.warn('User initiated account deletion.', { userId: user.id }); await deleteUserAccount(passwordForDelete); - alert("Your account and all associated data have been permanently deleted."); - // The onAuthStateChange listener in App.tsx will handle the UI update - await supabase.auth.signOut(); + alert("Your account and all associated data have been permanently deleted. You will now be logged out."); onClose(); - logger.warn('User account deleted successfully.', { userId: session.user.id }); + onSignOut(); // Call the sign out handler from App.tsx + logger.warn('User account deleted successfully.', { userId: user.id }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; - logger.error('ProfileManager: Account deletion failed.', { error: errorMessage, stack: (error as Error).stack }); + logger.error('ProfileManager: Account deletion failed for user:', { userId: user.id, error: errorMessage, stack: (error as Error).stack }); setDeleteError(errorMessage); } finally { setDeleteLoading(false); } }; + const handleToggleDarkMode = async (newMode: boolean) => { + try { + if (!user) { + throw new Error("Cannot update preferences, no user is logged in."); + } + // Call the API client function to update preferences + const updatedProfile = await updateUserPreferences({ darkMode: newMode }); + // Notify parent component (App.tsx) to update its profile state + onProfileUpdate(updatedProfile); + logger.info('Dark mode preference updated.', { userId: user.id, darkMode: newMode }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logger.error('Failed to update dark mode preference:', { userId: user.id, error: errorMessage }); + // Optionally, show an error message to the user + setProfileMessage(`Failed to update dark mode: ${errorMessage}`); + setTimeout(() => setProfileMessage(''), 3000); + } + }; + + const handleToggleUnitSystem = async (newSystem: 'metric' | 'imperial') => { + try { + if (!user) { + throw new Error("Cannot update preferences, no user is logged in."); + } + // Call the API client function to update preferences + const updatedProfile = await updateUserPreferences({ unitSystem: newSystem }); + // Notify parent component (App.tsx) to update its profile state + onProfileUpdate(updatedProfile); + logger.info('Unit system preference updated.', { userId: user.id, unitSystem: newSystem }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logger.error('Failed to update unit system preference:', { userId: user.id, error: errorMessage }); + // Optionally, show an error message to the user + setProfileMessage(`Failed to update unit system: ${errorMessage}`); + setTimeout(() => setProfileMessage(''), 3000); + } + }; + + const handleAuthSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthLoading(true); + setAuthError(''); + try { + let response; + if (isRegistering) { + response = await registerUser(authEmail, authPassword); + logger.info('New user registration successful.', { email: authEmail }); + } else { + response = await loginUser(authEmail, authPassword); + } + onLoginSuccess(response.user, response.token); + onClose(); // Close modal on success + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; + setAuthError(errorMessage); + } finally { + setAuthLoading(false); + } + }; + + const handlePasswordResetRequest = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthLoading(true); + setAuthError(''); + setResetMessage(''); + try { + const response = await requestPasswordReset(authEmail); + setResetMessage(response.message); + logger.info('Password reset email sent successfully.', { email: authEmail }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; + setAuthError(errorMessage); + logger.error('Password reset request failed.', { email: authEmail, error: errorMessage }); + } finally { + setAuthLoading(false); + } + }; + + const handleOAuthSignIn = async (provider: 'google' | 'github') => { + setAuthLoading(true); + setAuthError(''); + try { + // Supabase handles the redirection to the OAuth provider and back to your app. + // App.tsx's useEffect will then detect the new session and call onLoginSuccess indirectly. + const { error } = await supabase.auth.signInWithOAuth({ + provider: provider, + options: { + redirectTo: window.location.origin, // Redirect back to the app's root URL + }, + }); + + if (error) { + logger.error(`OAuth sign-in failed for ${provider}:`, { error: error.message }); + setAuthError(`Failed to sign in with ${provider}: ${error.message}`); + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'An unexpected error occurred.'; + logger.error(`Unexpected error during OAuth sign-in with ${provider}:`, { error: errorMessage }); + setAuthError(`An unexpected error occurred during sign-in with ${provider}.`); + } finally { + setAuthLoading(false); // This might not be reached if a redirect happens immediately + } + }; + if (!isOpen) return null; return ( @@ -216,143 +355,280 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, -
-

My Account

-

Manage your profile, preferences, and security.

- -
- -
- - {activeTab === 'profile' && ( -
-
- - setFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> -
-
- - setAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> -
-
- - {profileMessage &&

{profileMessage}

} -
-
- )} - - {activeTab === 'security' && ( -
-
- -
- setPassword(e.target.value)} placeholder="••••••••" required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> - + {authStatus === 'SIGNED_OUT' ? ( + isForgotPassword ? ( +
+

Reset Password

+

Enter your email to receive a password reset link.

+ +
+ + setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
-
-
- -
- setConfirmPassword(e.target.value)} placeholder="••••••••" required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> - + {authError &&

{authError}

} + {resetMessage &&

{resetMessage}

}
-
-
- - {passwordError &&

{passwordError}

} - {passwordMessage &&

{passwordMessage}

} -
- -
-

Link Social Accounts

-

Connect other accounts for easier sign-in.

-
- - -
-
- - )} - - {activeTab === 'data' && ( -
-
-

Export Your Data

-

Download a JSON file of your profile, watched items, and shopping lists.

- -
- -
- -
-

Danger Zone

-

This action is permanent and cannot be undone. All your data will be erased.

- - {!isConfirmingDelete ? ( - - ) : ( -
{ e.preventDefault(); setIsDeleteModalOpen(true); }} className="mt-4 space-y-3 bg-white dark:bg-gray-800 p-4 rounded-md border border-red-500/50"> -

To confirm, please enter your current password.

-
- -
- setPasswordForDelete(e.target.value)} - required - placeholder="Enter your password" - className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" - /> - -
-
- {deleteError &&

{deleteError}

} -
- - -
-
- )}
- )} -
+ ) : ( +
+

{isRegistering ? 'Create an Account' : 'Sign In'}

+

{isRegistering ? 'to get started.' : 'to access your account.'}

+
+
+ + setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> +
+
+ +
+ setAuthPassword(e.target.value)} required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> + +
+
+ {!isRegistering && ( +
+ +
+ )} +
+ + {authError &&

{authError}

} +
+
+
+ +
+
+ +
+ + +
+ {authError && ( + // Display auth errors from OAuth attempts as well +

{authError}

+ )} + +
+ ) + ) : ( + // Wrap the authenticated view in a React Fragment to return a single element + <> +
+

My Account

+

Manage your profile, preferences, and security.

+ +
+ +
+ + {activeTab === 'profile' && ( +
+
+ + setFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> +
+
+ + setAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> +
+
+ + {profileMessage &&

{profileMessage}

} +
+
+ )} + + {activeTab === 'security' && ( +
+
+ +
+ setPassword(e.target.value)} placeholder="••••••••" required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> + +
+
+
+ +
+ setConfirmPassword(e.target.value)} placeholder="••••••••" required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> + +
+
+
+ + {passwordError &&

{passwordError}

} + {passwordMessage &&

{passwordMessage}

} +
+ +
+

Link Social Accounts

+

Connect other accounts for easier sign-in.

+
+ + +
+
+
+ )} + + {activeTab === 'data' && ( +
+
+

Export Your Data

+

Download a JSON file of your profile, watched items, and shopping lists.

+ +
+ +
+ +
+

Danger Zone

+

This action is permanent and cannot be undone. All your data will be erased.

+ + {!isConfirmingDelete ? ( + + ) : ( +
{ e.preventDefault(); setIsDeleteModalOpen(true); }} className="mt-4 space-y-3 bg-white dark:bg-gray-800 p-4 rounded-md border border-red-500/50"> +

To confirm, please enter your current password.

+
+ +
+ setPasswordForDelete(e.target.value)} + required + placeholder="Enter your password" + className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" + /> + +
+
+ {deleteError &&

{deleteError}

} +
+ + +
+
+ )} +
+
+ )} + + {activeTab === 'preferences' && ( +
+
+

Theme

+

Choose your preferred visual theme.

+
+ +
+
+ +
+

Unit System

+

Select your preferred system of measurement.

+
+ + +
+
+ {profileMessage &&

{profileMessage}

} +
+ )} +
+ + )}
); diff --git a/src/components/SignUpModal.tsx b/src/components/SignUpModal.tsx deleted file mode 100644 index 50902ef1..00000000 --- a/src/components/SignUpModal.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useState } from 'react'; -import { supabase } from '../services/supabaseClient'; -import { LoadingSpinner } from './LoadingSpinner'; -import { XMarkIcon } from './icons/XMarkIcon'; -import { GoogleIcon } from './icons/GoogleIcon'; -import { GithubIcon } from './icons/GithubIcon'; -import { PasswordStrength } from './PasswordStrength'; -import { EyeIcon } from './icons/EyeIcon'; -import { EyeSlashIcon } from './icons/EyeSlashIcon'; - -interface SignUpModalProps { - isOpen: boolean; - onClose: () => void; - onSwitchToSignIn: () => void; -} - -export const SignUpModal: React.FC = ({ isOpen, onClose, onSwitchToSignIn }) => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [message, setMessage] = useState(null); - const [showPassword, setShowPassword] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - // Add password confirmation check - if (password !== confirmPassword) { - setError("Passwords do not match."); - setLoading(false); - return; - } - setError(null); - setMessage(null); - - // Check if there is a current anonymous session - const { data: { session } } = await supabase.auth.getSession(); - - if (session && session.user.is_anonymous) { - // If the user is anonymous, upgrade their account instead of creating a new one. - try { - const { error } = await supabase.auth.updateUser({ email, password }); - if (error) throw error; - setMessage('Your account has been created! Check your email for a confirmation link.'); - } catch (err) { - // We perform a type check to ensure 'err' is an error object before accessing its message property. - // This provides type safety without using 'any' or 'unknown'. - const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred during account upgrade.'; - setError(errorMessage); - } finally { - setLoading(false); - } - } else { - // Standard sign-up flow for new users. - try { - const { error } = await supabase.auth.signUp({ - email, - password, - options: { emailRedirectTo: window.location.href } - }); - if (error) throw error; - setMessage('Check your email for the confirmation link!'); - } catch (err) { - // We perform a type check to ensure 'err' is an error object before accessing its message property. - const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred.'; - setError(errorMessage); - } finally { - setLoading(false); - } - } - }; - - const handleOAuthSignIn = async (provider: 'google' | 'github') => { - setLoading(true); - setError(null); - const { error } = await supabase.auth.signInWithOAuth({ - provider, - options: { - redirectTo: window.location.href, - } - }); - if (error) { - setError(error.message); - setLoading(false); - } - }; - - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} - > - - -
-

- Create an Account -

-

- to start personalizing your experience. -

- -
- - -
- -
-
- OR -
-
- -
-
- - setEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary" placeholder="you@example.com" /> -
-
- -
- setPassword(e.target.value)} required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary" placeholder="••••••••" /> - -
- {password.length > 0 && } -
-
- -
- setConfirmPassword(e.target.value)} required className="block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary" placeholder="••••••••" /> - -
-
- {error &&

{error}

} - {message &&

{message}

} - -
-

- Already have an account? -

-
-
-
-
-
- ); -}; \ No newline at end of file diff --git a/src/components/SupabaseConnector.tsx b/src/components/SupabaseConnector.tsx deleted file mode 100644 index 9b91a84b..00000000 --- a/src/components/SupabaseConnector.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState } from 'react'; -import { initializeSupabase, testDatabaseConnection, disconnectSupabase } from '../services/supabaseClient'; -import { PlugIcon } from './icons/PlugIcon'; -import { LoadingSpinner } from './LoadingSpinner'; - -interface SupabaseConnectorProps { - onSuccess: () => void; -} - -export const SupabaseConnector: React.FC = ({ onSuccess }) => { - const [url, setUrl] = useState(''); - const [anonKey, setAnonKey] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleConnect = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(null); - - const client = initializeSupabase(url, anonKey); - if (!client) { - setError("Failed to initialize client. Check credentials."); - setIsLoading(false); - return; - } - - const { success, error: testError } = await testDatabaseConnection(); - - if (success) { - localStorage.setItem('supabaseUrl', url); - localStorage.setItem('supabaseAnonKey', anonKey); - onSuccess(); - } else { - setError(testError || 'Connection failed. Please check your URL, Key, and RLS policies.'); - disconnectSupabase(); // Clear the invalid client - } - - setIsLoading(false); - }; - - return ( -
-

- - Connect to Database -

-

- To save and view flyer history, connect to your Supabase project. This is optional. -

-
-
- - setUrl(e.target.value)} - required - className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" - placeholder="https://your-project.supabase.co" - /> -
-
- - setAnonKey(e.target.value)} - required - className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" - placeholder="ey..." - /> -
- - {error && ( -
- {error} -
- )} -
-
- ); -}; diff --git a/src/components/SystemCheck.tsx b/src/components/SystemCheck.tsx index c05bf121..9d7d8404 100644 --- a/src/components/SystemCheck.tsx +++ b/src/components/SystemCheck.tsx @@ -1,52 +1,43 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { supabase, invokeSystemCheckFunction, runDatabaseSelfTest } from '../services/supabaseClient'; +import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth } from '../services/apiClient'; import { ShieldCheckIcon } from './icons/ShieldCheckIcon'; import { LoadingSpinner } from './LoadingSpinner'; import { CheckCircleIcon } from './icons/CheckCircleIcon'; import { XCircleIcon } from './icons/XCircleIcon'; -import { DatabaseSeeder } from './DatabaseSeeder'; type TestStatus = 'idle' | 'running' | 'pass' | 'fail'; // Using an enum for check IDs improves type safety and autocompletion. enum CheckID { GEMINI = 'gemini', + BACKEND = 'backend', SCHEMA = 'schema', - RLS = 'rls', - TRIGGER = 'trigger', - STORAGE = 'storage', - FUNCTIONS = 'functions', - PERMISSIONS = 'permissions', + DB_POOL = 'db_pool', SEED = 'seed', + STORAGE = 'storage', } interface Check { id: CheckID; name: string; + description: string; status: TestStatus; message: string; } const initialChecks: Check[] = [ - { id: CheckID.GEMINI, name: 'Gemini API Key', status: 'idle', message: 'Verifies the VITE_API_KEY is set.' }, - { id: CheckID.SCHEMA, name: 'Database Schema', status: 'idle', message: 'Verifies required tables exist.' }, - { id: CheckID.RLS, name: 'RLS Policies', status: 'idle', message: 'Verifies key security policies are active.' }, - { id: CheckID.TRIGGER, name: 'User Creation Trigger', status: 'idle', message: 'Checks function security configuration.' }, - { id: CheckID.STORAGE, name: 'Storage Bucket', status: 'idle', message: "Checks 'flyers' bucket exists and is public." }, - { id: CheckID.FUNCTIONS, name: 'Edge Functions', status: 'idle', message: "Verifies 'delete-user' and 'seed-database' are deployed." }, - { id: CheckID.PERMISSIONS, name: 'Client Permissions', status: 'idle', message: 'Verifies anon key can perform basic CRUD.' }, - { id: CheckID.SEED, name: 'Seeded Users', status: 'idle', message: 'Verifies default development users exist.' }, + { id: CheckID.GEMINI, name: 'Gemini API Key', description: 'Verifies the VITE_API_KEY is set in your environment.', status: 'idle', message: '' }, + { id: CheckID.BACKEND, name: 'Backend Server Connection', description: 'Checks if the local Express.js server is running and reachable.', status: 'idle', message: '' }, + { id: CheckID.DB_POOL, name: 'Database Connection Pool', description: 'Checks the health of the database connection pool.', status: 'idle', message: '' }, + { id: CheckID.SCHEMA, name: 'Database Schema', description: 'Verifies required tables exist in the database.', status: 'idle', message: '' }, + { id: CheckID.SEED, name: 'Default Admin User', description: 'Verifies the default admin user can be logged into.', status: 'idle', message: '' }, + { id: CheckID.STORAGE, name: 'Assets Storage Directory', description: 'Checks if the local assets folder exists and is writable.', status: 'idle', message: '' }, ]; -interface SystemCheckProps { - onReady?: () => void; -} - -export const SystemCheck: React.FC = ({ onReady }) => { +export const SystemCheck: React.FC = () => { const [checks, setChecks] = useState(initialChecks); const [isRunning, setIsRunning] = useState(false); const [hasRunAutoTest, setHasRunAutoTest] = useState(false); - const [showSeeder, setShowSeeder] = useState(false); const updateCheckStatus = useCallback((id: CheckID, status: TestStatus, message: string) => { setChecks(prev => prev.map(c => c.id === id ? { ...c, status, message } : c)); @@ -59,113 +50,96 @@ export const SystemCheck: React.FC = ({ onReady }) => { const checkApiKey = useCallback(() => { if (import.meta.env.VITE_API_KEY) { - updateCheckStatus(CheckID.GEMINI, 'pass', 'VITE_API_KEY is present.'); + updateCheckStatus(CheckID.GEMINI, 'pass', 'VITE_API_KEY is set.'); return true; } else { - updateCheckStatus(CheckID.GEMINI, 'fail', 'VITE_API_KEY is missing from your environment variables.'); + updateCheckStatus(CheckID.GEMINI, 'fail', 'VITE_API_KEY is missing. Please add it to your .env file.'); return false; } }, [updateCheckStatus]); - const checkBackendSetup = useCallback(async () => { + const checkBackendConnection = useCallback(async () => { try { - const results = await invokeSystemCheckFunction(); - if (typeof results === 'object' && results !== null) { - let backendChecksPassed = true; - for (const key in results) { - if (Object.prototype.hasOwnProperty.call(results, key)) { - const { pass, message } = (results as Record)[key]; - updateCheckStatus(key as CheckID, pass ? 'pass' : 'fail', message); - if (!pass) { - backendChecksPassed = false; - } - } - } - return backendChecksPassed; - } else { - throw new Error("System check function returned invalid data format."); + const isReachable = await pingBackend(); + if (isReachable) { + updateCheckStatus(CheckID.BACKEND, 'pass', 'Backend server is running and reachable.'); + return true; } + throw new Error("Backend server is not responding. Is it running?"); } catch (e) { - const failedCheckIds = [CheckID.SCHEMA, CheckID.RLS, CheckID.TRIGGER, CheckID.STORAGE]; - const errorMessage = getErrorMessage(e); - failedCheckIds.forEach(id => updateCheckStatus(id, 'fail', errorMessage)); + updateCheckStatus(CheckID.BACKEND, 'fail', getErrorMessage(e)); return false; } }, [updateCheckStatus]); - const checkFunctionDeployments = useCallback(async () => { + const checkDatabaseSchema = useCallback(async () => { try { - const { error: seedError } = await supabase.functions.invoke('seed-database', {body: {}}); - if (seedError && seedError.message.includes('Not found')) throw new Error("'seed-database' function not found."); - - const { error: deleteError } = await supabase.functions.invoke('delete-user', {body: {}}); - if (deleteError && deleteError.message.includes('Not found')) throw new Error("'delete-user' function not found."); - - updateCheckStatus(CheckID.FUNCTIONS, 'pass', 'All required Edge Functions are deployed.'); - return true; + const { success, message } = await checkDbSchema(); + updateCheckStatus(CheckID.SCHEMA, success ? 'pass' : 'fail', message); + return success; } catch (e) { - const errorMessage = getErrorMessage(e); - updateCheckStatus(CheckID.FUNCTIONS, 'fail', `${errorMessage} Please deploy it via the Supabase CLI.`); + updateCheckStatus(CheckID.SCHEMA, 'fail', getErrorMessage(e)); return false; } }, [updateCheckStatus]); - const checkClientPermissions = useCallback(async () => { + const checkDatabasePool = useCallback(async () => { try { - const { success, error } = await runDatabaseSelfTest(); - if (!success) throw new Error(error || 'Client-side CRUD test failed.'); - updateCheckStatus(CheckID.PERMISSIONS, 'pass', 'Anon key has correct table permissions.'); - return true; + const { success, message } = await checkDbPoolHealth(); + updateCheckStatus(CheckID.DB_POOL, success ? 'pass' : 'fail', message); + return success; } catch (e) { - updateCheckStatus(CheckID.PERMISSIONS, 'fail', getErrorMessage(e)); + updateCheckStatus(CheckID.DB_POOL, 'fail', getErrorMessage(e)); + return false; + } + }, [updateCheckStatus]); + + const checkStorageDirectory = useCallback(async () => { + try { + const { success, message } = await checkStorage(); + updateCheckStatus(CheckID.STORAGE, success ? 'pass' : 'fail', message); + return success; + } catch (e) { + updateCheckStatus(CheckID.STORAGE, 'fail', getErrorMessage(e)); return false; } }, [updateCheckStatus]); const checkSeededUsers = useCallback(async () => { try { - const { error } = await supabase.auth.signInWithPassword({ - email: 'admin@example.com', - password: 'password123', - }); - if (error) throw error; - await supabase.auth.signOut(); - updateCheckStatus(CheckID.SEED, 'pass', 'Default admin user login verified.'); + await loginUser('admin@example.com', 'password123'); + updateCheckStatus(CheckID.SEED, 'pass', 'Default admin user login was successful.'); return true; } catch (e) { const errorMessage = getErrorMessage(e); - const message = errorMessage.includes('Invalid login credentials') - ? "Invalid login credentials. The seeded users are missing from your database." + const message = errorMessage.includes('Incorrect email or password') + ? "Login failed. Ensure the default admin user is seeded in your database." : `Failed: ${errorMessage}`; updateCheckStatus(CheckID.SEED, 'fail', message); - setShowSeeder(true); return false; } }, [updateCheckStatus]); const runChecks = useCallback(async () => { setIsRunning(true); - setShowSeeder(false); setChecks(prev => prev.map(c => ({ ...c, status: 'running', message: 'Checking...' }))); if (!checkApiKey()) { setIsRunning(false); return; } - if (!await checkBackendSetup()) { setIsRunning(false); return; } - if (!await checkFunctionDeployments()) { setIsRunning(false); return; } - if (!await checkClientPermissions()) { setIsRunning(false); return; } - const allPassed = await checkSeededUsers(); + if (!await checkBackendConnection()) { setIsRunning(false); return; } + if (!await checkDatabasePool()) { setIsRunning(false); return; } + if (!await checkDatabaseSchema()) { setIsRunning(false); return; } + if (!await checkStorageDirectory()) { setIsRunning(false); return; } + await checkSeededUsers(); setIsRunning(false); - if (allPassed) { - onReady?.(); - } - }, [onReady, checkApiKey, checkBackendSetup, checkFunctionDeployments, checkClientPermissions, checkSeededUsers]); + }, [checkApiKey, checkBackendConnection, checkDatabasePool, checkDatabaseSchema, checkStorageDirectory, checkSeededUsers]); useEffect(() => { - if (supabase && !hasRunAutoTest) { + if (!hasRunAutoTest) { setHasRunAutoTest(true); runChecks(); } - }, [supabase, hasRunAutoTest, runChecks]); + }, [hasRunAutoTest, runChecks]); const getStatusIndicator = (status: TestStatus) => { switch (status) { @@ -177,22 +151,20 @@ export const SystemCheck: React.FC = ({ onReady }) => { } }; - if (!supabase) return null; - return (

System Check

-

- This checklist verifies your Supabase setup against the README instructions. +

+ This checklist verifies your local development environment setup.

    {checks.map(check => (
  • -
    {getStatusIndicator(check.status)}
    +
    {getStatusIndicator(check.status)}

    {check.name}

    @@ -203,16 +175,10 @@ export const SystemCheck: React.FC = ({ onReady }) => { ))}

- {showSeeder && ( -
- -
- )} - +
+ + )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts new file mode 100644 index 00000000..23293a7d --- /dev/null +++ b/src/services/apiClient.ts @@ -0,0 +1,329 @@ +import { Profile, UserProfile } from '../types'; + +interface AuthResponse { + user: { id: string; email: string }; + token: string; +} + +// This constant should point to your backend API. +// It's often a good practice to store this in an environment variable. +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api'; + +// --- API Fetch Wrapper with Token Refresh Logic --- + +let isRefreshing = false; +let failedQueue: Array<{ resolve: (value?: unknown) => void; reject: (reason?: any) => void; }> = []; + +const processQueue = (error: Error | null, token: string | null = null) => { + failedQueue.forEach(prom => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +/** + * Attempts to refresh the access token using the HttpOnly refresh token cookie. + * @returns A promise that resolves to the new access token. + */ +const refreshToken = async (): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/auth/refresh-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to refresh token.'); + } + localStorage.setItem('authToken', data.token); + return data.token; + } catch (error) { + // If refresh fails, the user must log in again. + localStorage.removeItem('authToken'); + // Force a reload to reset the app state to logged-out. + // A more advanced implementation might use a global state management event. + window.location.href = '/'; + throw error; + } +}; + +/** + * A custom fetch wrapper that handles automatic token refreshing. + * All authenticated API calls should use this function. + * @param url The URL to fetch. + * @param options The fetch options. + * @returns A promise that resolves to the fetch Response. + */ +const apiFetch = async (url: string, options: RequestInit = {}): Promise => { + let token = localStorage.getItem('authToken'); + if (token) { + options.headers = { + ...options.headers, + 'Authorization': `Bearer ${token}`, + }; + } + + let response = await fetch(url, options); + + if (response.status === 401) { + if (isRefreshing) { + // If a refresh is already in progress, wait for it to complete. + await new Promise((resolve, reject) => failedQueue.push({ resolve, reject })); + options.headers!['Authorization'] = `Bearer ${localStorage.getItem('authToken')}`; + return fetch(url, options); // Retry with the new token + } + + isRefreshing = true; + try { + await refreshToken(); + processQueue(null, localStorage.getItem('authToken')); + options.headers!['Authorization'] = `Bearer ${localStorage.getItem('authToken')}`; + return fetch(url, options); // Retry the original request + } finally { + isRefreshing = false; + } + } + + return response; +}; + +/** + * Pings the backend server to check if it's running and reachable. + * @returns A promise that resolves to true if the server responds with 'pong'. + */ +export const pingBackend = async (): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/health/ping`); + if (!response.ok) return false; + const text = await response.text(); + return text === 'pong'; + } catch (error) { + // Network errors mean the server is not reachable + console.error("Backend ping failed:", error); + return false; + } +}; + +/** + * Checks the backend's database schema. + * @returns A promise that resolves to an object with success status and a message. + */ +export const checkDbSchema = async (): Promise<{ success: boolean; message: string }> => { + const response = await fetch(`${API_BASE_URL}/health/db-schema`); + const data = await response.json(); + if (!response.ok) { + // Use the message from the backend error response + throw new Error(data.message || 'Failed to check database schema.'); + } + return data; +}; + +/** + * Checks the backend's storage directory. + * @returns A promise that resolves to an object with success status and a message. + */ +export const checkStorage = async (): Promise<{ success: boolean; message: string }> => { + const response = await fetch(`${API_BASE_URL}/health/storage`); + const data = await response.json(); + if (!response.ok) { + // Use the message from the backend error response + throw new Error(data.message || 'Failed to check storage directory.'); + } + return data; +}; + +/** + * Checks the backend's database connection pool health. + * @returns A promise that resolves to an object with success status and a message. + */ +export const checkDbPoolHealth = async (): Promise<{ success: boolean; message: string }> => { + const response = await fetch(`${API_BASE_URL}/health/db-pool`); + const data = await response.json(); + if (!response.ok) { + // Use the message from the backend error response + throw new Error(data.message || 'Failed to check database pool health.'); + } + return data; +}; + + +/** + * Fetches the full profile for the currently authenticated user. + * It retrieves the auth token from local storage and sends it in the Authorization header. + * @returns A promise that resolves to the user's combined UserProfile object. + * @throws An error if the request fails or if the user is not authenticated. + */ +export const getAuthenticatedUserProfile = async (): Promise => { + const token = localStorage.getItem('authToken'); + + if (!token) { + throw new Error('Authentication token not found.'); + } + + const response = await apiFetch(`${API_BASE_URL}/users/profile`, { + method: 'GET', + }); + + if (!response.ok) { + if (response.status === 401) throw new Error('Session expired. Please log in again.'); + // Attempt to get a more descriptive error message from the response body. + const errorData = await response.json().catch(() => ({ message: 'Failed to fetch user profile.' })); + throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + } + + const profile: UserProfile = await response.json(); + return profile; +}; + +export async function loginUser(email: string, password: string): Promise { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + // The backend should return an error message in the JSON body + throw new Error(data.message || 'Login failed'); + } + return data; +} + +export async function registerUser(email: string, password: string): Promise { + const response = await fetch(`${API_BASE_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Registration failed'); + } + return data; +} + +/** + * Sends a password reset request for the given email to the backend. + * @param email The user's email address. + * @returns A promise that resolves with a success message. + */ +export async function requestPasswordReset(email: string): Promise<{ message: string }> { + const response = await fetch(`${API_BASE_URL}/auth/forgot-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to send password reset email.'); + } + return data; +} + +/** + * Sends the password reset token and new password to the backend. + * @param token The password reset token from the URL. + * @param newPassword The user's new password. + * @returns A promise that resolves with a success message. + */ +export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { + const response = await fetch(`${API_BASE_URL}/auth/reset-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, newPassword }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to reset password.'); + } + return data; +} +/** + * Sends updated user preferences to the backend. + * @param preferences A partial object of the user's preferences to update. + * @returns A promise that resolves to the user's full, updated profile object. + */ +export async function updateUserPreferences(preferences: Partial): Promise { + const token = localStorage.getItem('authToken'); + if (!token) { + throw new Error('No authentication token found.'); + } + + const response = await apiFetch(`${API_BASE_URL}/users/profile/preferences`, { + method: 'PUT', + body: JSON.stringify(preferences), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Failed to update user preferences'); + } + return data; +} + +/** + * Sends a new password to the backend to be updated. + * @param newPassword The user's new password. + * @returns A promise that resolves on success. + */ +export async function updateUserPassword(newPassword: string): Promise<{ message: string }> { + const token = localStorage.getItem('authToken'); + if (!token) { + throw new Error('No authentication token found.'); + } + + const response = await apiFetch(`${API_BASE_URL}/users/profile/password`, { + method: 'PUT', + body: JSON.stringify({ newPassword }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to update password'); + } + return data; +} + +/** + * Sends a request to delete the user's account, verifying with their current password. + * @param password The user's current password for verification. + * @returns A promise that resolves on success. + */ +export async function deleteUserAccount(password: string): Promise<{ message: string }> { + const token = localStorage.getItem('authToken'); + if (!token) { + throw new Error('No authentication token found.'); + } + + const response = await apiFetch(`${API_BASE_URL}/users/account`, { + method: 'DELETE', + body: JSON.stringify({ password }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Failed to delete account'); + } + return data; +} \ No newline at end of file diff --git a/src/services/db.ts b/src/services/db.ts new file mode 100644 index 00000000..f49f3157 --- /dev/null +++ b/src/services/db.ts @@ -0,0 +1,229 @@ +import { Pool } from 'pg'; +import { logger } from './logger'; +import { Profile } from '../types'; // Assuming your Profile type is in `types.ts` + +// Configure your PostgreSQL connection pool +// IMPORTANT: For production, use environment variables for these credentials. +const pool = new Pool({ + user: process.env.DB_USER || 'postgres', + host: process.env.DB_HOST || 'localhost', + database: process.env.DB_NAME || 'flyer-crawler', // Replace with your actual database name + password: process.env.DB_PASSWORD || 'your_db_password', // Replace with your actual database password + port: parseInt(process.env.DB_PORT || '5432', 10), +}); + +logger.info(`Database connection pool created for host: ${process.env.DB_HOST || 'localhost'}`); + +/** + * Defines the structure of a user object as returned from the database. + */ +interface DbUser { + id: string; // UUID + email: string; + password_hash: string; + refresh_token?: string | null; +} + +/** + * Finds a user by their email in the public.users table. + * @param email The email of the user to find. + * @returns A promise that resolves to the user object or undefined if not found. + */ +export async function findUserByEmail(email: string): Promise { + try { + const res = await pool.query( + 'SELECT id, email, password_hash, refresh_token FROM public.users WHERE email = $1', + [email] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in findUserByEmail:', { error }); + throw new Error('Failed to retrieve user from database.'); + } +} + +/** + * Creates a new user in the public.users table. + * @param email The user's email. + * @param passwordHash The bcrypt hashed password. + * @returns A promise that resolves to the newly created user object (id, email). + */ +export async function createUser(email: string, passwordHash: string): Promise<{ id: string; email: string }> { + try { + const res = await pool.query<{ id: string; email: string }>( + 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id, email', + [email, passwordHash] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in createUser:', { error }); + throw new Error('Failed to create user in database.'); + } +} + +/** + * Finds a user by their ID. Used by the JWT strategy to validate tokens. + * @param id The UUID of the user to find. + * @returns A promise that resolves to the user object (id, email) or undefined if not found. + */ +export async function findUserById(id: string): Promise<{ id: string; email: string } | undefined> { + try { + const res = await pool.query<{ id: string; email: string }>( + 'SELECT id, email FROM public.users WHERE id = $1', + [id] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in findUserById:', { error }); + throw new Error('Failed to retrieve user by ID from database.'); + } +} + +/** + * Finds a user's profile by their user ID. + * @param id The UUID of the user. + * @returns A promise that resolves to the user's profile object or undefined if not found. + */ +export async function findUserProfileById(id: string): Promise { + try { + // This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.id' + const res = await pool.query( + 'SELECT id, full_name, avatar_url, preferences, role FROM public.profiles WHERE id = $1', + [id] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in findUserProfileById:', { error }); + throw new Error('Failed to retrieve user profile from database.'); + } +} + +/** + * Updates the preferences for a given user. + * The `pg` driver automatically handles serializing the JS object to JSONB. + * @param id The UUID of the user. + * @param preferences The preferences object to save. + * @returns A promise that resolves to the updated profile object. + */ +export async function updateUserPreferences(id: string, preferences: Profile['preferences']): Promise { + try { + const res = await pool.query( + `UPDATE public.profiles + SET preferences = preferences || $1, updated_at = now() + WHERE id = $2 + RETURNING id, full_name, avatar_url, preferences, role`, + [preferences, id] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in updateUserPreferences:', { error }); + throw new Error('Failed to update user preferences in database.'); + } +} + +/** + * Updates the password hash for a given user. + * @param id The UUID of the user. + * @param passwordHash The new bcrypt hashed password. + */ +export async function updateUserPassword(id: string, passwordHash: string): Promise { + try { + await pool.query( + 'UPDATE public.users SET password_hash = $1 WHERE id = $2', + [passwordHash, id] + ); + } catch (error) { + logger.error('Database error in updateUserPassword:', { error }); + throw new Error('Failed to update user password in database.'); + } +} + +/** + * Deletes a user from the database by their ID. + * @param id The UUID of the user to delete. + */ +export async function deleteUserById(id: string): Promise { + try { + await pool.query('DELETE FROM public.users WHERE id = $1', [id]); + } catch (error) { + logger.error('Database error in deleteUserById:', { error }); + throw new Error('Failed to delete user from database.'); + } +} + +/** + * Checks for the existence of a list of tables in the public schema. + * @param tableNames An array of table names to check. + * @returns A promise that resolves to an array of table names that are missing from the database. + */ +export async function checkTablesExist(tableNames: string[]): Promise { + try { + // This query checks the information_schema to find which of the provided table names exist. + const query = ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ANY($1::text[]) + `; + const res = await pool.query<{ table_name: string }>(query, [tableNames]); + + const existingTables = new Set(res.rows.map(row => row.table_name)); + const missingTables = tableNames.filter(name => !existingTables.has(name)); + + return missingTables; + } catch (error) { + logger.error('Database error in checkTablesExist:', { error }); + throw new Error('Failed to check for tables in database.'); + } +} + +/** + * Gets the current status of the connection pool. + * @returns An object with the total, idle, and waiting client counts. + */ +export function getPoolStatus() { + // pool.totalCount: The total number of clients in the pool. + // pool.idleCount: The number of clients that are idle and waiting for a query. + // pool.waitingCount: The number of queued requests waiting for a client to become available. + return { + totalCount: pool.totalCount, + idleCount: pool.idleCount, + waitingCount: pool.waitingCount, + }; +} + +/** + * Saves or updates a refresh token for a user. + * @param userId The UUID of the user. + * @param refreshToken The new refresh token to save. + */ +export async function saveRefreshToken(userId: string, refreshToken: string): Promise { + try { + // For simplicity, we store one token per user. For multi-device support, a separate table is better. + await pool.query( + 'UPDATE public.users SET refresh_token = $1 WHERE id = $2', + [refreshToken, userId] + ); + } catch (error) { + logger.error('Database error in saveRefreshToken:', { error }); + throw new Error('Failed to save refresh token.'); + } +} + +/** + * Finds a user by their refresh token. + * @param refreshToken The refresh token to look up. + * @returns A promise that resolves to the user object (id, email) or undefined if not found. + */ +export async function findUserByRefreshToken(refreshToken: string): Promise<{ id: string; email: string } | undefined> { + try { + const res = await pool.query<{ id: string; email: string }>( + 'SELECT id, email FROM public.users WHERE refresh_token = $1', + [refreshToken] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in findUserByRefreshToken:', { error }); + return undefined; // Return undefined on error to prevent token leakage + } +} \ No newline at end of file diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index 19385d40..bbda80b3 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -2,7 +2,6 @@ import { GoogleGenAI, Type, Modality, GroundingChunk } from "@google/genai"; import type { FlyerItem, MasterGroceryItem, UnitPrice, Store } from "../types"; -import { supabase } from './supabaseClient'; //import { CATEGORIES } from '../types'; import { parsePriceToCents } from '../utils/priceParser'; @@ -140,16 +139,14 @@ interface ExtractedLogoData { export const extractCoreDataFromImage = async (imageFiles: File[], masterItems: MasterGroceryItem[]): Promise => { const imageParts = await Promise.all(imageFiles.map(fileToGenerativePart)); - const UNMATCHED_ITEM_ID = 0; - if (!supabase) { - throw new Error("Supabase client not initialized."); - } - - // Invoke the secure Edge Function instead of calling Gemini directly - const { data: parsedJson, error } = await supabase.functions.invoke('process-flyer', { - body: { imageParts, masterItems }, + // TODO: This logic needs to be migrated to a new endpoint on your Express backend. + // The backend endpoint will securely call the Gemini API. + // For now, this is a placeholder to show what needs to be done. + const response = await fetch('/api/ai/process-flyer', { // This endpoint does not exist yet. + method: 'POST', + body: JSON.stringify({ imageParts, masterItems }) }); - + const { data: parsedJson, error } = await response.json(); if (error) { throw new Error(`Error invoking process-flyer function: ${error.message}`); } @@ -158,6 +155,7 @@ export const extractCoreDataFromImage = async (imageFiles: File[], masterItems: return null; } + const UNMATCHED_ITEM_ID = 0; const rawData = parsedJson as { store_name: string; valid_from: string | null; diff --git a/src/services/supabaseClient.ts b/src/services/supabaseClient.ts index 75ef6a9b..171deee4 100644 --- a/src/services/supabaseClient.ts +++ b/src/services/supabaseClient.ts @@ -671,17 +671,6 @@ export const updateUserPreferences = async (userId: string, preferences: Profile return data as Profile; // Cast is safe here because we expect a single result }; -/** - * Updates the authenticated user's password. - * @param newPassword The new password. - */ -export const updateUserPassword = async (newPassword: string): Promise => { - const supabaseClient = ensureSupabase(); - - const { error } = await supabaseClient.auth.updateUser({ password: newPassword }); - if (error) throw new Error(`Error updating password: ${error.message}`); -}; - /** * Gathers all data for a specific user for export. * @param userId The UUID of the user. @@ -723,38 +712,6 @@ export const exportUserData = async (userId: string): Promise => }; }; - -/** - * Deletes the current user's account by invoking a secure Edge Function. - * @param password The user's current password for verification. - */ -export const deleteUserAccount = async (password: string): Promise => { - // Adding detailed logging to trace who is calling this function. - console.trace("deleteUserAccount called. This trace will show the call stack."); - const supabaseClient = ensureSupabase(); - - // Invoking the 'delete-user' edge function. - const { data, error } = await supabaseClient.functions.invoke('delete-user', { - body: { password }, - }); - - if (error) { - let errorDetails = `Edge Function returned an error: ${error.message}.`; - if (error.context) { - try { - const errorBody = await error.context.json(); - const message = errorBody.error || 'No error message in body.'; - const stack = errorBody.stack || 'No stack trace in body.'; - errorDetails = `Error: ${message}\n\nStack Trace:\n${stack}`; - } catch { - errorDetails += `\nCould not parse error response body.`; - } - } - throw new Error(errorDetails); - } - if (data?.error) throw new Error(data.error); -}; - /** * Calls the `system-check` Edge Function to verify the backend setup. * @returns The results of the system checks. diff --git a/src/types.ts b/src/types.ts index d113144a..7ef98459 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,11 @@ export interface DealItem { } // User-specific types +export interface User { + id: string; // UUID + email: string; +} + export interface Profile { id: string; // UUID updated_at?: string; @@ -86,6 +91,12 @@ export interface Profile { } | null; } +/** + * Represents the combined user and profile data object returned by the backend's /users/profile endpoint. + * It embeds the User object within the Profile object. + */ +export type UserProfile = Profile & { user: User }; + export interface SuggestedCorrection { id: number; flyer_item_id: number; diff --git a/src/types/supabase.ts b/src/types/supabase.ts index a2924cca..cc671ded 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -981,15 +981,21 @@ export type Database = { users: { Row: { created_at: string + email: string id: string + password_hash: string } Insert: { created_at?: string + email: string id?: string + password_hash: string } Update: { created_at?: string + email?: string id?: string + password_hash?: string } Relationships: [] }