diff --git a/backend/PHASE2_TEST_RESULTS.md b/backend/PHASE2_TEST_RESULTS.md new file mode 100644 index 0000000..a318d86 --- /dev/null +++ b/backend/PHASE2_TEST_RESULTS.md @@ -0,0 +1,116 @@ +# Auth v2 Phase 2: Households Backend - Implementation Summary + +## ✅ What was implemented + +### 1. Database Migration (004_households.sql) +- ✅ Created `households` table with id, name, invite_code, created_at +- ✅ Created `household_members` table for many-to-many user-household relationships +- ✅ Added `household_id` column to `shopping_items` table +- ✅ Added proper indexes for performance + +### 2. Household Service (`src/services/household.service.ts`) +- ✅ `createHousehold(userId, name)` - Creates household and makes user owner +- ✅ `joinHousehold(userId, inviteCode)` - User joins household with invite code +- ✅ `getMyHousehold(userId)` - Get user's household with member list +- ✅ `leaveHousehold(userId, householdId)` - Leave or delete household +- ✅ `regenerateInviteCode(userId, householdId)` - Owner-only invite code regeneration +- ✅ Automatic 8-character unique invite code generation +- ✅ Proper role management (owner/member) + +### 3. Household Routes (`src/routes/households.ts`) +- ✅ `POST /api/households` - Create household (authenticated) +- ✅ `GET /api/households/mine` - Get my household (authenticated) +- ✅ `POST /api/households/join` - Join with invite code (authenticated) +- ✅ `POST /api/households/:id/invite` - Regenerate invite (owner only) +- ✅ `DELETE /api/households/:id/leave` - Leave household (authenticated) + +### 4. Shopping Items Extended +- ✅ Updated `shopping.service.ts` to support user_id and household_id +- ✅ Added `scope` parameter support: `?scope=personal` vs `?scope=household` +- ✅ Personal shopping: user-specific items +- ✅ Household shopping: shared items for household members +- ✅ Proper access control - users can only modify their own or household items + +### 5. Favorites Per User +- ✅ Updated `recipe.service.ts` to use `user_favorites` table instead of `is_favorite` column +- ✅ `toggleFavorite(id, userId)` - Per-user favorites +- ✅ `listRecipes()` - Shows user-specific favorite status +- ✅ `GET /api/recipes?favorite=true` - Only user's favorites + +### 6. Notes Per User +- ✅ Updated `note.service.ts` to filter by user_id +- ✅ Users only see and can modify their own notes +- ✅ Maintains backward compatibility for unauthenticated access + +### 7. Routes Registration +- ✅ Added household routes to `src/app.ts` + +## ✅ Test Results + +All tests performed successfully with two test users: + +### User Management +- ✅ User registration and login working +- ✅ JWT tokens generated and accepted + +### Household Functionality +- ✅ **Household Creation**: User 1 created "Test Family" household with invite code EF27Y501 +- ✅ **Household Joining**: User 2 successfully joined using invite code +- ✅ **Member Roles**: User 1 = owner, User 2 = member +- ✅ **Invite Code Regeneration**: Owner (User 1) can regenerate → new code: 9IBNZMRM +- ✅ **Permission Control**: Member (User 2) cannot regenerate invite codes (403 Forbidden) + +### Shopping Lists +- ✅ **Personal Scope**: Each user sees only their own personal items + - User 1 personal: "User1 Personal Item" (2 pieces) + - User 2 personal: "User2 Personal Coffee" (1 bag) +- ✅ **Household Scope**: Both users see same household items + - "Household Milk" (1 liter) - added by User 1 + - "Household Bread" (2 loaves) - added by User 2 +- ✅ **Scope Isolation**: Personal and household lists completely separate + +### Favorites System +- ✅ **Per-User Favorites**: Each user has independent favorite recipes +- ✅ **Toggle Functionality**: User 1 favorites recipe → User 2 unfavorites same recipe +- ✅ **Isolated Lists**: User 1 has 1 favorite, User 2 has 0 favorites + +### Notes System +- ✅ **Per-User Notes**: Each user sees only their own notes on recipes +- ✅ **Content Isolation**: + - User 1 note: "User1 note: This is my favorite recipe!" + - User 2 note: "User2 note: Need to try this recipe soon." +- ✅ **Privacy**: Users cannot see other users' notes + +### API Endpoints Tested +``` +✅ POST /api/auth/register +✅ POST /api/households +✅ GET /api/households/mine +✅ POST /api/households/join +✅ POST /api/households/:id/invite +✅ GET /api/shopping?scope=personal +✅ GET /api/shopping?scope=household +✅ POST /api/shopping +✅ POST /api/shopping?scope=household +✅ PATCH /api/recipes/:id/favorite +✅ GET /api/recipes?favorite=true +✅ POST /api/recipes/:id/notes +✅ GET /api/recipes/:id/notes +``` + +## 🚀 System Status +- ✅ Database migrations applied successfully +- ✅ Backend server running on http://localhost:6001 +- ✅ All core household features working as specified +- ✅ Proper authentication and authorization +- ✅ Data isolation between users and households +- ✅ Backward compatibility maintained for non-authenticated access + +## 🔒 Security Features +- ✅ JWT-based authentication for all household operations +- ✅ Owner-only actions properly restricted +- ✅ User data isolation (personal shopping, favorites, notes) +- ✅ Household data sharing only between members +- ✅ Proper error handling and validation + +Phase 2 implementation **COMPLETE** and fully tested! 🎉 \ No newline at end of file diff --git a/backend/data/recipes.db b/backend/data/recipes.db index 159e90a..5957cee 100644 Binary files a/backend/data/recipes.db and b/backend/data/recipes.db differ diff --git a/backend/data/recipes.db-shm b/backend/data/recipes.db-shm index f36ce4b..b4e4986 100644 Binary files a/backend/data/recipes.db-shm and b/backend/data/recipes.db-shm differ diff --git a/backend/data/recipes.db-wal b/backend/data/recipes.db-wal index eb8f194..eb07115 100644 Binary files a/backend/data/recipes.db-wal and b/backend/data/recipes.db-wal differ diff --git a/backend/package-lock.json b/backend/package-lock.json index b223b7b..5016bb2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,18 +9,25 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.4.0", "@fastify/static": "^9.0.0", "@types/sharp": "^0.31.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", + "dotenv": "^17.3.1", "fastify": "^5.7.4", + "jsonwebtoken": "^9.0.3", "sharp": "^0.34.5", "ulid": "^3.0.2", "zod": "^4.3.6" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.2.3", "tsx": "^4.21.0", "typescript": "^5.9.3" @@ -521,6 +528,26 @@ "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/cors": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", @@ -608,6 +635,29 @@ ], "license": "MIT" }, + "node_modules/@fastify/jwt": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz", + "integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.0.2", + "fastify-plugin": "^5.0.1", + "steed": "^1.1.3" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -1206,6 +1256,16 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "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/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1216,6 +1276,24 @@ "@types/node": "*" } }, + "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/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": "25.2.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", @@ -1273,6 +1351,18 @@ } } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1334,6 +1424,20 @@ ], "license": "MIT" }, + "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/better-sqlite3": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", @@ -1368,6 +1472,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", @@ -1404,6 +1514,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/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1487,6 +1603,27 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1589,6 +1726,21 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-jwt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz", + "integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -1614,6 +1766,18 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fastify": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", @@ -1663,6 +1827,16 @@ ], "license": "MIT" }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -1672,6 +1846,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1850,6 +2034,49 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "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/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/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -1887,6 +2114,48 @@ ], "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/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", @@ -1920,6 +2189,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", @@ -1959,6 +2234,21 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "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/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1977,6 +2267,32 @@ "node": ">=10" } }, + "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-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/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -2236,6 +2552,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2392,6 +2714,19 @@ "node": ">= 0.8" } }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2548,6 +2883,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "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/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/backend/package.json b/backend/package.json index 1e82bad..b135364 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,18 +13,25 @@ "author": "", "license": "ISC", "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.4.0", "@fastify/static": "^9.0.0", "@types/sharp": "^0.31.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", + "dotenv": "^17.3.1", "fastify": "^5.7.4", + "jsonwebtoken": "^9.0.3", "sharp": "^0.34.5", "ulid": "^3.0.2", "zod": "^4.3.6" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.2.3", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/backend/src/app.ts b/backend/src/app.ts index 32c3cad..614238a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,5 +1,7 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; +import jwt from '@fastify/jwt'; +import cookie from '@fastify/cookie'; import { healthRoutes } from './routes/health.js'; import { categoryRoutes } from './routes/categories.js'; import { recipeRoutes } from './routes/recipes.js'; @@ -10,6 +12,7 @@ import { imageRoutes } from './routes/images.js'; import { botRoutes } from './routes/bot.js'; import { ogScrapeRoutes } from './routes/og-scrape.js'; import { authRoutes } from './routes/auth.js'; +import { householdRoutes } from './routes/households.js'; export async function buildApp() { const app = Fastify({ logger: true }); @@ -17,6 +20,19 @@ export async function buildApp() { await app.register(cors, { origin: true }); await app.after(); + // Register JWT plugin (though we handle JWT manually in auth service) + await app.register(jwt, { + secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production', + }); + await app.after(); + + // Register Cookie plugin for refresh tokens + await app.register(cookie, { + secret: process.env.COOKIE_SECRET || 'your-super-secret-cookie-key-change-in-production', + parseOptions: {}, + }); + await app.after(); + await app.register(healthRoutes); await app.after(); @@ -47,5 +63,8 @@ export async function buildApp() { await app.register(authRoutes); await app.after(); + await app.register(householdRoutes); + await app.after(); + return app; } diff --git a/backend/src/db/migrations/003_auth.sql b/backend/src/db/migrations/003_auth.sql new file mode 100644 index 0000000..9dd465b --- /dev/null +++ b/backend/src/db/migrations/003_auth.sql @@ -0,0 +1,43 @@ +-- Auth v2 Migration: Users and Authentication + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- User favorites table (replaces is_favorite column approach) +CREATE TABLE IF NOT EXISTS user_favorites ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, recipe_id) +); + +-- Add user_id to shopping_items (nullable for migration) +ALTER TABLE shopping_items ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL; + +-- Add user_id to notes (nullable for migration) +ALTER TABLE notes ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL; + +-- Add created_by to recipes (nullable for migration) +ALTER TABLE recipes ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL; + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id); +CREATE INDEX IF NOT EXISTS idx_user_favorites_recipe_id ON user_favorites(recipe_id); +CREATE INDEX IF NOT EXISTS idx_shopping_items_user_id ON shopping_items(user_id); +CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id); +CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by); + +-- Trigger for updating users.updated_at +CREATE TRIGGER IF NOT EXISTS users_update_timestamp AFTER UPDATE ON users BEGIN + UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id; +END; \ No newline at end of file diff --git a/backend/src/db/migrations/004_households.sql b/backend/src/db/migrations/004_households.sql new file mode 100644 index 0000000..6ce3021 --- /dev/null +++ b/backend/src/db/migrations/004_households.sql @@ -0,0 +1,27 @@ +-- Auth v2 Phase 2: Households + +-- Households table +CREATE TABLE IF NOT EXISTS households ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + invite_code TEXT UNIQUE NOT NULL, + created_at TEXT DEFAULT (datetime('now')) +); + +-- Household members table (many-to-many relationship between users and households) +CREATE TABLE IF NOT EXISTS household_members ( + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + joined_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (household_id, user_id) +); + +-- Add household_id to shopping_items for household shopping lists +ALTER TABLE shopping_items ADD COLUMN household_id TEXT REFERENCES households(id) ON DELETE CASCADE; + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_households_invite_code ON households(invite_code); +CREATE INDEX IF NOT EXISTS idx_household_members_household_id ON household_members(household_id); +CREATE INDEX IF NOT EXISTS idx_household_members_user_id ON household_members(user_id); +CREATE INDEX IF NOT EXISTS idx_shopping_items_household_id ON shopping_items(household_id); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 1a68dd5..5a39f50 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { buildApp } from './app.js'; import { runMigrations } from './db/migrate.js'; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index d0705f6..2275d99 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,7 +1,63 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; +import { authService } from '../services/auth.service.js'; -// v2: Auth middleware — currently passes through everything -export async function authMiddleware(request: FastifyRequest, _reply: FastifyReply) { - // TODO v2: Verify JWT token, set request.user - (request as any).user = { id: 'default', name: 'Luna' }; +declare module 'fastify' { + interface FastifyRequest { + user?: { + id: string; + email: string; + display_name: string; + }; + } +} + +// Required auth middleware - throws error if no valid token +export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) { + try { + const authHeader = request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return reply.status(401).send({ + success: false, + error: 'MISSING_TOKEN', + message: 'Authorization token required' + }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + const decoded = authService.verifyAccessToken(token); + + request.user = { + id: decoded.sub, + email: decoded.email, + display_name: decoded.display_name, + }; + } catch (error) { + return reply.status(401).send({ + success: false, + error: 'INVALID_TOKEN', + message: 'Invalid or expired token' + }); + } +} + +// Optional auth middleware - sets user if token is valid, but doesn't fail if missing +export async function optionalAuthMiddleware(request: FastifyRequest, _reply: FastifyReply) { + try { + const authHeader = request.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + const decoded = authService.verifyAccessToken(token); + + request.user = { + id: decoded.sub, + email: decoded.email, + display_name: decoded.display_name, + }; + } + } catch (error) { + // Silently ignore invalid tokens in optional auth + request.user = undefined; + } } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 818ace3..b578124 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,15 +1,308 @@ import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { authService } from '../services/auth.service.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const registerSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(8, 'Password must be at least 8 characters'), + display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long'), +}); + +const loginSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(1, 'Password is required'), +}); + +const updateProfileSchema = z.object({ + display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long').optional(), + avatar_url: z.string().url('Invalid avatar URL').or(z.literal('')).optional(), +}); + +const changePasswordSchema = z.object({ + current_password: z.string().min(1, 'Current password is required'), + new_password: z.string().min(8, 'New password must be at least 8 characters'), +}); + +const refreshTokenSchema = z.object({ + refresh_token: z.string().min(1, 'Refresh token is required'), +}); export async function authRoutes(app: FastifyInstance) { - app.post('/api/auth/login', async (_request, reply) => { - reply.status(501).send({ error: 'not implemented' }); + // POST /api/auth/register + app.post('/api/auth/register', async (request, reply) => { + try { + const data = registerSchema.parse(request.body); + + const result = await authService.register(data.email, data.password, data.display_name); + + // Set refresh token as httpOnly cookie + reply.setCookie('luna_refresh_token', result.tokens.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: '/', + }); + + reply.status(201).send({ + success: true, + user: result.user, + access_token: result.tokens.accessToken, + }); + } catch (error: any) { + if (error.message === 'EMAIL_EXISTS') { + return reply.status(400).send({ + success: false, + error: 'EMAIL_EXISTS', + message: 'An account with this email already exists', + }); + } + + if (error instanceof z.ZodError) { + const firstError = error.errors && error.errors.length > 0 ? error.errors[0] : null; + return reply.status(400).send({ + success: false, + error: 'VALIDATION_ERROR', + message: firstError?.message || 'Invalid data provided', + details: error.errors, + }); + } + + console.error('Register error:', error); + reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Registration failed', + }); + } }); - app.post('/api/auth/register', async (_request, reply) => { - reply.status(501).send({ error: 'not implemented' }); + // POST /api/auth/login + app.post('/api/auth/login', async (request, reply) => { + try { + const data = loginSchema.parse(request.body); + + const result = await authService.login(data.email, data.password); + + // Set refresh token as httpOnly cookie + reply.setCookie('luna_refresh_token', result.tokens.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: '/', + }); + + reply.send({ + success: true, + user: result.user, + access_token: result.tokens.accessToken, + }); + } catch (error: any) { + if (error.message === 'INVALID_CREDENTIALS') { + return reply.status(401).send({ + success: false, + error: 'INVALID_CREDENTIALS', + message: 'Invalid email or password', + }); + } + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + success: false, + error: 'VALIDATION_ERROR', + message: error.errors[0]?.message || 'Invalid data provided', + details: error.errors, + }); + } + + console.error('Login error:', error); + reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Login failed', + }); + } }); - app.get('/api/auth/me', async (_request, reply) => { - reply.status(501).send({ error: 'not implemented' }); + // POST /api/auth/logout + app.post('/api/auth/logout', async (request, reply) => { + // Clear refresh token cookie + reply.clearCookie('luna_refresh_token', { path: '/' }); + + reply.send({ + success: true, + message: 'Successfully logged out', + }); + }); + + // GET /api/auth/me (protected) + app.get('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => { + try { + const user = await authService.getProfile(request.user!.id); + + if (!user) { + return reply.status(404).send({ + success: false, + error: 'USER_NOT_FOUND', + message: 'User not found', + }); + } + + reply.send({ + success: true, + user, + }); + } catch (error) { + console.error('Get profile error:', error); + reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to get profile', + }); + } + }); + + // PUT /api/auth/me (protected) + app.put('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => { + try { + const data = updateProfileSchema.parse(request.body); + + const user = await authService.updateProfile(request.user!.id, data); + + reply.send({ + success: true, + user, + }); + } catch (error: any) { + if (error.message === 'USER_NOT_FOUND') { + return reply.status(404).send({ + success: false, + error: 'USER_NOT_FOUND', + message: 'User not found', + }); + } + + if (error.message === 'NO_UPDATES_PROVIDED') { + return reply.status(400).send({ + success: false, + error: 'NO_UPDATES_PROVIDED', + message: 'No updates provided', + }); + } + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + success: false, + error: 'VALIDATION_ERROR', + message: error.errors[0]?.message || 'Invalid data provided', + details: error.errors, + }); + } + + console.error('Update profile error:', error); + reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to update profile', + }); + } + }); + + // PUT /api/auth/me/password (protected) + app.put('/api/auth/me/password', { preHandler: authMiddleware }, async (request, reply) => { + try { + const data = changePasswordSchema.parse(request.body); + + await authService.changePassword(request.user!.id, data.current_password, data.new_password); + + reply.send({ + success: true, + message: 'Password changed successfully', + }); + } catch (error: any) { + if (error.message === 'USER_NOT_FOUND') { + return reply.status(404).send({ + success: false, + error: 'USER_NOT_FOUND', + message: 'User not found', + }); + } + + if (error.message === 'INVALID_CURRENT_PASSWORD') { + return reply.status(400).send({ + success: false, + error: 'INVALID_CURRENT_PASSWORD', + message: 'Current password is incorrect', + }); + } + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + success: false, + error: 'VALIDATION_ERROR', + message: error.errors[0]?.message || 'Invalid data provided', + details: error.errors, + }); + } + + console.error('Change password error:', error); + reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to change password', + }); + } + }); + + // POST /api/auth/refresh (for refreshing access tokens) + app.post('/api/auth/refresh', async (request, reply) => { + try { + const refreshToken = request.cookies.luna_refresh_token; + + if (!refreshToken) { + return reply.status(401).send({ + success: false, + error: 'MISSING_REFRESH_TOKEN', + message: 'Refresh token required', + }); + } + + const result = await authService.refreshTokens(refreshToken); + + // Set new refresh token as httpOnly cookie + reply.setCookie('luna_refresh_token', result.tokens.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: '/', + }); + + reply.send({ + success: true, + user: result.user, + access_token: result.tokens.accessToken, + }); + } catch (error: any) { + if (error.message === 'INVALID_REFRESH_TOKEN' || error.message === 'USER_NOT_FOUND') { + // Clear invalid refresh token cookie + reply.clearCookie('luna_refresh_token', { path: '/' }); + + return reply.status(401).send({ + success: false, + error: 'INVALID_REFRESH_TOKEN', + message: 'Invalid or expired refresh token', + }); + } + + console.error('Refresh token error:', error); + reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to refresh token', + }); + } }); } diff --git a/backend/src/routes/households.ts b/backend/src/routes/households.ts new file mode 100644 index 0000000..3c5a97f --- /dev/null +++ b/backend/src/routes/households.ts @@ -0,0 +1,178 @@ +import { FastifyInstance } from 'fastify'; +import { authMiddleware } from '../middleware/auth.js'; +import { householdService } from '../services/household.service.js'; + +export async function householdRoutes(app: FastifyInstance) { + // Create a new household + app.post('/api/households', { preHandler: [authMiddleware] }, async (request, reply) => { + const { name } = request.body as { name: string }; + + if (!name || name.trim() === '') { + return reply.status(400).send({ + success: false, + error: 'VALIDATION_ERROR', + message: 'Household name is required' + }); + } + + try { + const household = await householdService.createHousehold(request.user!.id, name.trim()); + return reply.status(201).send({ + success: true, + data: household + }); + } catch (error: any) { + if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') { + return reply.status(409).send({ + success: false, + error: 'USER_ALREADY_IN_HOUSEHOLD', + message: 'You are already a member of a household' + }); + } + + app.log.error(error); + return reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to create household' + }); + } + }); + + // Get my household + app.get('/api/households/mine', { preHandler: [authMiddleware] }, async (request, reply) => { + try { + const household = await householdService.getMyHousehold(request.user!.id); + + if (!household) { + return reply.status(404).send({ + success: false, + error: 'NO_HOUSEHOLD', + message: 'You are not a member of any household' + }); + } + + return reply.send({ + success: true, + data: household + }); + } catch (error: any) { + app.log.error(error); + return reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to get household' + }); + } + }); + + // Join a household with invite code + app.post('/api/households/join', { preHandler: [authMiddleware] }, async (request, reply) => { + const { inviteCode } = request.body as { inviteCode: string }; + + if (!inviteCode || inviteCode.trim() === '') { + return reply.status(400).send({ + success: false, + error: 'VALIDATION_ERROR', + message: 'Invite code is required' + }); + } + + try { + const household = await householdService.joinHousehold(request.user!.id, inviteCode.trim().toUpperCase()); + return reply.send({ + success: true, + data: household + }); + } catch (error: any) { + if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') { + return reply.status(409).send({ + success: false, + error: 'USER_ALREADY_IN_HOUSEHOLD', + message: 'You are already a member of a household' + }); + } + + if (error.message === 'INVALID_INVITE_CODE') { + return reply.status(404).send({ + success: false, + error: 'INVALID_INVITE_CODE', + message: 'Invalid invite code' + }); + } + + app.log.error(error); + return reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to join household' + }); + } + }); + + // Regenerate invite code (owner only) + app.post('/api/households/:id/invite', { preHandler: [authMiddleware] }, async (request, reply) => { + const { id } = request.params as { id: string }; + + try { + const newInviteCode = await householdService.regenerateInviteCode(request.user!.id, id); + + return reply.send({ + success: true, + data: { invite_code: newInviteCode } + }); + } catch (error: any) { + if (error.message === 'ONLY_OWNER_CAN_REGENERATE_INVITE') { + return reply.status(403).send({ + success: false, + error: 'FORBIDDEN', + message: 'Only household owners can regenerate invite codes' + }); + } + + app.log.error(error); + return reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to regenerate invite code' + }); + } + }); + + // Leave household + app.delete('/api/households/:id/leave', { preHandler: [authMiddleware] }, async (request, reply) => { + const { id } = request.params as { id: string }; + + try { + await householdService.leaveHousehold(request.user!.id, id); + + return reply.send({ + success: true, + message: 'Successfully left household' + }); + } catch (error: any) { + if (error.message === 'NOT_HOUSEHOLD_MEMBER') { + return reply.status(404).send({ + success: false, + error: 'NOT_HOUSEHOLD_MEMBER', + message: 'You are not a member of this household' + }); + } + + if (error.message === 'OWNER_CANNOT_LEAVE_WITH_MEMBERS') { + return reply.status(409).send({ + success: false, + error: 'OWNER_CANNOT_LEAVE_WITH_MEMBERS', + message: 'Household owners cannot leave while other members remain' + }); + } + + app.log.error(error); + return reply.status(500).send({ + success: false, + error: 'INTERNAL_ERROR', + message: 'Failed to leave household' + }); + } + }); +} \ No newline at end of file diff --git a/backend/src/routes/notes.ts b/backend/src/routes/notes.ts index df28020..a92525f 100644 --- a/backend/src/routes/notes.ts +++ b/backend/src/routes/notes.ts @@ -1,33 +1,44 @@ import { FastifyInstance } from 'fastify'; +import { optionalAuthMiddleware } from '../middleware/auth.js'; import * as svc from '../services/note.service.js'; export async function noteRoutes(app: FastifyInstance) { - app.get('/api/recipes/:id/notes', async (request) => { + app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => { const { id } = request.params as { id: string }; - return svc.listNotes(id); + const userId = request.user?.id; + + return svc.listNotes(id, userId); }); - app.post('/api/recipes/:id/notes', async (request, reply) => { + app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const { content } = request.body as { content: string }; + const userId = request.user?.id; + if (!content) return reply.status(400).send({ error: 'content required' }); - const note = svc.createNote(id, content); + + const note = svc.createNote(id, content, userId); if (!note) return reply.status(404).send({ error: 'Recipe not found' }); return reply.status(201).send(note); }); - app.put('/api/notes/:id', async (request, reply) => { + app.put('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const { content } = request.body as { content: string }; + const userId = request.user?.id; + if (!content) return reply.status(400).send({ error: 'content required' }); - const note = svc.updateNote(id, content); + + const note = svc.updateNote(id, content, userId); if (!note) return reply.status(404).send({ error: 'Not found' }); return note; }); - app.delete('/api/notes/:id', async (request, reply) => { + app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; - const ok = svc.deleteNote(id); + const userId = request.user?.id; + + const ok = svc.deleteNote(id, userId); if (!ok) return reply.status(404).send({ error: 'Not found' }); return { ok: true }; }); diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 1934c39..fc383f3 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -1,4 +1,5 @@ import { FastifyInstance } from 'fastify'; +import { optionalAuthMiddleware, authMiddleware } from '../middleware/auth.js'; import * as svc from '../services/recipe.service.js'; export async function recipeRoutes(app: FastifyInstance) { @@ -15,8 +16,10 @@ export async function recipeRoutes(app: FastifyInstance) { return { data: results, total: results.length }; }); - app.get('/api/recipes', async (request) => { + app.get('/api/recipes', { preHandler: [optionalAuthMiddleware] }, async (request) => { const query = request.query as any; + const userId = request.user?.id; + return svc.listRecipes({ page: query.page ? Number(query.page) : undefined, limit: query.limit ? Number(query.limit) : undefined, @@ -25,6 +28,7 @@ export async function recipeRoutes(app: FastifyInstance) { favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined, difficulty: query.difficulty, maxTime: query.maxTime ? Number(query.maxTime) : undefined, + userId: userId, }); }); @@ -56,9 +60,11 @@ export async function recipeRoutes(app: FastifyInstance) { return { ok: true }; }); - app.patch('/api/recipes/:id/favorite', async (request, reply) => { + app.patch('/api/recipes/:id/favorite', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; - const result = svc.toggleFavorite(id); + const userId = request.user?.id; + + const result = svc.toggleFavorite(id, userId); if (!result) return reply.status(404).send({ error: 'Not found' }); return result; }); diff --git a/backend/src/routes/shopping.ts b/backend/src/routes/shopping.ts index 7f705a2..1f1aab4 100644 --- a/backend/src/routes/shopping.ts +++ b/backend/src/routes/shopping.ts @@ -1,40 +1,92 @@ import { FastifyInstance } from 'fastify'; +import { optionalAuthMiddleware } from '../middleware/auth.js'; import * as svc from '../services/shopping.service.js'; export async function shoppingRoutes(app: FastifyInstance) { - app.get('/api/shopping', async () => { - return svc.listItems(); + // List shopping items with optional authentication + app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => { + const { scope } = request.query as { scope?: 'personal' | 'household' }; + const userId = request.user?.id; + const shoppingScope = scope || 'personal'; + + return svc.listItems(userId, shoppingScope); }); - app.post('/api/shopping/from-recipe/:id', async (request, reply) => { + // Add items from recipe with optional authentication + app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; - const items = svc.addFromRecipe(id); - if (!items) return reply.status(404).send({ error: 'Recipe not found' }); - return reply.status(201).send({ added: items.length }); + const { scope } = request.query as { scope?: 'personal' | 'household' }; + const userId = request.user?.id; + const shoppingScope = scope || 'personal'; + + try { + const items = svc.addFromRecipe(id, userId, shoppingScope); + if (!items) return reply.status(404).send({ error: 'Recipe not found' }); + return reply.status(201).send({ added: items.length }); + } catch (error: any) { + if (error.message === 'USER_NOT_IN_HOUSEHOLD') { + return reply.status(400).send({ error: 'You are not a member of any household' }); + } + throw error; + } }); - app.post('/api/shopping', async (request, reply) => { + // Add single item with optional authentication + app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string }; + const { scope } = request.query as { scope?: 'personal' | 'household' }; + const userId = request.user?.id; + const shoppingScope = scope || 'personal'; + if (!name) return reply.status(400).send({ error: 'name required' }); - const item = svc.addItem(name, amount, unit); - return reply.status(201).send(item); + + try { + const item = svc.addItem(name, amount, unit, userId, shoppingScope); + return reply.status(201).send(item); + } catch (error: any) { + if (error.message === 'USER_NOT_IN_HOUSEHOLD') { + return reply.status(400).send({ error: 'You are not a member of any household' }); + } + throw error; + } }); - app.patch('/api/shopping/:id/check', async (request, reply) => { + // Toggle check status with optional authentication + app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; - const item = svc.toggleCheck(id); + const userId = request.user?.id; + + const item = svc.toggleCheck(id, userId); if (!item) return reply.status(404).send({ error: 'Not found' }); return item; }); - app.delete('/api/shopping/checked', async () => { - const count = svc.deleteChecked(); + // Delete all items with optional authentication + app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => { + const { scope } = request.query as { scope?: 'personal' | 'household' }; + const userId = request.user?.id; + const shoppingScope = scope || 'personal'; + + const count = svc.deleteAll(userId, shoppingScope); return { ok: true, deleted: count }; }); - app.delete('/api/shopping/:id', async (request, reply) => { + // Delete checked items with optional authentication + app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => { + const { scope } = request.query as { scope?: 'personal' | 'household' }; + const userId = request.user?.id; + const shoppingScope = scope || 'personal'; + + const count = svc.deleteChecked(userId, shoppingScope); + return { ok: true, deleted: count }; + }); + + // Delete single item with optional authentication + app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; - const ok = svc.deleteItem(id); + const userId = request.user?.id; + + const ok = svc.deleteItem(id, userId); if (!ok) return reply.status(404).send({ error: 'Not found' }); return { ok: true }; }); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts new file mode 100644 index 0000000..b5a110b --- /dev/null +++ b/backend/src/services/auth.service.ts @@ -0,0 +1,200 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { ulid } from 'ulid'; +import { getDb } from '../db/connection.js'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production'; +const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key-change-in-production'; + +const BCRYPT_ROUNDS = 12; +const ACCESS_TOKEN_EXPIRES_IN = '15m'; +const REFRESH_TOKEN_EXPIRES_IN = '7d'; + +export interface User { + id: string; + email: string; + display_name: string; + avatar_url?: string; + created_at: string; + updated_at: string; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +export interface AuthResult { + user: User; + tokens: AuthTokens; +} + +class AuthService { + private db = getDb(); + + async register(email: string, password: string, displayName: string): Promise { + // Check if user already exists + const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existingUser) { + throw new Error('EMAIL_EXISTS'); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + // Create user + const userId = ulid(); + const now = new Date().toISOString(); + + this.db.prepare(` + INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(userId, email, passwordHash, displayName, now, now); + + // Get created user + const user = this.db.prepare(` + SELECT id, email, display_name, avatar_url, created_at, updated_at + FROM users WHERE id = ? + `).get(userId) as User; + + // Generate tokens + const tokens = this.generateTokens(user); + + return { user, tokens }; + } + + async login(email: string, password: string): Promise { + // Get user by email + const userWithPassword = this.db.prepare(` + SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at + FROM users WHERE email = ? + `).get(email) as any; + + if (!userWithPassword) { + throw new Error('INVALID_CREDENTIALS'); + } + + // Check password + const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash); + if (!passwordValid) { + throw new Error('INVALID_CREDENTIALS'); + } + + // Remove password from user object + const { password_hash, ...user } = userWithPassword; + + // Generate tokens + const tokens = this.generateTokens(user); + + return { user, tokens }; + } + + async getProfile(userId: string): Promise { + const user = this.db.prepare(` + SELECT id, email, display_name, avatar_url, created_at, updated_at + FROM users WHERE id = ? + `).get(userId) as User | undefined; + + return user || null; + } + + async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise { + const updates: string[] = []; + const values: any[] = []; + + if (data.display_name !== undefined) { + updates.push('display_name = ?'); + values.push(data.display_name); + } + + if (data.avatar_url !== undefined) { + updates.push('avatar_url = ?'); + values.push(data.avatar_url); + } + + if (updates.length === 0) { + throw new Error('NO_UPDATES_PROVIDED'); + } + + updates.push('updated_at = ?'); + values.push(new Date().toISOString()); + values.push(userId); + + this.db.prepare(` + UPDATE users SET ${updates.join(', ')} WHERE id = ? + `).run(...values); + + const user = await this.getProfile(userId); + if (!user) { + throw new Error('USER_NOT_FOUND'); + } + + return user; + } + + async changePassword(userId: string, currentPassword: string, newPassword: string): Promise { + // Get current password hash + const userWithPassword = this.db.prepare(` + SELECT password_hash FROM users WHERE id = ? + `).get(userId) as any; + + if (!userWithPassword) { + throw new Error('USER_NOT_FOUND'); + } + + // Verify current password + const currentPasswordValid = await bcrypt.compare(currentPassword, userWithPassword.password_hash); + if (!currentPasswordValid) { + throw new Error('INVALID_CURRENT_PASSWORD'); + } + + // Hash new password + const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + + // Update password + this.db.prepare(` + UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? + `).run(newPasswordHash, new Date().toISOString(), userId); + + return true; + } + + async refreshTokens(refreshToken: string): Promise { + try { + const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any; + const user = await this.getProfile(decoded.sub); + + if (!user) { + throw new Error('USER_NOT_FOUND'); + } + + const tokens = this.generateTokens(user); + return { user, tokens }; + } catch (error) { + throw new Error('INVALID_REFRESH_TOKEN'); + } + } + + verifyAccessToken(token: string): any { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + throw new Error('INVALID_ACCESS_TOKEN'); + } + } + + private generateTokens(user: User): AuthTokens { + const payload = { + sub: user.id, + email: user.email, + display_name: user.display_name, + }; + + const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN }); + const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN }); + + return { accessToken, refreshToken }; + } +} + +export const authService = new AuthService(); \ No newline at end of file diff --git a/backend/src/services/household.service.ts b/backend/src/services/household.service.ts new file mode 100644 index 0000000..ce95502 --- /dev/null +++ b/backend/src/services/household.service.ts @@ -0,0 +1,218 @@ +import { ulid } from 'ulid'; +import { getDb } from '../db/connection.js'; + +export interface Household { + id: string; + name: string; + invite_code: string; + created_at: string; +} + +export interface HouseholdMember { + user_id: string; + email: string; + display_name: string; + avatar_url?: string; + role: 'owner' | 'member'; + joined_at: string; +} + +export interface HouseholdWithMembers extends Household { + members: HouseholdMember[]; +} + +class HouseholdService { + private db = getDb(); + + // Generate a random 8-character invite code + private generateInviteCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + // Ensure invite code is unique + private generateUniqueInviteCode(): string { + let code: string; + let attempts = 0; + do { + code = this.generateInviteCode(); + attempts++; + if (attempts > 100) { + throw new Error('Could not generate unique invite code'); + } + } while (this.db.prepare('SELECT id FROM households WHERE invite_code = ?').get(code)); + + return code; + } + + async createHousehold(userId: string, name: string): Promise { + // Check if user is already in a household + const existingMembership = this.db.prepare(` + SELECT household_id FROM household_members WHERE user_id = ? + `).get(userId); + + if (existingMembership) { + throw new Error('USER_ALREADY_IN_HOUSEHOLD'); + } + + const householdId = ulid(); + const inviteCode = this.generateUniqueInviteCode(); + + // Create household + this.db.prepare(` + INSERT INTO households (id, name, invite_code) + VALUES (?, ?, ?) + `).run(householdId, name, inviteCode); + + // Add user as owner + this.db.prepare(` + INSERT INTO household_members (household_id, user_id, role) + VALUES (?, ?, 'owner') + `).run(householdId, userId); + + // Return household with members + const household = await this.getMyHousehold(userId); + if (!household) { + throw new Error('Failed to create household'); + } + + return household; + } + + async joinHousehold(userId: string, inviteCode: string): Promise { + // Check if user is already in a household + const existingMembership = this.db.prepare(` + SELECT household_id FROM household_members WHERE user_id = ? + `).get(userId); + + if (existingMembership) { + throw new Error('USER_ALREADY_IN_HOUSEHOLD'); + } + + // Find household by invite code + const household = this.db.prepare(` + SELECT id FROM households WHERE invite_code = ? + `).get(inviteCode) as { id: string } | undefined; + + if (!household) { + throw new Error('INVALID_INVITE_CODE'); + } + + // Add user as member + this.db.prepare(` + INSERT INTO household_members (household_id, user_id, role) + VALUES (?, ?, 'member') + `).run(household.id, userId); + + // Return household with members + const result = await this.getMyHousehold(userId); + if (!result) { + throw new Error('Failed to join household'); + } + + return result; + } + + async getMyHousehold(userId: string): Promise { + // Get user's household + const householdMembership = this.db.prepare(` + SELECT h.*, hm.role + FROM households h + JOIN household_members hm ON h.id = hm.household_id + WHERE hm.user_id = ? + `).get(userId) as (Household & { role: 'owner' | 'member' }) | undefined; + + if (!householdMembership) { + return null; + } + + // Get all members of the household + const members = this.db.prepare(` + SELECT + u.id as user_id, + u.email, + u.display_name, + u.avatar_url, + hm.role, + hm.joined_at + FROM household_members hm + JOIN users u ON hm.user_id = u.id + WHERE hm.household_id = ? + ORDER BY hm.role DESC, hm.joined_at ASC + `).all(householdMembership.id) as HouseholdMember[]; + + const { role, ...household } = householdMembership; + + return { + ...household, + members + }; + } + + async leaveHousehold(userId: string, householdId: string): Promise { + // Check if user is member of this household + const membership = this.db.prepare(` + SELECT role FROM household_members + WHERE user_id = ? AND household_id = ? + `).get(userId, householdId) as { role: string } | undefined; + + if (!membership) { + throw new Error('NOT_HOUSEHOLD_MEMBER'); + } + + // If user is owner, check if there are other members + if (membership.role === 'owner') { + const memberCount = this.db.prepare(` + SELECT COUNT(*) as count FROM household_members WHERE household_id = ? + `).get(householdId) as { count: number }; + + if (memberCount.count > 1) { + throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS'); + } + + // If owner is the only member, delete the entire household + this.db.prepare('DELETE FROM households WHERE id = ?').run(householdId); + } else { + // Remove member from household + this.db.prepare(` + DELETE FROM household_members + WHERE user_id = ? AND household_id = ? + `).run(userId, householdId); + } + } + + async regenerateInviteCode(userId: string, householdId: string): Promise { + // Check if user is owner of this household + const membership = this.db.prepare(` + SELECT role FROM household_members + WHERE user_id = ? AND household_id = ? + `).get(userId, householdId) as { role: string } | undefined; + + if (!membership || membership.role !== 'owner') { + throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE'); + } + + const newInviteCode = this.generateUniqueInviteCode(); + + this.db.prepare(` + UPDATE households SET invite_code = ? WHERE id = ? + `).run(newInviteCode, householdId); + + return newInviteCode; + } + + async getHouseholdByInviteCode(inviteCode: string): Promise { + const household = this.db.prepare(` + SELECT id, name, invite_code, created_at + FROM households WHERE invite_code = ? + `).get(inviteCode) as Household | undefined; + + return household || null; + } +} + +export const householdService = new HouseholdService(); \ No newline at end of file diff --git a/backend/src/services/note.service.ts b/backend/src/services/note.service.ts index 57b83c1..092898f 100644 --- a/backend/src/services/note.service.ts +++ b/backend/src/services/note.service.ts @@ -1,26 +1,64 @@ import { getDb } from '../db/connection.js'; import { ulid } from 'ulid'; -export function listNotes(recipeId: string) { - return getDb().prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipeId); +export function listNotes(recipeId: string, userId?: string) { + const db = getDb(); + + if (!userId) { + // Legacy: return all notes without user filtering + return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id IS NULL ORDER BY created_at DESC').all(recipeId); + } + + // Return only user's notes + return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id = ? ORDER BY created_at DESC').all(recipeId, userId); } -export function createNote(recipeId: string, content: string) { +export function createNote(recipeId: string, content: string, userId?: string) { const db = getDb(); const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId); if (!recipe) return null; + const id = ulid(); - db.prepare('INSERT INTO notes (id, recipe_id, content) VALUES (?, ?, ?)').run(id, recipeId, content); + db.prepare('INSERT INTO notes (id, recipe_id, content, user_id) VALUES (?, ?, ?, ?)').run(id, recipeId, content, userId || null); return db.prepare('SELECT * FROM notes WHERE id = ?').get(id); } -export function updateNote(id: string, content: string) { +export function updateNote(id: string, content: string, userId?: string) { const db = getDb(); - const result = db.prepare('UPDATE notes SET content = ? WHERE id = ?').run(content, id); + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: update notes without user filtering + query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id IS NULL'; + params = [content, id]; + } else { + // Update only if note belongs to user + query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id = ?'; + params = [content, id, userId]; + } + + const result = db.prepare(query).run(...params); if (result.changes === 0) return null; return db.prepare('SELECT * FROM notes WHERE id = ?').get(id); } -export function deleteNote(id: string): boolean { - return getDb().prepare('DELETE FROM notes WHERE id = ?').run(id).changes > 0; +export function deleteNote(id: string, userId?: string): boolean { + const db = getDb(); + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: delete notes without user filtering + query = 'DELETE FROM notes WHERE id = ? AND user_id IS NULL'; + params = [id]; + } else { + // Delete only if note belongs to user + query = 'DELETE FROM notes WHERE id = ? AND user_id = ?'; + params = [id, userId]; + } + + return db.prepare(query).run(...params).changes > 0; } diff --git a/backend/src/services/recipe.service.ts b/backend/src/services/recipe.service.ts index 8d0302a..91ec634 100644 --- a/backend/src/services/recipe.service.ts +++ b/backend/src/services/recipe.service.ts @@ -63,7 +63,7 @@ function mapTimeFields(row: any) { export function listRecipes(opts: { page?: number; limit?: number; category_id?: string; category_slug?: string; - favorite?: boolean; difficulty?: string; maxTime?: number; + favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string; }) { const db = getDb(); const page = opts.page || 1; @@ -74,15 +74,30 @@ export function listRecipes(opts: { if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); } if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); } - if (opts.favorite !== undefined) { conditions.push('r.is_favorite = ?'); params.push(opts.favorite ? 1 : 0); } + if (opts.favorite !== undefined && opts.userId) { + conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL')); + } if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); } if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); } + let joins = 'LEFT JOIN categories c ON r.category_id = c.id'; + if (opts.userId) { + joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?'; + params.unshift(opts.userId); + } + const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''; - const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where}`).get(...params) as any; + + // Adjust parameter positions based on whether userId is included + const countParams = opts.userId ? [opts.userId, ...params.slice(1)] : params; + const dataParams = opts.userId ? [opts.userId, ...params.slice(1), limit, offset] : [...params, limit, offset]; + + const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`).get(...countParams) as any; const rows = db.prepare( - `SELECT r.*, c.name as category_name, c.slug as category_slug FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?` - ).all(...params, limit, offset); + `SELECT r.*, c.name as category_name, c.slug as category_slug, + ${opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite'} + FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?` + ).all(...dataParams); const data = rows.map(mapTimeFields); return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) }; @@ -205,13 +220,34 @@ export function deleteRecipe(id: string): boolean { return result.changes > 0; } -export function toggleFavorite(id: string) { +export function toggleFavorite(id: string, userId?: string) { const db = getDb(); - const recipe = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any; + const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any; if (!recipe) return null; - const newVal = recipe.is_favorite ? 0 : 1; - db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id); - return { id, is_favorite: newVal }; + + // If no user authentication, fallback to old is_favorite column + if (!userId) { + const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any; + const newVal = recipeWithFavorite.is_favorite ? 0 : 1; + db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id); + return { id, is_favorite: newVal }; + } + + // Check if recipe is already favorited by user + const existing = db.prepare( + 'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?' + ).get(userId, id) as any; + + if (existing) { + // Remove from favorites + db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id); + return { id, is_favorite: false }; + } else { + // Add to favorites + const favoriteId = ulid(); + db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id); + return { id, is_favorite: true }; + } } export function getRandomRecipe() { diff --git a/backend/src/services/shopping.service.ts b/backend/src/services/shopping.service.ts index 8d69e66..2397b6f 100644 --- a/backend/src/services/shopping.service.ts +++ b/backend/src/services/shopping.service.ts @@ -1,14 +1,48 @@ import { getDb } from '../db/connection.js'; import { ulid } from 'ulid'; -export function listItems() { +export type ShoppingScope = 'personal' | 'household'; + +export function listItems(userId?: string, scope: ShoppingScope = 'personal') { const db = getDb(); - const items = db.prepare(` - SELECT si.*, r.title as recipe_title - FROM shopping_items si - LEFT JOIN recipes r ON si.recipe_id = r.id - ORDER BY si.checked, si.created_at DESC - `).all() as any[]; + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: no user authentication, return all items + query = ` + SELECT si.*, r.title as recipe_title + FROM shopping_items si + LEFT JOIN recipes r ON si.recipe_id = r.id + WHERE si.user_id IS NULL AND si.household_id IS NULL + ORDER BY si.checked, si.created_at DESC + `; + params = []; + } else if (scope === 'household') { + // Get household shopping list + query = ` + SELECT si.*, r.title as recipe_title + FROM shopping_items si + LEFT JOIN recipes r ON si.recipe_id = r.id + LEFT JOIN household_members hm ON si.household_id = hm.household_id + WHERE hm.user_id = ? AND si.household_id IS NOT NULL + ORDER BY si.checked, si.created_at DESC + `; + params = [userId]; + } else { + // Get personal shopping list + query = ` + SELECT si.*, r.title as recipe_title + FROM shopping_items si + LEFT JOIN recipes r ON si.recipe_id = r.id + WHERE si.user_id = ? AND si.household_id IS NULL + ORDER BY si.checked, si.created_at DESC + `; + params = [userId]; + } + + const items = db.prepare(query).all(...params) as any[]; const grouped: Record = {}; for (const item of items) { @@ -21,44 +55,183 @@ export function listItems() { return Object.values(grouped); } -export function addFromRecipe(recipeId: string) { +export function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') { const db = getDb(); const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId); if (!recipe) return null; + const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[]; - const insert = db.prepare('INSERT INTO shopping_items (id, name, amount, unit, recipe_id) VALUES (?, ?, ?, ?, ?)'); + + let householdId = null; + if (userId && scope === 'household') { + // Get user's household + const membership = db.prepare(` + SELECT household_id FROM household_members WHERE user_id = ? + `).get(userId) as { household_id: string } | undefined; + + if (!membership) { + throw new Error('USER_NOT_IN_HOUSEHOLD'); + } + householdId = membership.household_id; + } + + const insert = db.prepare(` + INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const added: any[] = []; const txn = db.transaction(() => { for (const ing of ingredients) { const id = ulid(); - insert.run(id, ing.name, ing.amount, ing.unit, recipeId); - added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, checked: 0 }); + insert.run(id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId); + added.push({ + id, + name: ing.name, + amount: ing.amount, + unit: ing.unit, + recipe_id: recipeId, + user_id: userId || null, + household_id: householdId, + checked: 0 + }); } }); txn(); return added; } -export function addItem(name: string, amount?: number, unit?: string) { +export function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') { const db = getDb(); + + let householdId = null; + if (userId && scope === 'household') { + // Get user's household + const membership = db.prepare(` + SELECT household_id FROM household_members WHERE user_id = ? + `).get(userId) as { household_id: string } | undefined; + + if (!membership) { + throw new Error('USER_NOT_IN_HOUSEHOLD'); + } + householdId = membership.household_id; + } + const id = ulid(); - db.prepare('INSERT INTO shopping_items (id, name, amount, unit) VALUES (?, ?, ?, ?)').run(id, name, amount ?? null, unit ?? null); + db.prepare(` + INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, name, amount ?? null, unit ?? null, userId || null, householdId); + return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id); } -export function toggleCheck(id: string) { +export function toggleCheck(id: string, userId?: string) { const db = getDb(); - const item = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id) as any; + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: no user authentication + query = 'SELECT * FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL'; + params = [id]; + } else { + // Check if item belongs to user (personal) or their household + query = ` + SELECT si.* FROM shopping_items si + LEFT JOIN household_members hm ON si.household_id = hm.household_id + WHERE si.id = ? AND (si.user_id = ? OR hm.user_id = ?) + `; + params = [id, userId, userId]; + } + + const item = db.prepare(query).get(...params) as any; if (!item) return null; + const newVal = item.checked ? 0 : 1; db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id); return { ...item, checked: newVal }; } -export function deleteItem(id: string): boolean { - return getDb().prepare('DELETE FROM shopping_items WHERE id = ?').run(id).changes > 0; +export function deleteItem(id: string, userId?: string): boolean { + const db = getDb(); + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: no user authentication + query = 'DELETE FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL'; + params = [id]; + } else { + // Delete only if item belongs to user (personal) or their household + query = ` + DELETE FROM shopping_items + WHERE id = ? AND id IN ( + SELECT si.id FROM shopping_items si + LEFT JOIN household_members hm ON si.household_id = hm.household_id + WHERE si.user_id = ? OR hm.user_id = ? + ) + `; + params = [id, userId, userId]; + } + + return db.prepare(query).run(...params).changes > 0; } -export function deleteChecked(): number { - return getDb().prepare('DELETE FROM shopping_items WHERE checked = 1').run().changes; +export function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): number { + const db = getDb(); + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: no user authentication + query = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL'; + params = []; + } else if (scope === 'household') { + // Delete all household items + query = ` + DELETE FROM shopping_items + WHERE household_id IN ( + SELECT household_id FROM household_members WHERE user_id = ? + ) + `; + params = [userId]; + } else { + // Delete all personal items + query = 'DELETE FROM shopping_items WHERE user_id = ? AND household_id IS NULL'; + params = [userId]; + } + + return db.prepare(query).run(...params).changes; +} + +export function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): number { + const db = getDb(); + + let query: string; + let params: any[]; + + if (!userId) { + // Legacy: no user authentication + query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id IS NULL AND household_id IS NULL'; + params = []; + } else if (scope === 'household') { + // Delete checked household items + query = ` + DELETE FROM shopping_items + WHERE checked = 1 AND household_id IN ( + SELECT household_id FROM household_members WHERE user_id = ? + ) + `; + params = [userId]; + } else { + // Delete checked personal items + query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id = ? AND household_id IS NULL'; + params = [userId]; + } + + return db.prepare(query).run(...params).changes; } diff --git a/features/AUTH-V2-SPEC.md b/features/AUTH-V2-SPEC.md new file mode 100644 index 0000000..fe9d073 --- /dev/null +++ b/features/AUTH-V2-SPEC.md @@ -0,0 +1,757 @@ +# Luna Recipes — Auth v2 Feature-Spezifikation + +## Übersicht + +Implementierung eines vollständigen Authentifizierungssystems für die Luna Rezept-App mit Multi-User-Support und Haushaltsfunktionalität. Das System ermöglicht es mehreren Nutzern, die App zu verwenden, dabei ihre Daten zu trennen und optional einen gemeinsamen Haushalt zu teilen. + +## 1. Datenmodell + +### 1.1 Neue Tabellen + +#### users +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_users_email ON users(email); +``` + +#### households +```sql +CREATE TABLE households ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + invite_code TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_households_invite_code ON households(invite_code); +``` + +#### household_members +```sql +CREATE TABLE household_members ( + household_id UUID REFERENCES households(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (household_id, user_id) +); +``` + +#### user_favorites (Neue Tabelle) +```sql +CREATE TABLE user_favorites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + recipe_id UUID REFERENCES recipes(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, recipe_id) +); +``` + +### 1.2 Erweiterte bestehende Tabellen + +#### shopping_items +```sql +-- Erweitern um User- und Haushaltszuordnung +ALTER TABLE shopping_items ADD COLUMN user_id UUID REFERENCES users(id); +ALTER TABLE shopping_items ADD COLUMN household_id UUID REFERENCES households(id); + +-- Migration: NULL user_id wird beim ersten Login zugewiesen +``` + +#### notes +```sql +-- Erweitern um User-Zuordnung +ALTER TABLE notes ADD COLUMN user_id UUID REFERENCES users(id); + +-- Migration: Bestehende Notizen werden dem ersten registrierten User zugewiesen +``` + +#### recipes +```sql +-- Recipes bleiben global, aber mit Ersteller-Info +ALTER TABLE recipes ADD COLUMN created_by UUID REFERENCES users(id); + +-- Migration: Bestehende Rezepte werden dem ersten registrierten User zugewiesen +``` + +### 1.3 Daten-Ownership + +- **Recipes:** Global sichtbar, `created_by` zur Information +- **Shopping Items:** Pro User ODER pro Haushalt (wenn `household_id` gesetzt) +- **Favorites:** Pro User (user_favorites Tabelle) +- **Notes:** Pro User +- **Households:** Gemeinsam für alle Mitglieder + +## 2. API Endpoints + +### 2.1 Authentication + +#### POST /api/auth/register +```json +// Request +{ + "email": "luna@example.com", + "password": "secure123!", + "display_name": "Luna" +} + +// Response (201 Created) +{ + "success": true, + "user": { + "id": "uuid-here", + "email": "luna@example.com", + "display_name": "Luna", + "avatar_url": null, + "created_at": "2026-02-18T15:30:00Z" + }, + "access_token": "jwt-token-here" +} + +// Error (400 Bad Request) +{ + "success": false, + "error": "EMAIL_EXISTS", + "message": "Ein Account mit dieser E-Mail existiert bereits" +} +``` + +#### POST /api/auth/login +```json +// Request +{ + "email": "luna@example.com", + "password": "secure123!" +} + +// Response (200 OK) +{ + "success": true, + "user": { + "id": "uuid-here", + "email": "luna@example.com", + "display_name": "Luna", + "avatar_url": null + }, + "access_token": "jwt-token-here" +} + +// Error (401 Unauthorized) +{ + "success": false, + "error": "INVALID_CREDENTIALS", + "message": "E-Mail oder Passwort ungültig" +} +``` + +#### POST /api/auth/logout +```json +// Response (200 OK) +{ + "success": true, + "message": "Erfolgreich abgemeldet" +} +``` + +#### GET /api/auth/me +```json +// Response (200 OK) +{ + "success": true, + "user": { + "id": "uuid-here", + "email": "luna@example.com", + "display_name": "Luna", + "avatar_url": "https://example.com/avatar.jpg", + "household": { + "id": "household-uuid", + "name": "Luna & Marc", + "role": "owner" + } + } +} +``` + +#### PUT /api/auth/me +```json +// Request (Profil bearbeiten) +{ + "display_name": "Luna Schmidt", + "avatar_url": "https://example.com/new-avatar.jpg" +} + +// Response (200 OK) +{ + "success": true, + "user": { + "id": "uuid-here", + "email": "luna@example.com", + "display_name": "Luna Schmidt", + "avatar_url": "https://example.com/new-avatar.jpg" + } +} +``` + +#### PUT /api/auth/me/password +```json +// Request +{ + "current_password": "old123!", + "new_password": "new456!" +} + +// Response (200 OK) +{ + "success": true, + "message": "Passwort erfolgreich geändert" +} + +// Error (400 Bad Request) +{ + "success": false, + "error": "INVALID_CURRENT_PASSWORD", + "message": "Aktuelles Passwort ist falsch" +} +``` + +### 2.2 Households + +#### POST /api/households +```json +// Request (Haushalt erstellen) +{ + "name": "Luna & Marc" +} + +// Response (201 Created) +{ + "success": true, + "household": { + "id": "household-uuid", + "name": "Luna & Marc", + "invite_code": "COOK2024", + "created_at": "2026-02-18T15:30:00Z", + "members": [ + { + "user_id": "user-uuid", + "display_name": "Luna", + "role": "owner", + "joined_at": "2026-02-18T15:30:00Z" + } + ] + } +} +``` + +#### POST /api/households/:id/invite +```json +// Response (200 OK) - Neuen Einladungscode generieren +{ + "success": true, + "invite_code": "COOK2025", + "expires_at": "2026-02-25T15:30:00Z" +} +``` + +#### POST /api/households/join +```json +// Request +{ + "invite_code": "COOK2024" +} + +// Response (200 OK) +{ + "success": true, + "household": { + "id": "household-uuid", + "name": "Luna & Marc", + "role": "member" + } +} + +// Error (400 Bad Request) +{ + "success": false, + "error": "INVALID_INVITE_CODE", + "message": "Einladungscode ungültig oder abgelaufen" +} +``` + +#### GET /api/households/mine +```json +// Response (200 OK) +{ + "success": true, + "household": { + "id": "household-uuid", + "name": "Luna & Marc", + "invite_code": "COOK2024", + "role": "owner", + "members": [ + { + "user_id": "user1-uuid", + "display_name": "Luna", + "role": "owner", + "joined_at": "2026-02-18T15:30:00Z" + }, + { + "user_id": "user2-uuid", + "display_name": "Marc", + "role": "member", + "joined_at": "2026-02-19T10:15:00Z" + } + ] + } +} +``` + +## 3. Frontend Pages + +### 3.1 Authentication Pages + +#### /login +- **Design:** Vollbild-Login mit Luna Recipes Logo +- **Felder:** E-Mail, Passwort, "Angemeldet bleiben" Checkbox +- **Actions:** Login, "Registrieren" Link, "Passwort vergessen" Link (v2.1) +- **Mobile:** Touch-optimierte Input-Felder, große Login-Button +- **Validation:** Client-side Validation mit sofortigem Feedback +- **States:** Loading State beim Login-Prozess + +#### /register +- **Design:** Gleiche Basis wie Login-Page +- **Felder:** E-Mail, Passwort, Passwort wiederholen, Display Name +- **Validation:** + - E-Mail Format-Check + - Passwort-Stärke Indikator + - Passwort-Match Validation + - Display Name min. 2 Zeichen +- **Actions:** Registrieren, "Schon Account?" Login-Link +- **Flow:** Nach erfolgreicher Registrierung → automatisch eingeloggt → Dashboard + +### 3.2 Profile Pages + +#### /profile +- **Layout:** Tabbed Interface (Profil, Haushalt) +- **Profil Tab:** + - Avatar (Upload oder URL) + - Display Name (inline editierbar) + - E-Mail (nicht editierbar, mit "Ändern" Link für v2.1) + - "Passwort ändern" Button +- **Mobile:** Große Avatar-Anzeige, Touch-freundliche Edit-Buttons + +#### /profile/edit +- **Modal oder Fullscreen (Mobile):** Profil bearbeiten +- **Felder:** Display Name, Avatar URL/Upload +- **Actions:** Speichern, Abbrechen +- **Validation:** Display Name required + +#### /profile/password +- **Layout:** Focused Passwort-Ändern Page +- **Felder:** Aktuelles Passwort, Neues Passwort, Bestätigung +- **Security:** + - Passwort-Stärke Anzeige + - "Passwort anzeigen" Toggle + - Session-Refresh nach Änderung +- **UX:** Erfolgs-Toast + Redirect nach Speichern + +#### /profile/household +- **States:** + - **Kein Haushalt:** "Haushalt erstellen" oder "Haushalt beitreten" + - **Haushalt Owner:** Mitglieder-Liste, Einladungscode verwalten + - **Haushalt Member:** Mitglieder-Liste, "Haushalt verlassen" +- **Features:** + - Einladungscode kopieren/teilen + - QR-Code für Einladung (Mobile-optimiert) + - Mitglieder-Management (nur Owner) + +## 4. Authentication Flow + +### 4.1 Token-Strategy + +#### JWT Access Token +- **Laufzeit:** 15 Minuten +- **Storage:** Memory (React State + Context) +- **Payload:** + ```json + { + "sub": "user-uuid", + "email": "luna@example.com", + "display_name": "Luna", + "household_id": "household-uuid", + "iat": 1708272600, + "exp": 1708273500 + } + ``` + +#### Refresh Token +- **Laufzeit:** 30 Tage +- **Storage:** httpOnly Cookie, Secure, SameSite=Strict +- **Rotation:** Neuer Refresh Token bei jedem Access Token Refresh +- **Cookie Name:** `luna_refresh_token` + +### 4.2 Auto-Refresh Flow + +```typescript +// Auth Context implementiert Auto-Refresh +const authContext = { + // Access Token im Memory + accessToken: string | null, + // User Info aus Token dekodiert + user: User | null, + // Auto-refresh 2 Minuten vor Ablauf + refreshToken: () => Promise, + logout: () => void +} + +// Axios Interceptor für Auto-Refresh +axios.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + await authContext.refreshToken() + // Retry original request + return axios.request(error.config) + } + return Promise.reject(error) + } +) +``` + +### 4.3 Route Protection + +```typescript +// Protected Route Component +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + + if (loading) return + if (!user) return + + return <>{children} +} + +// App Router + + } /> + } /> + + + + } /> + +``` + +### 4.4 Login Flow States + +1. **Unauthenticated:** Redirect zu `/login` +2. **Login Success:** + - Set Access Token in Memory + - Set Refresh Token in Cookie + - Redirect zu ursprünglicher Route oder `/` +3. **Token Expired:** Auto-refresh, bei Fehler → Logout +4. **Logout:** Clear Token + Cookie, Redirect zu `/login` + +## 5. Haushalt-Feature + +### 5.1 Haushalt erstellen + +1. User klickt "Haushalt erstellen" in `/profile/household` +2. Modal: Haushalt-Name eingeben +3. Backend erstellt Haushalt + generiert Einladungscode +4. User wird automatisch als Owner hinzugefügt +5. Einladungscode wird angezeigt zum Teilen + +### 5.2 Haushalt beitreten + +1. User erhält Einladungscode (per Link, QR-Code oder Text) +2. User klickt "Haushalt beitreten" und gibt Code ein +3. Backend validiert Code und fügt User als Member hinzu +4. Einkaufsliste wird automatisch geteilt + +### 5.3 Gemeinsame Einkaufsliste + +#### Datenlogik +- Shopping Items mit `household_id` sind für alle Haushaltsmitglieder sichtbar +- Shopping Items mit nur `user_id` sind privat +- Standard: Neue Items werden Haushalt zugeordnet (wenn User in Haushalt ist) +- Toggle: "Privat" Checkbox beim Hinzufügen + +#### UI/UX +- **Indicator:** Haushalt-Items haben kleines Haushalt-Icon +- **Filter:** "Alle", "Haushalt", "Privat" Tabs in Shopping Liste +- **Mobile:** Swipe-Actions: "Als privat markieren" / "Mit Haushalt teilen" + +### 5.4 Persönliche Favoriten + +- Favoriten sind immer pro User (`user_favorites` Tabelle) +- Keine Sharing-Option für Favoriten in v2 +- Favoriten-Liste zeigt nur eigene Favoriten + +## 6. Migration Strategy + +### 6.1 Backwards Compatibility + +- **Anonymous Access:** Rezepte bleiben öffentlich zugänglich ohne Login +- **URLs:** Alle bestehenden Recipe-URLs funktionieren weiterhin +- **Features:** Basis-Funktionen (Rezepte suchen, ansehen) ohne Auth + +### 6.2 Daten-Migration + +#### Phase 1: Schema Updates +```sql +-- Neue Tabellen erstellen (siehe Datenmodell) +-- Bestehende Tabellen erweitern (users_id Spalten als optional) + +-- Default User erstellen für Migration +INSERT INTO users (id, email, display_name, password_hash) +VALUES ( + 'migration-user-uuid', + 'migration@luna-recipes.local', + 'Luna (Migration)', + 'no-login' +); +``` + +#### Phase 2: Daten zuweisen +```sql +-- Bestehende Rezepte dem Migration User zuweisen +UPDATE recipes +SET created_by = 'migration-user-uuid' +WHERE created_by IS NULL; + +-- Bestehende Notes dem Migration User zuweisen +UPDATE notes +SET user_id = 'migration-user-uuid' +WHERE user_id IS NULL; + +-- Shopping Items vorerst ohne user_id lassen +-- Werden beim ersten User-Login zugewiesen +``` + +#### Phase 3: Erste echte User +- Erste User die sich registrieren bekommen alle bestehenden Daten zugewiesen +- Migration User wird nach erstem echten User gelöscht +- Shopping Items ohne user_id werden beim Login zugewiesen + +### 6.3 Rollback Plan + +- Auth-Features sind additiv, keine Breaking Changes +- Bei Problemen: Auth-Routes deaktivieren, App läuft weiter ohne Auth +- Datenbank-Rollback: user_id Spalten auf NULL setzen + +## 7. Sicherheit + +### 7.1 Passwort-Sicherheit + +```javascript +// bcrypt mit 12 Rounds (Backend) +const passwordHash = await bcrypt.hash(password, 12) + +// Passwort-Validierung (Frontend + Backend) +const passwordRules = { + minLength: 8, + requireUppercase: false, // UX-freundlich für v2 + requireNumbers: false, + requireSpecialChars: false +} +``` + +### 7.2 JWT Security + +```javascript +// JWT Signing (Backend) +const accessToken = jwt.sign( + { + sub: user.id, + email: user.email, + display_name: user.display_name, + household_id: user.household_id + }, + process.env.JWT_SECRET, + { expiresIn: '15m' } +) + +// httpOnly Cookie Config +res.cookie('luna_refresh_token', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days +}) +``` + +### 7.3 Rate Limiting + +```javascript +// Login Rate Limiting +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 attempts per IP + message: 'Zu viele Login-Versuche, bitte warten Sie 15 Minuten', + standardHeaders: true +}) + +// Registration Rate Limiting +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, // 3 registrations per IP per hour + message: 'Zu viele Registrierungen, bitte warten Sie' +}) +``` + +### 7.4 Input Validation + +```javascript +// Zod Schema für API Validation (Backend) +const registerSchema = z.object({ + email: z.string().email('Ungültige E-Mail-Adresse'), + password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'), + display_name: z.string().min(2, 'Name muss mindestens 2 Zeichen haben') + .max(50, 'Name darf maximal 50 Zeichen haben') +}) + +// XSS Protection für User-generierte Inhalte +const sanitizedDisplayName = DOMPurify.sanitize(display_name) +``` + +## 8. Mobile-First UX Considerations + +### 8.1 Touch Targets +- **Minimum Size:** 44px × 44px für alle Buttons +- **Spacing:** 8px minimum zwischen clickbaren Elementen +- **Form Fields:** 48px Höhe für bessere Touch-Ergonomie + +### 8.2 Responsive Breakpoints +```css +/* Mobile First */ +.auth-form { + width: 100%; + padding: 1rem; +} + +/* Tablet */ +@media (min-width: 768px) { + .auth-form { + max-width: 400px; + margin: 0 auto; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } +} +``` + +### 8.3 Keyboard & Input Handling +```jsx +// Input Types für bessere Mobile Keyboards + + + + +// Auto-focus Management + +``` + +### 8.4 Loading States +- **Skeleton Loading:** Für User Profile, Haushalt-Listen +- **Button States:** Loading Spinner in Buttons während API Calls +- **Toast Messages:** Für Success/Error Feedback +- **Optimistic Updates:** Favoriten, Haushalt beitreten + +### 8.5 Offline Considerations +- **Service Worker:** Caching für Auth-relevante Pages +- **Network Awareness:** Retry-Mechanismus für failed Requests +- **Local Storage:** Backup für kritische User Preferences + +## 9. Testing Strategy + +### 9.1 Unit Tests +- Auth Context / Hook Tests +- Password Validation Tests +- JWT Token Handling Tests +- API Response Validation Tests + +### 9.2 Integration Tests +- Login/Register Flow End-to-End +- Token Refresh Flow +- Haushalt erstellen/beitreten Flow +- Migration Logic Tests + +### 9.3 Security Tests +- SQL Injection Prevention +- XSS Prevention +- CSRF Protection Validation +- Rate Limiting Effectiveness + +## 10. Performance + +### 10.1 Bundle Size +- **Code Splitting:** Auth-Pages lazy loaded +- **Tree Shaking:** Nur genutzte Auth-Libraries +- **JWT Library:** Lightweight jwt-decode statt vollständiger Library + +### 10.2 API Optimization +- **Response Caching:** User Profile Daten +- **Debounced Requests:** Profile Updates +- **Batch Requests:** Initial App Load (User + Household in einem Call) + +### 10.3 Database Performance +```sql +-- Wichtige Indizes für Auth Queries +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_household_members_user_id ON household_members(user_id); +CREATE INDEX idx_shopping_items_user_household ON shopping_items(user_id, household_id); +CREATE INDEX idx_user_favorites_user_id ON user_favorites(user_id); +``` + +--- + +## Implementierungs-Prioritäten + +### Phase 1 (MVP) +1. ✅ Datenbank Schema + Migration +2. ✅ Backend Auth Endpoints +3. ✅ Frontend Login/Register Pages +4. ✅ JWT Token Flow + Auto-Refresh +5. ✅ Route Protection + +### Phase 2 (Multi-User) +1. User Profile Management +2. Shopping Items User-Zuordnung +3. Favorites pro User +4. Notes pro User + +### Phase 3 (Households) +1. Household Creation + Joining +2. Shared Shopping Lists +3. Household Management UI +4. Invite Code Generation + +### Phase 4 (Polish) +1. Avatar Upload +2. Advanced Security Features +3. Performance Optimizations +4. Mobile UX Improvements + +**Geschätzte Entwicklungszeit:** 3-4 Wochen für komplette Implementierung \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7396592..ec1e94b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { BrowserRouter, Routes, Route } from 'react-router' import { AppShell } from './components/layout/AppShell' +import { AuthProvider } from './context/AuthContext' +import { PublicRoute } from './components/auth/AuthGuard' import { HomePage } from './pages/HomePage' import { RecipePage } from './pages/RecipePage' import { SearchPage } from './pages/SearchPage' @@ -7,21 +9,44 @@ import { PlaceholderPage } from './pages/PlaceholderPage' import { ProfilePage } from './pages/ProfilePage' import { RecipeFormPage } from './pages/RecipeFormPage' import { ShoppingPage } from './pages/ShoppingPage' +import { LoginPage } from './pages/LoginPage' +import { RegisterPage } from './pages/RegisterPage' export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + {/* Public Auth Routes */} + + + + } + /> + + + + } + /> + + {/* Main App Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c1015f5..63d26f6 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,21 +1,100 @@ import { apiFetch } from './client' -// v2: Auth API — placeholder functions - export interface User { id: string - name: string - email?: string + email: string + display_name: string + avatar_url?: string + created_at: string + updated_at: string } -export function login(_email: string, _password: string) { - return apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email: _email, password: _password }) }) +export interface AuthResponse { + user: User + access_token: string } -export function register(_email: string, _password: string, _name: string) { - return apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ email: _email, password: _password, name: _name }) }) +export interface RegisterData { + email: string + password: string + display_name: string } -export function fetchMe() { - return apiFetch('/auth/me') +export interface LoginData { + email: string + password: string } + +export interface UpdateProfileData { + display_name?: string + avatar_url?: string +} + +export interface ChangePasswordData { + current_password: string + new_password: string +} + +// Auth token storage (in memory, not localStorage) +let authToken: string | null = null + +export function setAuthToken(token: string | null) { + authToken = token +} + +export function getAuthToken() { + return authToken +} + +// Add Authorization header to apiFetch when token exists +const authFetch = (path: string, options?: RequestInit): Promise => { + const headers = { ...options?.headers } as Record + if (authToken) { + headers.Authorization = `Bearer ${authToken}` + } + return apiFetch(path, { ...options, headers }) +} + +export function register(data: RegisterData): Promise { + return apiFetch('/auth/register', { + method: 'POST', + body: JSON.stringify(data) + }) +} + +export function login(data: LoginData): Promise { + return apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify(data) + }) +} + +export function logout(): Promise { + return authFetch('/auth/logout', { + method: 'POST' + }) +} + +export function getMe(): Promise { + return authFetch('/auth/me') +} + +export function updateProfile(data: UpdateProfileData): Promise { + return authFetch('/auth/me', { + method: 'PUT', + body: JSON.stringify(data) + }) +} + +export function changePassword(data: ChangePasswordData): Promise { + return authFetch('/auth/me/password', { + method: 'PUT', + body: JSON.stringify(data) + }) +} + +export function refreshToken(): Promise { + return apiFetch('/auth/refresh', { + method: 'POST' + }) +} \ No newline at end of file diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts index 1d9045b..da24fd9 100644 --- a/frontend/src/api/shopping.ts +++ b/frontend/src/api/shopping.ts @@ -40,6 +40,10 @@ export function deleteItem(id: string) { return apiFetch(`/shopping/${id}`, { method: 'DELETE' }) } +export function deleteAll() { + return apiFetch('/shopping/all', { method: 'DELETE' }) +} + export function deleteChecked() { return apiFetch('/shopping/checked', { method: 'DELETE' }) } diff --git a/frontend/src/components/auth/AuthGuard.tsx b/frontend/src/components/auth/AuthGuard.tsx new file mode 100644 index 0000000..ef60d27 --- /dev/null +++ b/frontend/src/components/auth/AuthGuard.tsx @@ -0,0 +1,59 @@ +import { type ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router' +import { useAuth } from '../../context/AuthContext' +import { Skeleton } from '../ui/Skeleton' + +interface ProtectedRouteProps { + children: ReactNode +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading } = useAuth() + const location = useLocation() + + if (isLoading) { + return ( +
+
+ + + +
+
+ ) + } + + if (!isAuthenticated) { + // Redirect to login and remember where they were trying to go + return + } + + return <>{children} +} + +interface PublicRouteProps { + children: ReactNode +} + +export function PublicRoute({ children }: PublicRouteProps) { + const { isAuthenticated, isLoading } = useAuth() + + if (isLoading) { + return ( +
+
+ + + +
+
+ ) + } + + if (isAuthenticated) { + // Already logged in, redirect to home + return + } + + return <>{children} +} \ No newline at end of file diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..77f769b --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,117 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' +import { + User, + getMe, + setAuthToken, + getAuthToken, + refreshToken, + logout as apiLogout +} from '../api/auth' + +interface AuthContextType { + user: User | null + isAuthenticated: boolean + isLoading: boolean + login: (token: string, user: User) => void + logout: () => void + updateUser: (user: User) => void +} + +const AuthContext = createContext(null) + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + const isAuthenticated = !!user + + // Initialize auth on app start + useEffect(() => { + let mounted = true + + async function initAuth() { + try { + // Try to refresh token first (cookie-based) + const authResponse = await refreshToken() + if (mounted) { + setAuthToken(authResponse.access_token) + setUser(authResponse.user) + } + } catch (error) { + // If refresh fails, check if we already have a token + const existingToken = getAuthToken() + if (existingToken) { + try { + const userData = await getMe() + if (mounted) { + setUser(userData) + } + } catch (meError) { + // Token is invalid, clear it + if (mounted) { + setAuthToken(null) + } + } + } + } finally { + if (mounted) { + setIsLoading(false) + } + } + } + + initAuth() + + return () => { + mounted = false + } + }, []) + + const login = (token: string, userData: User) => { + setAuthToken(token) + setUser(userData) + } + + const logout = async () => { + try { + await apiLogout() + } catch (error) { + // Ignore logout API errors + console.warn('Logout API call failed:', error) + } finally { + setAuthToken(null) + setUser(null) + } + } + + const updateUser = (userData: User) => { + setUser(userData) + } + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2e483a5 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { Link, useNavigate, useLocation } from 'react-router' +import { motion } from 'framer-motion' +import { Mail, Lock, Eye, EyeOff } from 'lucide-react' +import { login as apiLogin } from '../api/auth' +import { useAuth } from '../context/AuthContext' +import { Button } from '../components/ui/Button' +import { showToast } from '../utils/toast' + +export function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + + const { login } = useAuth() + const navigate = useNavigate() + const location = useLocation() + + // Get the page they were trying to visit, default to home + const from = location.state?.from?.pathname || '/' + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setIsLoading(true) + + try { + const response = await apiLogin({ email, password }) + login(response.access_token, response.user) + showToast.success(`Willkommen zurück, ${response.user.display_name}!`) + navigate(from, { replace: true }) + } catch (err) { + setError('Ungültige E-Mail oder Passwort') + showToast.error('Anmeldung fehlgeschlagen') + } finally { + setIsLoading(false) + } + } + + return ( +
+ + {/* Header */} +
+ + 👨‍🍳 + +

+ Willkommen zurück +

+

+ Melde dich an, um deine Rezepte zu verwalten +

+
+ + {/* Login Form */} +
+ {/* Email Field */} +
+ +
+ + setEmail(e.target.value)} + className="w-full pl-10 pr-4 py-3 rounded-xl border border-sand focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors" + placeholder="deine@email.de" + required + /> +
+
+ + {/* Password Field */} +
+ +
+ + setPassword(e.target.value)} + className="w-full pl-10 pr-12 py-3 rounded-xl border border-sand focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors" + placeholder="••••••••" + required + /> + +
+
+ + {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Submit Button */} + +
+ + {/* Sign Up Link */} +
+

