Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages
This commit is contained in:
116
backend/PHASE2_TEST_RESULTS.md
Normal file
116
backend/PHASE2_TEST_RESULTS.md
Normal file
@@ -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! 🎉
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
344
backend/package-lock.json
generated
344
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
43
backend/src/db/migrations/003_auth.sql
Normal file
43
backend/src/db/migrations/003_auth.sql
Normal file
@@ -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;
|
||||
27
backend/src/db/migrations/004_households.sql
Normal file
27
backend/src/db/migrations/004_households.sql
Normal file
@@ -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);
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { buildApp } from './app.js';
|
||||
import { runMigrations } from './db/migrate.js';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '/',
|
||||
});
|
||||
|
||||
app.post('/api/auth/register', async (_request, reply) => {
|
||||
reply.status(501).send({ error: 'not implemented' });
|
||||
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.get('/api/auth/me', 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
178
backend/src/routes/households.ts
Normal file
178
backend/src/routes/households.ts
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
200
backend/src/services/auth.service.ts
Normal file
200
backend/src/services/auth.service.ts
Normal file
@@ -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<AuthResult> {
|
||||
// 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<AuthResult> {
|
||||
// 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<User | null> {
|
||||
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<User> {
|
||||
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<boolean> {
|
||||
// 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<AuthResult> {
|
||||
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();
|
||||
218
backend/src/services/household.service.ts
Normal file
218
backend/src/services/household.service.ts
Normal file
@@ -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<HouseholdWithMembers> {
|
||||
// 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<HouseholdWithMembers> {
|
||||
// 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<HouseholdWithMembers | null> {
|
||||
// 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<void> {
|
||||
// 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<string> {
|
||||
// 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<Household | null> {
|
||||
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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 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() {
|
||||
|
||||
@@ -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(`
|
||||
|
||||
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
|
||||
`).all() as any[];
|
||||
`;
|
||||
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<string, any> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
757
features/AUTH-V2-SPEC.md
Normal file
757
features/AUTH-V2-SPEC.md
Normal file
@@ -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<void>,
|
||||
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 <LoadingSpinner />
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// App Router
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
```
|
||||
|
||||
### 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
|
||||
<input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="username"
|
||||
placeholder="luna@example.com"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Passwort"
|
||||
/>
|
||||
|
||||
// Auto-focus Management
|
||||
<input ref={emailRef} autoFocus onSubmit={focusPassword} />
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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,11 +9,33 @@ 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 (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Auth Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<RegisterPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main App Routes */}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="recipe/:slug" element={<RecipePage />} />
|
||||
@@ -23,5 +47,6 @@ export default function App() {
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<User>('/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<User>('/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<User>('/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 = <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||
const headers = { ...options?.headers } as Record<string, string>
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`
|
||||
}
|
||||
return apiFetch(path, { ...options, headers })
|
||||
}
|
||||
|
||||
export function register(data: RegisterData): Promise<AuthResponse> {
|
||||
return apiFetch<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function login(data: LoginData): Promise<AuthResponse> {
|
||||
return apiFetch<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return authFetch<void>('/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMe(): Promise<User> {
|
||||
return authFetch<User>('/auth/me')
|
||||
}
|
||||
|
||||
export function updateProfile(data: UpdateProfileData): Promise<User> {
|
||||
return authFetch<User>('/auth/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function changePassword(data: ChangePasswordData): Promise<void> {
|
||||
return authFetch<void>('/auth/me/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshToken(): Promise<AuthResponse> {
|
||||
return apiFetch<AuthResponse>('/auth/refresh', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export function deleteItem(id: string) {
|
||||
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function deleteAll() {
|
||||
return apiFetch<void>('/shopping/all', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function deleteChecked() {
|
||||
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
59
frontend/src/components/auth/AuthGuard.tsx
Normal file
59
frontend/src/components/auth/AuthGuard.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen p-4">
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login and remember where they were trying to go
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
interface PublicRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function PublicRoute({ children }: PublicRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen p-4">
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Already logged in, redirect to home
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
117
frontend/src/context/AuthContext.tsx
Normal file
117
frontend/src/context/AuthContext.tsx
Normal file
@@ -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<AuthContextType | null>(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<User | null>(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 (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
updateUser
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
146
frontend/src/pages/LoginPage.tsx
Normal file
146
frontend/src/pages/LoginPage.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="w-full max-w-md"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
className="text-6xl mb-4"
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
👨🍳
|
||||
</motion.div>
|
||||
<h1 className="font-display text-3xl text-espresso mb-2">
|
||||
Willkommen zurück
|
||||
</h1>
|
||||
<p className="text-warm-grey">
|
||||
Melde dich an, um deine Rezepte zu verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-espresso mb-2">
|
||||
E-Mail
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-espresso mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
className="text-berry-red text-sm text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full min-h-[44px] py-3"
|
||||
>
|
||||
{isLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-warm-grey">
|
||||
Noch kein Account?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline font-medium">
|
||||
Jetzt registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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: <ShoppingCart size={18} />, 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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon="🔐"
|
||||
title="Anmeldung erforderlich"
|
||||
description="Melde dich an, um dein Profil zu verwalten"
|
||||
/>
|
||||
<div className="absolute bottom-32 left-1/2 transform -translate-x-1/2 space-y-3 w-full max-w-sm px-4">
|
||||
<Link to="/login">
|
||||
<Button className="w-full min-h-[44px]">
|
||||
Anmelden
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button variant="ghost" className="w-full min-h-[44px]">
|
||||
Registrieren
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
@@ -39,10 +81,22 @@ export function ProfilePage() {
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
👤
|
||||
{user?.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.display_name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User size={28} className="text-primary" />
|
||||
)}
|
||||
</motion.div>
|
||||
<h1 className="font-display text-2xl text-espresso">Luna</h1>
|
||||
<p className="text-sm text-warm-grey mt-1">Hobbyköchin & Rezeptsammlerin</p>
|
||||
<h1 className="font-display text-2xl text-espresso">
|
||||
{user?.display_name || 'Benutzer'}
|
||||
</h1>
|
||||
<p className="text-sm text-warm-grey mt-1">
|
||||
{user?.email || 'Hobbyköchin & Rezeptsammlerin'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -63,6 +117,34 @@ export function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Actions */}
|
||||
<div className="px-4 pb-6">
|
||||
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
|
||||
<Settings size={16} className="text-primary" />
|
||||
Profil verwalten
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled
|
||||
title="Kommt bald"
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
|
||||
>
|
||||
<span className="text-sm">Profil bearbeiten</span>
|
||||
<span className="text-xs">Kommt bald</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled
|
||||
title="Kommt bald"
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
|
||||
>
|
||||
<span className="text-sm">Passwort ändern</span>
|
||||
<span className="text-xs">Kommt bald</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Info */}
|
||||
<div className="px-4 pb-6">
|
||||
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
|
||||
@@ -73,7 +155,7 @@ export function ProfilePage() {
|
||||
<div className="text-sm text-warm-grey space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Version</span>
|
||||
<span className="text-espresso">1.0</span>
|
||||
<span className="text-espresso">2.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Erstellt</span>
|
||||
@@ -86,12 +168,11 @@ export function ProfilePage() {
|
||||
{/* Logout Button */}
|
||||
<div className="px-4 pb-8">
|
||||
<button
|
||||
disabled
|
||||
title="Kommt in v2"
|
||||
className="w-full flex items-center justify-center gap-2 bg-sand/50 text-warm-grey px-4 py-3 rounded-xl font-medium text-sm cursor-not-allowed opacity-60 min-h-[44px]"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center gap-2 bg-berry-red text-white px-4 py-3 rounded-xl font-medium text-sm hover:bg-berry-red/90 transition-colors min-h-[44px]"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Abmelden — kommt in v2
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
frontend/src/pages/RegisterPage.tsx
Normal file
259
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -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<Record<string, string>>({})
|
||||
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
// 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<HTMLInputElement>
|
||||
) => {
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="w-full max-w-md"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
className="text-6xl mb-4"
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
👨🍳
|
||||
</motion.div>
|
||||
<h1 className="font-display text-3xl text-espresso mb-2">
|
||||
Account erstellen
|
||||
</h1>
|
||||
<p className="text-warm-grey">
|
||||
Registriere dich und starte deine Rezeptsammlung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-espresso mb-2">
|
||||
Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={handleChange('display_name')}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border ${errors.display_name ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="Dein Name"
|
||||
/>
|
||||
</div>
|
||||
{errors.display_name && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.display_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-espresso mb-2">
|
||||
E-Mail
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange('email')}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border ${errors.email ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-espresso mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleChange('password')}
|
||||
className={`w-full pl-10 pr-12 py-3 rounded-xl border ${errors.password ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-espresso mb-2">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange('confirmPassword')}
|
||||
className={`w-full pl-10 pr-12 py-3 rounded-xl border ${errors.confirmPassword ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General Error Message */}
|
||||
{errors.general && (
|
||||
<motion.div
|
||||
className="text-berry-red text-sm text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{errors.general}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full min-h-[44px] py-3"
|
||||
>
|
||||
{isLoading ? 'Account wird erstellt...' : 'Registrieren'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-warm-grey">
|
||||
Schon einen Account?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline font-medium">
|
||||
Jetzt anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
{totalItems > 0 && (
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
title="Alles löschen"
|
||||
>
|
||||
<Trash2 size={20} className="text-berry-red/70" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear All Confirm Dialog */}
|
||||
{showClearConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6">
|
||||
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl">
|
||||
<h3 className="font-display text-lg text-espresso mb-2">Einkaufsliste leeren?</h3>
|
||||
<p className="text-warm-grey text-sm mb-5">Alle Artikel werden unwiderruflich gelöscht.</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteAllMutation.mutate()
|
||||
setShowClearConfirm(false)
|
||||
}}
|
||||
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]"
|
||||
>
|
||||
🗑️ Alles löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pull indicator */}
|
||||
{pulling && (
|
||||
<div className="text-center text-warm-grey text-sm py-2">↓ Loslassen zum Aktualisieren</div>
|
||||
@@ -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)
|
||||
}
|
||||
swiping.current = false
|
||||
}
|
||||
|
||||
@@ -262,12 +310,14 @@ function ShoppingItemRow({
|
||||
return (
|
||||
<li className="relative overflow-hidden">
|
||||
{/* Delete background */}
|
||||
<div className="absolute inset-y-0 right-0 w-20 bg-berry-red flex items-center justify-center">
|
||||
<X size={18} className="text-white" />
|
||||
<div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
|
||||
<span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
|
||||
{pastThreshold ? '🗑️ Löschen' : '×'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative bg-surface flex items-center gap-3 px-4 min-h-[52px] transition-transform"
|
||||
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
|
||||
style={{ transform: `translateX(${swipeX}px)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
|
||||
53
frontend/src/utils/toast.ts
Normal file
53
frontend/src/utils/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const showToast = {
|
||||
success: (message: string) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user