diff --git a/.gitignore b/.gitignore index 6fb6af1..5423511 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Personal personalNotes.md user-db-implementation-plan.md +docs/ # Claude CLAUDE.md diff --git a/backend/package-lock.json b/backend/package-lock.json index 40f0f0b..a8f444f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,12 +10,19 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@prisma/adapter-pg": "^7.0.0", "@prisma/client": "^7.0.0", "bcryptjs": "^3.0.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", + "cookie-parser": "^1.4.7", "dotenv": "^16.0.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.0.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0" @@ -23,8 +30,10 @@ "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", "@types/node": "^20.0.0", + "@types/passport-jwt": "^4.0.1", "prisma": "^7.0.0", "typescript": "^5.0.0" } @@ -646,6 +655,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", @@ -666,6 +688,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", @@ -999,6 +1031,16 @@ "@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/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -1068,6 +1110,16 @@ "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==", + "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", @@ -1075,16 +1127,53 @@ "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==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.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-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/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -1143,6 +1232,12 @@ "@types/node": "*" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -1688,6 +1783,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "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", @@ -1952,6 +2053,23 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -2124,6 +2242,25 @@ "node": ">= 0.6" } }, + "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-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.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -2336,6 +2473,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "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", @@ -3451,6 +3597,61 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.39", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.39.tgz", + "integrity": "sha512-MW79m7HuOqBk8mwytiXYTMELJiBbV3Zl9Y39dCCn1yC8K+WGNSq1QGvzywbylp5vGShEztMScCWHX/XFOS0rXg==", + "license": "MIT" + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -3489,6 +3690,48 @@ "dev": true, "license": "MIT" }, + "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.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/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -4014,6 +4257,42 @@ "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-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-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4064,6 +4343,11 @@ "devOptional": 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/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -4666,7 +4950,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5338,7 +5621,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -5431,6 +5713,15 @@ } } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index eeddf46..a076005 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,12 +15,19 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@prisma/adapter-pg": "^7.0.0", "@prisma/client": "^7.0.0", "bcryptjs": "^3.0.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", + "cookie-parser": "^1.4.7", "dotenv": "^16.0.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.0.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0" @@ -28,8 +35,10 @@ "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", "@types/node": "^20.0.0", + "@types/passport-jwt": "^4.0.1", "prisma": "^7.0.0", "typescript": "^5.0.0" } diff --git a/backend/prisma/migrations/20260312183216_add_refresh_tokens/migration.sql b/backend/prisma/migrations/20260312183216_add_refresh_tokens/migration.sql new file mode 100644 index 0000000..269d48f --- /dev/null +++ b/backend/prisma/migrations/20260312183216_add_refresh_tokens/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 76fe15e..0cda994 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,6 +24,16 @@ enum UnitType { // ─── Auth Domain ────────────────────────────────────────────────────────────── +model RefreshToken { + id String @id @default(uuid()) @db.Uuid + user_id String @db.Uuid + token_hash String + expires_at DateTime + created_at DateTime @default(now()) + + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) +} + model User { id String @id @default(uuid()) @db.Uuid username String @unique @db.VarChar(50) @@ -38,6 +48,7 @@ model User { recipes Recipe[] shoppingLists ShoppingList[] sharedRecipes RecipeGroup[] @relation("SharedBy") + refresh_tokens RefreshToken[] } model Role { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 69783ef..53dc147 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,9 +3,10 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PrismaModule } from './prisma/prisma.module'; import { UserModule } from './user/user.module'; +import { AuthModule } from './auth/auth.module'; @Module({ - imports: [PrismaModule, UserModule], + imports: [PrismaModule, UserModule, AuthModule], controllers: [AppController], providers: [AppService], }) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..60f3c09 --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,90 @@ +import { + Controller, + Post, + Body, + Res, + Req, + HttpCode, + UnauthorizedException, +} from "@nestjs/common"; +import { Response, Request } from "express"; +import { AuthService } from "./auth.service"; +import { LoginDto } from "./dto/login.dto"; + +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" as const, + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms + path: "/auth", // cookie is only sent to /auth routes +}; + +@Controller("auth") +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post("login") + @HttpCode(200) + async login( + @Body() dto: LoginDto, + @Res({ passthrough: true }) res: Response, + ) { + const { accessToken, refreshToken, user } = await this.authService.login( + dto.email, + dto.password, + ); + + res.cookie("refresh_token", refreshToken, COOKIE_OPTIONS); + + return { + accessToken, + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + createdAt: user.created_at, + }, + }; + } + + @Post("refresh") + @HttpCode(200) + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + const token = req.cookies?.refresh_token; + if (!token) throw new UnauthorizedException("No refresh token"); + + const { accessToken, refreshToken, user } = + await this.authService.rotateRefreshToken(token); + + res.cookie("refresh_token", refreshToken, COOKIE_OPTIONS); + + return { + accessToken, + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + createdAt: user.created_at, + }, + }; + } + + @Post("logout") + @HttpCode(200) + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + const token = req.cookies?.refresh_token; + if (token) { + await this.authService.logout(token); + } + + res.clearCookie("refresh_token", { path: "/auth" }); + return { message: "Logged out" }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..e63dc35 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { UserModule } from "../user/user.module"; +import { AuthService } from "./auth.service"; +import { AuthController } from "./auth.controller"; +import { JwtStrategy } from "./strategies/jwt.strategy"; +import { JwtAuthGuard } from "./guards/jwt-auth.guard"; + +@Module({ + imports: [ + UserModule, + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: "1h" }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, JwtAuthGuard], + exports: [JwtAuthGuard, JwtStrategy], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..6d16e8f --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,103 @@ +import * as crypto from "crypto"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { UserService } from "../user/user.service"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class AuthService { + constructor( + private readonly userService: UserService, + private readonly jwtService: JwtService, + private readonly prisma: PrismaService, + ) {} + + async validateCredentials(email: string, password: string) { + const user = await this.userService.findByEmail(email); + if (!user) throw new UnauthorizedException("Invalid credentials"); + + const valid = await this.userService.verifyPassword( + password, + user.password_hash, + ); + if (!valid) throw new UnauthorizedException("Invalid credentials"); + + return user; + } + + async generateTokens(userId: string, username: string) { + // Access token — signed JWT, expires in 1 hour + const payload = { sub: userId, username }; + const accessToken = this.jwtService.sign(payload); + + // Refresh token — random 32-byte hex string (not a JWT) + const refreshToken = crypto.randomBytes(32).toString("hex"); + const tokenHash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); // 30 days + + await this.prisma.refreshToken.create({ + data: { + user_id: userId, + token_hash: tokenHash, + expires_at: expiresAt, + }, + }); + + return { accessToken, refreshToken }; + } + + async login(email: string, password: string) { + const user = await this.validateCredentials(email, password); + const { accessToken, refreshToken } = await this.generateTokens( + user.id, + user.username, + ); + return { accessToken, refreshToken, user }; + } + + async rotateRefreshToken(incomingToken: string) { + const tokenHash = crypto + .createHash("sha256") + .update(incomingToken) + .digest("hex"); + + const stored = await this.prisma.refreshToken.findFirst({ + where: { token_hash: tokenHash }, + include: { user: true }, + }); + + if (!stored || stored.expires_at < new Date()) { + // Delete expired record if it exists + if (stored) { + await this.prisma.refreshToken.deleteMany({ where: { id: stored.id } }); + } + throw new UnauthorizedException("Refresh token invalid or expired"); + } + + // Delete old token before issuing new one (rotation) + await this.prisma.refreshToken.deleteMany({ where: { id: stored.id } }); + + const { accessToken, refreshToken } = await this.generateTokens( + stored.user.id, + stored.user.username, + ); + + return { accessToken, refreshToken, user: stored.user }; + } + + async logout(incomingToken: string) { + const tokenHash = crypto + .createHash("sha256") + .update(incomingToken) + .digest("hex"); + + await this.prisma.refreshToken.deleteMany({ + where: { token_hash: tokenHash }, + }); + } +} diff --git a/backend/src/auth/decorators/current-user.decorator.ts b/backend/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..5729aa3 --- /dev/null +++ b/backend/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..4dd490e --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsString } from "class-validator"; + +export class LoginDto { + @IsEmail() + email!: string; + + @IsString() + password!: string; +} diff --git a/backend/src/auth/dto/token-response.dto.ts b/backend/src/auth/dto/token-response.dto.ts new file mode 100644 index 0000000..61f7f19 --- /dev/null +++ b/backend/src/auth/dto/token-response.dto.ts @@ -0,0 +1,6 @@ +import { UserResponseDto } from "../../user/dto/user-response.dto"; + +export class TokenResponseDto { + accessToken!: string; + user!: UserResponseDto; +} diff --git a/backend/src/auth/guards/jwt-auth.guard.ts b/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2e81dba --- /dev/null +++ b/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class JwtAuthGuard extends AuthGuard("jwt") {} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..4412a7a --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET as string, + }); + } + + async validate(payload: { sub: string; username: string }) { + return { id: payload.sub, username: payload.username }; + } +} diff --git a/backend/src/dto/create-user.dto.ts b/backend/src/dto/create-user.dto.ts deleted file mode 100644 index 5a354fd..0000000 --- a/backend/src/dto/create-user.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ - -export class CreateUserDto { - firstName!: string; - lastName!: string; - username!: string; - email!: string; - password!: string; // plain-text; will be hashed in service -} \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index aaf09a1..4dbd69e 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,14 +1,21 @@ -import 'reflect-metadata'; -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import "reflect-metadata"; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; +import { ValidationPipe } from "@nestjs/common"; +import cookieParser from "cookie-parser"; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.use(cookieParser()); + app.enableCors({ - origin: 'http://localhost:5173', + origin: "http://localhost:5173", + credentials: true, }); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + const port = process.env.PORT ?? 3001; await app.listen(port); console.log(`Backend running on http://localhost:${port}`); diff --git a/backend/src/user/dto/create-user.dto.ts b/backend/src/user/dto/create-user.dto.ts new file mode 100644 index 0000000..8d85acc --- /dev/null +++ b/backend/src/user/dto/create-user.dto.ts @@ -0,0 +1,22 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from "class-validator"; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + firstName!: string; + + @IsString() + @IsNotEmpty() + lastName!: string; + + @IsString() + @IsNotEmpty() + username!: string; + + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; +} diff --git a/backend/src/dto/user-response.dto.ts b/backend/src/user/dto/user-response.dto.ts similarity index 100% rename from backend/src/dto/user-response.dto.ts rename to backend/src/user/dto/user-response.dto.ts diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 94d8bd6..c804863 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,42 +1,20 @@ -import { Controller, Get, Post, Param, Body, NotFoundException } from '@nestjs/common'; -import { UserService } from './user.service'; -import { CreateUserDto } from '../dto/create-user.dto'; -import { UserResponseDto } from '../dto/user-response.dto'; +import { + Controller, + Get, + Post, + Param, + Body, + NotFoundException, +} from "@nestjs/common"; +import { UserService } from "./user.service"; +import { CreateUserDto } from "./dto/create-user.dto"; +import { UserResponseDto } from "./dto/user-response.dto"; -@Controller('user') +@Controller("user") export class UserController { constructor(private readonly userService: UserService) {} - - @Get('password-validation/:password') - getPassTest(@Param('password') password:string){ - return this.userService.passwordValidation(password); - } - - @Get('hash-password/:password') - getHashTest(@Param('password') password:string){ - return this.userService.hashPassword(password); - } - - @Get('hash-verifiedpassword/:password/:hash') - getVerifiedTest( - @Param('password') password:string, - @Param('hash') hash:string - ){ - return this.userService.verifyPassword(password, hash); - } - - // POST just for testing - @Post('verify-password') - verifyPasswordTest(@Body() body: { password: string, hash: string }) { - console.log('password:', body.password); - console.log('hash:', body.hash); - console.log('hash length:', body.hash.length); - return this.userService.verifyPassword(body.password, body.hash); - } - - // stuff from db imp plan - @Post('register') + @Post("register") async register(@Body() dto: CreateUserDto): Promise { const user = await this.userService.registerUser(dto); return { @@ -49,10 +27,10 @@ export class UserController { }; } - @Get(':id') - async getUser(@Param('id') id: string): Promise { + @Get(":id") + async getUser(@Param("id") id: string): Promise { const user = await this.userService.findById(id); - if (!user) throw new NotFoundException('User not found'); + if (!user) throw new NotFoundException("User not found"); return { id: user.id, username: user.username, @@ -62,5 +40,4 @@ export class UserController { createdAt: user.created_at, }; } - } diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index e21d51f..0165abe 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,9 +1,10 @@ -import { Module } from '@nestjs/common'; -import { UserService } from './user.service'; -import { UserController } from './user.controller'; +import { Module } from "@nestjs/common"; +import { UserService } from "./user.service"; +import { UserController } from "./user.controller"; @Module({ controllers: [UserController], providers: [UserService], + exports: [UserService], }) export class UserModule {} diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 3f6a838..b03d052 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -1,76 +1,76 @@ -import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import * as bcrypt from 'bcryptjs' -import { CreateUserDto } from '../dto/create-user.dto'; -import { PrismaService } from '../prisma/prisma.service'; +import { + BadRequestException, + ConflictException, + Injectable, +} from "@nestjs/common"; +import * as bcrypt from "bcryptjs"; +import { CreateUserDto } from "./dto/create-user.dto"; +import { PrismaService } from "../prisma/prisma.service"; @Injectable() export class UserService { - async passwordValidation(password:string) { - // Password Pattern: lowercase, UPPERCASE, digit, min length 8 - const passPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d@$!%*?&]{8,}$/; - return passPattern.test(password); - } + async passwordValidation(password: string) { + // Password Pattern: lowercase, UPPERCASE, digit, min length 8 + const passPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d@$!%*?&]{8,}$/; + return passPattern.test(password); + } - async hashPassword(password:string) { - // Salt password 10 times and return hash result - const saltRounds = 10; - const salt = await bcrypt.genSalt(saltRounds); - return await bcrypt.hash(password, salt); - } + async hashPassword(password: string) { + // Salt password 10 times and return hash result + const saltRounds = 10; + const salt = await bcrypt.genSalt(saltRounds); + return await bcrypt.hash(password, salt); + } - async verifyPassword(password:string, hash:string) { - // Compare password with hashed password, return true/false - return await bcrypt.compare(password, hash); - } + async verifyPassword(password: string, hash: string) { + // Compare password with hashed password, return true/false + return await bcrypt.compare(password, hash); + } - async validateEmail(email:string) { - const emailPattern = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/; - return emailPattern.test(email) - } - - // stuff from db imp plan + // stuff from db imp plan - constructor(private readonly prisma: PrismaService) {} - - async findByUsername(username: string) { - return this.prisma.user.findUnique({ where: { username } }); - } + constructor(private readonly prisma: PrismaService) {} - async findById(id: string) { - return this.prisma.user.findUnique({ where: { id } }); - } + async findByUsername(username: string) { + return this.prisma.user.findUnique({ where: { username } }); + } - async findByEmail(email: string) { - return this.prisma.user.findUnique({ where: { email } }); - } + async findById(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } - async registerUser(dto: CreateUserDto) { - // 1. Check email uniqueness + async findByEmail(email: string) { + return this.prisma.user.findUnique({ where: { email } }); + } + + async registerUser(dto: CreateUserDto) { + // Check email uniqueness const emailTaken = await this.findByEmail(dto.email); - if (emailTaken) throw new ConflictException('Email already in use'); + if (emailTaken) throw new ConflictException("Email already in use"); - // 2. Check username uniqueness + // Check username uniqueness const usernameTaken = await this.findByUsername(dto.username); - if (usernameTaken) throw new ConflictException('Username already taken'); + if (usernameTaken) throw new ConflictException("Username already taken"); - // 3. Validate password shape + // Validate password shape const validPassword = this.passwordValidation(dto.password); - if (!validPassword) throw new BadRequestException('Password does not meet requirements'); + if (!validPassword) + throw new BadRequestException("Password does not meet requirements"); - // 4. Hash password + // Hash password const password_hash = await this.hashPassword(dto.password); - // 5. Create user in DB + // Create user in DB const user = await this.prisma.user.create({ - data: { - first_name: dto.firstName, - last_name: dto.lastName, - username: dto.username, - email: dto.email, - password_hash, - }, + data: { + first_name: dto.firstName, + last_name: dto.lastName, + username: dto.username, + email: dto.email, + password_hash, + }, }); return user; - } + } } diff --git a/docker-compose.yml b/docker-compose.yml index 4beb693..f76371a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,14 @@ services: db: image: postgres:16-alpine restart: unless-stopped - env_file: .env + env_file: ./backend/.env volumes: - postgres_data:/var/lib/postgresql/data - ./db/init:/docker-entrypoint-initdb.d ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U kitchensync_user -d kitchensync"] interval: 5s timeout: 5s retries: 5 @@ -19,7 +19,7 @@ services: context: ./backend dockerfile: Dockerfile restart: unless-stopped - env_file: .env + env_file: ./backend/.env ports: - "3001:3001" depends_on: @@ -31,7 +31,7 @@ services: context: ./frontend dockerfile: Dockerfile restart: unless-stopped - env_file: .env + env_file: ./frontend/.env ports: - "5173:5173" depends_on: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9c74403..d5a2423 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.0.0", "@mui/icons-material": "^6.0.0", "@mui/material": "^6.0.0", + "axios": "^1.13.6", "react": "^18.0.0", "react-dom": "^18.0.0", "react-router-dom": "^7.13.1" @@ -1605,6 +1606,23 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1667,6 +1685,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1706,6 +1737,18 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1765,6 +1808,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1775,6 +1827,20 @@ "csstype": "^3.0.2" } }, + "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==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -1791,6 +1857,51 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1858,6 +1969,42 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1892,6 +2039,82 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2021,6 +2244,36 @@ "yallist": "^3.0.2" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2159,6 +2412,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 27f794f..0ed1585 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.0.0", "@mui/icons-material": "^6.0.0", "@mui/material": "^6.0.0", + "axios": "^1.13.6", "react": "^18.0.0", "react-dom": "^18.0.0", "react-router-dom": "^7.13.1" diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index cb28556..8a6c1ba 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,58 +1,16 @@ -const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; - export interface AuthUser { id: string; - email: string; - firstName: string; - lastName: string; username: string; -} - -export interface AuthResponse { - token: string; - user: AuthUser; -} - -// STUB: POST /auth/register -export async function registerUser(data: { + email: string; firstName: string; lastName: string; - username: string; - email: string; - password: string; -}): Promise { - const res = await fetch(`${API_URL}/auth/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (!res.ok) throw new Error("Registration failed"); - return res.json(); -} - -// STUB: POST /auth/login -export async function loginUser(data: { - email: string; - password: string; -}): Promise { - const res = await fetch(`${API_URL}/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (!res.ok) throw new Error("Login failed"); - return res.json(); + createdAt: string; } -// STUB: POST /auth/google — expects a Google OAuth token from the client-side flow -export async function loginWithGoogle( - googleToken: string, -): Promise { - const res = await fetch(`${API_URL}/auth/google`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: googleToken }), - }); - if (!res.ok) throw new Error("Google login failed"); - return res.json(); +export interface AuthContextValue { + user: AuthUser | null; + accessToken: string | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; } diff --git a/frontend/src/api/axiosInstance.ts b/frontend/src/api/axiosInstance.ts new file mode 100644 index 0000000..a5596bd --- /dev/null +++ b/frontend/src/api/axiosInstance.ts @@ -0,0 +1,8 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL ?? "http://localhost:3001", + withCredentials: true, +}); + +export default api; diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 3acae9d..4792f13 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -15,10 +15,10 @@ import RestaurantMenuIcon from "@mui/icons-material/RestaurantMenu"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useAuth } from "../context/AuthContext"; +import { useNavigate } from "react-router-dom"; import { DARK_GREEN } from "../styles/tokens"; const AUTH_NAV_ITEMS = [ - { label: "Home", route: "/" }, { label: "Recipes", route: "/recipes" }, { label: "Groups", route: "/groups" }, { label: "Shopping List", route: "/shopping-list" }, @@ -33,13 +33,23 @@ export default function NavBar() { const [drawerOpen, setDrawerOpen] = useState(false); const theme = useTheme(); const isDesktop = useMediaQuery(theme.breakpoints.up("md")); - const { isAuthenticated } = useAuth(); + const { user, logout } = useAuth(); + const navigate = useNavigate(); - const navItems = isAuthenticated ? AUTH_NAV_ITEMS : GUEST_NAV_ITEMS; + async function handleLogout() { + await logout(); + navigate("/login"); + } + + const navItems = user ? AUTH_NAV_ITEMS : GUEST_NAV_ITEMS; return ( <> - + {/* Logo */} ))} + {user && ( + + )} ) : ( {/* Mobile drawer */} - setDrawerOpen(false)}> + setDrawerOpen(false)} + > {navItems.map(({ label, route }) => ( ))} + {user && ( + { setDrawerOpen(false); handleLogout(); }} + sx={{ color: DARK_GREEN }} + > + + + )} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 7ba05e5..7795ce3 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,29 +1,109 @@ -import { createContext, useContext, useState } from "react"; -import type { ReactNode } from "react"; -import type { AuthUser } from "../api/auth"; - -interface AuthContextValue { - user: AuthUser | null; - isAuthenticated: boolean; - setUser: (user: AuthUser | null) => void; -} +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, +} from "react"; +import api from "../api/axiosInstance"; +import { AuthUser, AuthContextValue } from "../api/auth"; -const AuthContext = createContext({ - user: null, - isAuthenticated: false, - setUser: () => {}, -}); +const AuthContext = createContext(null); -export function AuthProvider({ children }: { children: ReactNode }) { +export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const tokenRef = useRef(null); + tokenRef.current = accessToken; + + const silentRefresh = useCallback(async (): Promise => { + try { + const res = await api.post<{ accessToken: string; user: AuthUser }>( + "/auth/refresh", + ); + setAccessToken(res.data.accessToken); + setUser(res.data.user); + return res.data.accessToken; + } catch (error) { + setAccessToken(null); + setUser(null); + return null; + } + }, []); + + // On mount — attempt to rehydrate session from the refresh token cookie + useEffect(() => { + silentRefresh().finally(() => setIsLoading(false)); + }, [silentRefresh]); + + // Axios interceptors — registered once, live for the lifetime of the provider + useEffect(() => { + const reqId = api.interceptors.request.use((config) => { + const token = tokenRef.current; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + const resId = api.interceptors.response.use( + (response) => response, + async (error) => { + const original = error.config; + const is401 = error.response?.status === 401; + const alreadyRetried = original._retry; + const isRefreshCall = original.url?.includes("/auth/refresh"); + + if (is401 && !alreadyRetried && !isRefreshCall) { + original._retry = true; + const newToken = await silentRefresh(); + if (newToken) { + original.headers.Authorization = `Bearer ${newToken}`; + return api(original); + } + } + return Promise.reject(error); + }, + ); + + return () => { + api.interceptors.request.eject(reqId); + api.interceptors.response.eject(resId); + }; + }, [silentRefresh]); + + const login = useCallback(async (email: string, password: string) => { + const res = await api.post<{ accessToken: string; user: AuthUser }>( + "/auth/login", + { email, password }, + ); + setAccessToken(res.data.accessToken); + setUser(res.data.user); + }, []); + + const logout = useCallback(async () => { + try { + await api.post("/auth/logout"); + } finally { + setAccessToken(null); + setUser(null); + } + }, []); return ( - + {children} ); } -export function useAuth() { - return useContext(AuthContext); +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; } diff --git a/frontend/src/context/ProtectedRoute.tsx b/frontend/src/context/ProtectedRoute.tsx new file mode 100644 index 0000000..d1a4cfa --- /dev/null +++ b/frontend/src/context/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "./AuthContext"; +import React from "react"; + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, isLoading } = useAuth(); + + if (isLoading) return null; + + if (!user) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/hooks/useGoogleLogin.ts b/frontend/src/hooks/useGoogleLogin.ts index 4c2a0c2..f0cba25 100644 --- a/frontend/src/hooks/useGoogleLogin.ts +++ b/frontend/src/hooks/useGoogleLogin.ts @@ -1,24 +1,12 @@ import { useState } from "react"; -import { loginWithGoogle } from "../api/auth"; -import { useAuth } from "../context/AuthContext"; export function useGoogleLogin() { - const { setUser } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [isLoading] = useState(false); + const [error] = useState(null); - async function loginWithGoogleToken(googleToken: string) { - setIsLoading(true); - setError(null); - try { - const { user } = await loginWithGoogle(googleToken); - setUser(user); - } catch (e) { - setError(e instanceof Error ? e.message : "Google login failed"); - } finally { - setIsLoading(false); - } + async function loginWithGoogle(_googleToken: string) { + // TODO: implement when Google SSO is added } - return { loginWithGoogle: loginWithGoogleToken, isLoading, error }; + return { loginWithGoogle, isLoading, error }; } diff --git a/frontend/src/hooks/useLogin.ts b/frontend/src/hooks/useLogin.ts index 85a98e9..d9efc45 100644 --- a/frontend/src/hooks/useLogin.ts +++ b/frontend/src/hooks/useLogin.ts @@ -1,9 +1,8 @@ import { useState } from "react"; -import { loginUser } from "../api/auth"; import { useAuth } from "../context/AuthContext"; export function useLogin() { - const { setUser } = useAuth(); + const { login: authLogin } = useAuth(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -11,8 +10,7 @@ export function useLogin() { setIsLoading(true); setError(null); try { - const { user } = await loginUser(data); - setUser(user); + await authLogin(data.email, data.password); } catch (e) { setError(e instanceof Error ? e.message : "Login failed"); } finally { @@ -20,5 +18,5 @@ export function useLogin() { } } - return { login, isLoading, error }; + return { login, isLoading, error, clearError: () => setError(null) }; } diff --git a/frontend/src/hooks/useRegister.ts b/frontend/src/hooks/useRegister.ts index 05a5132..5e82ea1 100644 --- a/frontend/src/hooks/useRegister.ts +++ b/frontend/src/hooks/useRegister.ts @@ -1,9 +1,9 @@ import { useState } from "react"; -import { registerUser } from "../api/auth"; import { useAuth } from "../context/AuthContext"; +import api from "../api/axiosInstance"; export function useRegister() { - const { setUser } = useAuth(); + const { login } = useAuth(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -17,8 +17,8 @@ export function useRegister() { setIsLoading(true); setError(null); try { - const { user } = await registerUser(data); - setUser(user); + await api.post("/user/register", data); + await login(data.email, data.password); } catch (e) { setError(e instanceof Error ? e.message : "Registration failed"); } finally { @@ -26,5 +26,5 @@ export function useRegister() { } } - return { register, isLoading, error }; + return { register, isLoading, error, clearError: () => setError(null) }; } diff --git a/frontend/src/pages/authPage.tsx b/frontend/src/pages/authPage.tsx index 25c19a5..6c96c87 100644 --- a/frontend/src/pages/authPage.tsx +++ b/frontend/src/pages/authPage.tsx @@ -1,4 +1,5 @@ import { useReducer, useState } from "react"; +import { useNavigate } from "react-router-dom"; import Box from "@mui/material/Box"; import { useRegister } from "../hooks/useRegister"; import { useLogin } from "../hooks/useLogin"; @@ -18,6 +19,7 @@ interface AuthPageProps { } export default function AuthPage({ defaultMode = "signup" }: AuthPageProps) { + const navigate = useNavigate(); const [mode, setMode] = useState(defaultMode); const [warning, setWarning] = useState(null); const [formState, dispatch] = useReducer( @@ -29,12 +31,13 @@ export default function AuthPage({ defaultMode = "signup" }: AuthPageProps) { register, isLoading: registerLoading, error: registerError, + clearError: clearRegisterError, } = useRegister(); - const { login, isLoading: loginLoading, error: loginError } = useLogin(); + const { login, isLoading: loginLoading, error: loginError, clearError: clearLoginError } = useLogin(); const { loginWithGoogle, isLoading: googleLoading } = useGoogleLogin(); const isLoading = registerLoading || loginLoading || googleLoading; - const error = registerError ?? loginError; + let error = registerError ?? loginError; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -47,6 +50,7 @@ export default function AuthPage({ defaultMode = "signup" }: AuthPageProps) { } setWarning(null); await register({ firstName, lastName, username, email, password }); + if (!registerError) navigate("/"); } else { const { email, password } = formState; if (!email || !password) { @@ -55,8 +59,8 @@ export default function AuthPage({ defaultMode = "signup" }: AuthPageProps) { } setWarning(null); await login({ email, password }); + if (!loginError) navigate("/"); } - // TODO: navigate to "/" on success once auth backend is live } async function handleGoogleLogin() { @@ -67,6 +71,8 @@ export default function AuthPage({ defaultMode = "signup" }: AuthPageProps) { function toggleMode() { setMode((prev) => (prev === "signup" ? "login" : "signup")); setWarning(null); + clearRegisterError(); + clearLoginError(); dispatch({ type: "formReset" }); window.scrollTo({ top: 0, behavior: "instant" }); } diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 5b8f892..78dc0a3 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,16 +1,14 @@ -import { createBrowserRouter } from "react-router-dom"; +import { createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom"; import RootLayout from "./layouts/rootLayout"; import HomePage from "./pages/homePage"; import AuthPage from "./pages/authPage"; -export const router = createBrowserRouter([ - { - path: "/", - element: , - children: [ - { index: true, element: }, - { path: "login", element: }, - { path: "signup", element: }, - ], - }, -]); +export const router = createBrowserRouter( + createRoutesFromElements( + }> + } /> + } /> + } /> + + ) +);