+ Noch kein Account?{' '} + + Jetzt registrieren + +

+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 8370b9e..e44d3ed 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,10 +1,17 @@ import { useQuery } from '@tanstack/react-query' import { motion } from 'framer-motion' +import { Link } from 'react-router' import { fetchRecipes } from '../api/recipes' import { fetchShopping } from '../api/shopping' -import { LogOut, Info, Heart, BookOpen, ShoppingCart } from 'lucide-react' +import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User } from 'lucide-react' +import { useAuth } from '../context/AuthContext' +import { EmptyState } from '../components/ui/EmptyState' +import { Button } from '../components/ui/Button' +import { showToast } from '../utils/toast' export function ProfilePage() { + const { user, isAuthenticated, logout, isLoading } = useAuth() + const { data: allRecipes } = useQuery({ queryKey: ['recipes', {}], queryFn: () => fetchRecipes({}), @@ -30,6 +37,41 @@ export function ProfilePage() { { icon: , label: 'Einkauf', value: totalShoppingItems }, ] + const handleLogout = async () => { + try { + await logout() + showToast.success('Erfolgreich abgemeldet') + } catch (error) { + console.error('Logout failed:', error) + showToast.error('Abmeldung fehlgeschlagen') + } + } + + // Show login prompt if not authenticated + if (!isLoading && !isAuthenticated) { + return ( +
+ +
+ + + + + + +
+
+ ) + } + return (
{/* Header */} @@ -39,10 +81,22 @@ export function ProfilePage() { initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} > - 👤 + {user?.avatar_url ? ( + {user.display_name} + ) : ( + + )} -

Luna

-

Hobbyköchin & Rezeptsammlerin

+

+ {user?.display_name || 'Benutzer'} +

+

+ {user?.email || 'Hobbyköchin & Rezeptsammlerin'} +

{/* Stats */} @@ -63,6 +117,34 @@ export function ProfilePage() { + {/* Profile Actions */} +
+
+
+ + Profil verwalten +
+ + + + +
+
+ {/* App Info */}
@@ -73,7 +155,7 @@ export function ProfilePage() {
Version - 1.0 + 2.0
Erstellt @@ -86,12 +168,11 @@ export function ProfilePage() { {/* Logout Button */}
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..a042543 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router' +import { motion } from 'framer-motion' +import { Mail, Lock, User, Eye, EyeOff } from 'lucide-react' +import { register as apiRegister } from '../api/auth' +import { useAuth } from '../context/AuthContext' +import { Button } from '../components/ui/Button' +import { showToast } from '../utils/toast' + +export function RegisterPage() { + const [formData, setFormData] = useState({ + display_name: '', + email: '', + password: '', + confirmPassword: '' + }) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [errors, setErrors] = useState>({}) + + const { login } = useAuth() + const navigate = useNavigate() + + const validateForm = (): boolean => { + const newErrors: Record = {} + + // Name validation + if (!formData.display_name.trim()) { + newErrors.display_name = 'Name ist erforderlich' + } else if (formData.display_name.trim().length < 2) { + newErrors.display_name = 'Name muss mindestens 2 Zeichen lang sein' + } + + // Email validation + if (!formData.email) { + newErrors.email = 'E-Mail ist erforderlich' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Ungültige E-Mail-Adresse' + } + + // Password validation + if (!formData.password) { + newErrors.password = 'Passwort ist erforderlich' + } else if (formData.password.length < 6) { + newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein' + } + + // Confirm password validation + if (!formData.confirmPassword) { + newErrors.confirmPassword = 'Passwort bestätigen ist erforderlich' + } else if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwörter stimmen nicht überein' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleChange = (field: keyof typeof formData) => ( + e: React.ChangeEvent + ) => { + setFormData({ ...formData, [field]: e.target.value }) + // Clear error for this field when user starts typing + if (errors[field]) { + setErrors({ ...errors, [field]: '' }) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validateForm()) { + return + } + + setIsLoading(true) + setErrors({}) + + try { + const response = await apiRegister({ + display_name: formData.display_name.trim(), + email: formData.email, + password: formData.password + }) + login(response.access_token, response.user) + showToast.success(`Willkommen bei Luna Rezepte, ${response.user.display_name}!`) + navigate('/', { replace: true }) + } catch (err: any) { + if (err.message?.includes('409')) { + setErrors({ email: 'E-Mail-Adresse ist bereits registriert' }) + showToast.error('E-Mail bereits registriert') + } else { + setErrors({ general: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }) + showToast.error('Registrierung fehlgeschlagen') + } + } finally { + setIsLoading(false) + } + } + + return ( +
+ + {/* Header */} +
+ + 👨‍🍳 + +

+ Account erstellen +

+

+ Registriere dich und starte deine Rezeptsammlung +

+
+ + {/* Registration Form */} +
+ {/* Name Field */} +
+ +
+ + +
+ {errors.display_name && ( +

{errors.display_name}

+ )} +
+ + {/* Email Field */} +
+ +
+ + +
+ {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Password Field */} +
+ +
+ + + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password Field */} +
+ +
+ + + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword}

+ )} +
+ + {/* General Error Message */} + {errors.general && ( + + {errors.general} + + )} + + {/* Submit Button */} + +
+ + {/* Login Link */} +
+

+ Schon einen Account?{' '} + + Jetzt anmelden + +

+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index 2822f70..308b5b6 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -7,6 +7,7 @@ import { toggleCheck, deleteItem, deleteChecked, + deleteAll, } from '../api/shopping' import type { ShoppingGroup, ShoppingItem } from '../api/shopping' import { EmptyState } from '../components/ui/EmptyState' @@ -36,6 +37,13 @@ export function ShoppingPage() { onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }), }) + const deleteAllMutation = useMutation({ + mutationFn: deleteAll, + onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }), + }) + + const [showClearConfirm, setShowClearConfirm] = useState(false) + const addMutation = useMutation({ mutationFn: addCustomItem, onSuccess: () => { @@ -121,12 +129,48 @@ export function ShoppingPage() { className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center" title="Erledigte löschen" > - + + + )} + {totalItems > 0 && ( + )}
+ {/* Clear All Confirm Dialog */} + {showClearConfirm && ( +
+
+

Einkaufsliste leeren?

+

Alle Artikel werden unwiderruflich gelöscht.

+
+ + +
+
+
+ )} + {/* Pull indicator */} {pulling && (
↓ Loslassen zum Aktualisieren
@@ -230,9 +274,11 @@ function ShoppingItemRow({ onToggle: () => void onDelete: () => void }) { + const THRESHOLD = -80 const [swipeX, setSwipeX] = useState(0) const touchStartX = useRef(0) const swiping = useRef(false) + const pastThreshold = swipeX < THRESHOLD const handleTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX @@ -243,17 +289,19 @@ function ShoppingItemRow({ const dx = e.touches[0].clientX - touchStartX.current if (dx < -10) { swiping.current = true - setSwipeX(Math.max(dx, -80)) + setSwipeX(Math.max(dx, -160)) } else { setSwipeX(0) } } const handleTouchEnd = () => { - if (swipeX < -60) { - onDelete() + if (pastThreshold) { + setSwipeX(-300) + setTimeout(() => onDelete(), 200) + } else { + setSwipeX(0) } - setSwipeX(0) swiping.current = false } @@ -262,12 +310,14 @@ function ShoppingItemRow({ return (
  • {/* Delete background */} -
    - +
    + + {pastThreshold ? '🗑️ Löschen' : '×'} +
    { + toast.success(message, { + duration: 4000, + style: { + background: '#C4737E', + color: 'white', + borderRadius: '12px', + fontSize: '14px', + fontWeight: '500', + }, + iconTheme: { + primary: '#C4737E', + secondary: 'white', + }, + }) + }, + + error: (message: string) => { + toast.error(message, { + duration: 5000, + style: { + background: '#C94C4C', + color: 'white', + borderRadius: '12px', + fontSize: '14px', + fontWeight: '500', + }, + iconTheme: { + primary: '#C94C4C', + secondary: 'white', + }, + }) + }, + + loading: (message: string) => { + return toast.loading(message, { + style: { + background: '#7A6E65', + color: 'white', + borderRadius: '12px', + fontSize: '14px', + fontWeight: '500', + }, + }) + }, + + dismiss: (toastId: string) => { + toast.dismiss(toastId) + } +} \ No newline at end of file