diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb9d0d0..43bbbc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,32 +55,3 @@ jobs: - name: Run backend tests run: | docker compose -f project/compose.yaml exec -T backend-dev sh -c "python -m pytest tests/ -v" - - linter: - name: Lint code base - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Run GitHub Super Linter - uses: super-linter/super-linter/slim@v8.6.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEFAULT_BRANCH: main - VALIDATE_ALL_CODEBASE: false - # Root ignore file - # GitHub workflows - # Project backend/frontend + Dockerfiles + compose files - FILTER_REGEX_INCLUDE: '^/github/workspace/(\.gitignore|\.github/workflows/.*\.ya?ml|project/(backend/.*\.py|frontend/.*\.(ts|tsx|js|jsx|css|mjs|cjs)|Dockerfile[^/]*|(compose|docker-compose)\.ya?ml))$' - VALIDATE_PYTHON: true - VALIDATE_PYTHON_BLACK: true - VALIDATE_PYTHON_FLAKE8: true - VALIDATE_TYPESCRIPT_ES: true - VALIDATE_JAVASCRIPT_ES: true - VALIDATE_CSS: true - VALIDATE_DOCKERFILE: true - VALIDATE_YAML: true - VALIDATE_GITHUB_ACTIONS: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ac275e3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: "Lint code base" + +on: + pull_request: + branches: [main, 'blatt*'] + push: + branches: [main, 'blatt*'] + +permissions: + contents: read + +jobs: + linter: + name: Lint code base + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run GitHub Super Linter + uses: super-linter/super-linter/slim@v8.6.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: main + VALIDATE_ALL_CODEBASE: false + # Root ignore file + # GitHub workflows + # Project backend/frontend + Dockerfiles + compose files + FILTER_REGEX_INCLUDE: '^/github/workspace/(\.gitignore|\.github/workflows/.*\.ya?ml|project/(backend/.*\.py|frontend/.*\.(ts|tsx|js|jsx|css|mjs|cjs)|Dockerfile[^/]*|(compose|docker-compose)\.ya?ml))$' + VALIDATE_PYTHON: true + VALIDATE_PYTHON_BLACK: true + VALIDATE_PYTHON_FLAKE8: true + VALIDATE_TYPESCRIPT_ES: true + VALIDATE_JAVASCRIPT_ES: true + VALIDATE_CSS: true + VALIDATE_DOCKERFILE: true + VALIDATE_YAML: true + VALIDATE_GITHUB_ACTIONS: true diff --git a/README.md b/README.md index 5beb5c9..66a742c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # LazyCook -[![ci](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml/badge.svg)](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml) +[![ci.yml](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml/badge.svg)](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml) +[![lint.yml](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/lint.yml/badge.svg)](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/lint.yml) LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutaten einzutragen und darauf soll LazyCook dir mögliche Rezepte/Gerichte vorschlagen die du aus diesen Zutaten gemacht werden können. @@ -21,6 +22,22 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - [x] [#47](https://github.com/GalacticCodeGambit/LazyCook/issues/47) Der Nutzer kann bereits hinzugefügte Zutaten bearbeiten, wie z.B. Menge, Einheit - [x] [#45](https://github.com/GalacticCodeGambit/LazyCook/issues/45) Passwörter "sicher" in Datenbank Abspeichern als Hash und vom Frontend/Backend "sicher" behandeln + ## Installation und Setup +1. Klonen das Repository: + `git clone https://github.com/GalacticCodeGambit/LazyCook.git` +2. Navigieren zum Projektverzeichnis: + `cd LazyCook/Project` +3. Starten der Anwendung mit: Docker Compose: + `docker compose up --build -d` + +Die Anwendung sollte jetzt unter `http://localhost:8000` erreichbar sein. + +Für die Funktion [#115](https://github.com/GalacticCodeGambit/LazyCook/issues/115) von Email Versenden/Empfangen muss im Ordner `project/` eine `.env` Datei mit den folgenden Variablen angelegt werden: +``` +EMAIL_HOST= +GMAIL_PASSWORD= +``` + ## Verwendete Technologien - Frontend: HTML, CSS, TypeScript/React - Backend: Python @@ -30,26 +47,29 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - Containerization: Docker - Projekt Management: GitHub Projects -## Code-Quality / Linting -- Der Workflow `.github/workflows/ci.yml` prüft mit dem Job `linter` das Projekt mit **GitHub Super-Linter**. -- Dabei werden u. a. Python-, TypeScript-, JavaScript-, YAML- und Dockerfile-Dateien validiert. +### Code-Quality / Linting +- Der Workflow `.github/workflows/lint.yml` prüft das Projekt mit **GitHub Super-Linter**. +- Dabei werden u.a. Python-, TypeScript-, JavaScript-, YAML- und Dockerfile-Dateien validiert. - ## Installation and Setup -1. Klonen das Repository: - `git clone https://github.com/GalacticCodeGambit/LazyCook.git` -2. Navigieren zum Projektverzeichnis: - `cd LazyCook/Project` -3. Starten der Anwendung mit: Docker Compose: - `docker compose up --build -d` - -Die Anwendung sollte jetzt unter `http://localhost:8000` erreichbar sein. +## Entwicklung +### Linter +#### Python `Black` +Automatisch formatieren: +``` +black project/backend/ . +``` + +`Black` installieren: +``` +python -m pip install black +``` -## Probleme beim Entwickeln +### Probleme beim Entwickeln Problem: Code hinzugefügt/geändert aber Änderungen werden nicht übernommen von Docker ``` diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md new file mode 100644 index 0000000..5ce09da --- /dev/null +++ b/docs/Review-Protokoll-01.md @@ -0,0 +1,34 @@ +# Review-Protokoll-01 +### **Datum** (Startzeit und Endzeit): +12.05.2026 16.:00 bis 16:20 + +### **Teilnehmende**: +- Moderator: PrussianBaron +- Zeitwächter: Nicoolaus +- Notizen: GalacticCodeGambit +- Teilnehmer: EdenBernhard, Hellocrafting + +### **Ziel**/**Schwerpunkt** des Reviews:
+Schwerpunkt: Team übergreifenden Verständnis des Algorithmus Rezept Suche verbessern. +Warum: Umfangreiches Verständnis schaffen, da es sich um ein Zentrales Feature handelt und um die Umsetzung der Rezept Anzeige bewerten zu können. + +Planen/Umsetzung von Features die auf den Algorithmus darauf Bauen. + +### **Komponenten für den Review**:
+Datei: `RecipeSucuk.py` + +### **Kriterien für den Review**: +- Codequalität +- Performance soll auf 100 Rezepten in unter 1s laufen +- Testabdeckung +- Wartbarkeit + +### **Review-Methodik**: +Walkthroughs und Code Review von `RecipeSucuk.py` + +### **Ergebnisse**: +- Umsetzung der Anzeigen der Rezepte + Top 100 Matching Rezepte werden an Frontend geschickt von Algorithmus. + Danach ist der Mehr-Button nicht mehr da. +- Müssen Test schreiben für die Datei `RecipeSucuk.py`. +- Akronyme für `RecipeSucuk.py` wurde bestimmt (SUCUK - Search for Uncomplicated Cooking and User-friendly Kitchen recipes) diff --git a/project/Dockerfile-frontend b/project/Dockerfile-frontend index fc2b0e3..b3ee918 100644 --- a/project/Dockerfile-frontend +++ b/project/Dockerfile-frontend @@ -1,4 +1,4 @@ -FROM node:24-alpine3.20 +FROM node:24.1.0-alpine3.20 WORKDIR /app # Copy package.json and package-lock.json diff --git a/project/backend/Auth.py b/project/backend/Auth.py index bbe28e5..3c8dc46 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -11,7 +11,14 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt import bcrypt -from pydantic import BaseModel + +import hashlib + +PASSWORD_RESET_EXPIRE_MINUTES = 30 + +import hashlib + +PASSWORD_RESET_EXPIRE_MINUTES = 30 from Models import Token, User @@ -132,3 +139,37 @@ async def getCurrentUser(token: Annotated[str, Depends(oauth2_scheme)]) -> User: raise credentials_exception return User(email=konto["email"], name=konto["name"]) + + +# --- Reset Password --- # + + +def hashResetToken(token: str) -> str: + """SHA-256 Hash – für Reset-Tokens reicht das, sie sind ohnehin hochentropisch.""" + return hashlib.sha256(token.encode()).hexdigest() + + +def createPasswordResetToken(kontoId: int) -> str: + """Generiert Klartext-Token (geht per Mail) und speichert nur den Hash in der DB.""" + from Database import savePasswordResetToken + + token = secrets.token_urlsafe(48) + expiresAt = datetime.now(timezone.utc) + timedelta( + minutes=PASSWORD_RESET_EXPIRE_MINUTES + ) + savePasswordResetToken(kontoId, hashResetToken(token), expiresAt.isoformat()) + return token + + +def validatePasswordResetToken(token: str) -> dict | None: + from Database import getPasswordResetToken + + entry = getPasswordResetToken(hashResetToken(token)) + if entry is None or entry["usedAt"] is not None: + return None + expiresAt = datetime.fromisoformat(entry["expiresAt"]) + if expiresAt.tzinfo is None: + expiresAt = expiresAt.replace(tzinfo=timezone.utc) + if expiresAt < datetime.now(timezone.utc): + return None + return entry diff --git a/project/backend/Database.py b/project/backend/Database.py index 5d35f3a..580ad41 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -14,8 +14,8 @@ def getConnection() -> sqlite3.Connection: """Erstellt eine neue SQLite-Connection mit Row-Factory.""" con = sqlite3.connect(str(DB_PATH), check_same_thread=False) con.row_factory = sqlite3.Row + con.execute("PRAGMA journal_mode = WAL") con.execute("PRAGMA foreign_keys = ON") - # con.execute("PRAGMA journal_mode = WAL") return con @@ -61,7 +61,7 @@ def initDB(): """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Ingridient ( + CREATE TABLE IF NOT EXISTS Ingredient ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, amountType VARCHAR(30) NOT NULL @@ -79,7 +79,8 @@ def initDB(): CREATE TABLE IF NOT EXISTS Recipe ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, - vid INTEGER NOT NULL, + description nvarchar(20000), + vid INTEGER, FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE ) """) @@ -89,7 +90,7 @@ def initDB(): zid INTEGER NOT NULL, rid INTEGER NOT NULL, amount DECIMAL(10,2) NOT NULL, - FOREIGN KEY (zid) REFERENCES Ingridient (id) ON DELETE CASCADE, + FOREIGN KEY (zid) REFERENCES Ingredient (id) ON DELETE CASCADE, FOREIGN KEY (rid) REFERENCES Recipe (id) ON DELETE CASCADE, UNIQUE (zid, rid) ) @@ -104,6 +105,30 @@ def initDB(): UNIQUE (AccountID, rid) ) """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS IngredientUsage ( + AccountID INTEGER NOT NULL, + name TEXT NOT NULL, + displayName TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + lastUnit TEXT, + lastUsedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (AccountID, name), + FOREIGN KEY (AccountID) REFERENCES Account (id) ON DELETE CASCADE + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS PasswordResetToken ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kontoID INTEGER NOT NULL, + tokenHash TEXT NOT NULL UNIQUE, + expiresAt TIMESTAMP NOT NULL, + usedAt TIMESTAMP, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (kontoID) REFERENCES Account (id) ON DELETE CASCADE + ) + """) print("✅ Datenbank-Tabellen erfolgreich initialisiert") @@ -210,3 +235,324 @@ def cleanupExpiredTokens() -> None: with getDB() as con: cur = con.cursor() cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") + + +def addRecipe(name: str, description: str, vid: int) -> int: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Recipe (name, description) VALUES (?, ?)", + (name, description), + ) + return cur.lastrowid + + +def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Exists_from (zid, rid, amount) VALUES (?, ?, ?)", + (zid, rid, amount), + ) + + +def addIngredient(name: str, amountType: str) -> int: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Ingredient (name, amountType) VALUES (?, ?)", + (name, amountType), + ) + return cur.lastrowid + + +def getIngridientByName(name: str): + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + SELECT id, amountType + FROM Recipe + WHERE id = ? + """, + (name), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def getRecipe(recipeID: int) -> dict | None: + """Gibt eine Liste aller Rezepte zurück.""" + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + SELECT name, description + FROM Recipe + WHERE id = ? + """, + (recipeID), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def getAllRecipes() -> list[dict]: + with getDB() as con: + cur = con.cursor() + cur.execute(""" + SELECT id, name, description + FROM Recipe + """) + rows = cur.fetchall() + return [dict(row) for row in rows] + + +def getAllIngredientsForRecipe(rid: int): + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + SELECT + Ingredient.name, + Exists_from.amount, + Ingredient.amountType + FROM Ingredient + JOIN Exists_from ON Ingredient.id = Exists_from.zid + JOIN Recipe ON Exists_from.rid = Recipe.id + WHERE Exists_from.rid = ? + """, + (rid,), + ) # Note the comma here! + rows = cur.fetchall() + return [dict(row) for row in rows] + + +def getAllocatedRecipes(name: str) -> list[dict]: + + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + SELECT rid + FROM Exists_from + Inner Join Ingredient on rid=id, + Where Exists_from.id = ? + """, + (name,), + ) + rows = cur.fetchall() + return [dict(row) for row in rows] + + +""" +# --- 1. Fresh Pesto Pasta --- +r1 = addRecipe("Fresh Pesto Pasta", "Classic Italian basil pesto with linguine.", 301) +i1 = addIngredient("Linguine", "grams") +i2 = addIngredient("Fresh Basil", "grams") +i3 = addIngredient("Pine Nuts", "grams") +i4 = addIngredient("Parmesan", "grams") +addIngredientToRecipe(i1, r1, 200.0) +addIngredientToRecipe(i2, r1, 50.0) +addIngredientToRecipe(i3, r1, 30.0) +addIngredientToRecipe(i4, r1, 40.0) + +# --- 2. Beef Tacos --- +r2 = addRecipe("Beef Tacos", "Street-style seasoned beef tacos.", 302) +i5 = addIngredient("Corn Tortillas", "pieces") +i6 = addIngredient("Ground Beef", "grams") +i7 = addIngredient("Cumin", "teaspoons") +addIngredientToRecipe(i5, r2, 3.0) +addIngredientToRecipe(i6, r2, 150.0) +addIngredientToRecipe(i7, r2, 1.0) + +# --- 3. Greek Salad --- +r3 = addRecipe("Greek Salad", "Refreshing cucumber and feta salad.", 303) +i8 = addIngredient("Cucumber", "pieces") +i9 = addIngredient("Feta Cheese", "grams") +i10 = addIngredient("Kalamata Olives", "pieces") +addIngredientToRecipe(i8, r3, 1.0) +addIngredientToRecipe(i9, r3, 100.0) +addIngredientToRecipe(i10, r3, 10.0) + +# --- 4. Mushroom Risotto --- +r4 = addRecipe("Mushroom Risotto", "Creamy arborio rice with wild mushrooms.", 304) +i11 = addIngredient("Arborio Rice", "grams") +i12 = addIngredient("Mushrooms", "grams") +i13 = addIngredient("Vegetable Broth", "ml") +addIngredientToRecipe(i11, r4, 150.0) +addIngredientToRecipe(i12, r4, 200.0) +addIngredientToRecipe(i13, r4, 500.0) + +# --- 5. French Toast --- +r5 = addRecipe("French Toast", "Brioche soaked in cinnamon egg wash.", 305) +i14 = addIngredient("Brioche Bread", "slices") +i15 = addIngredient("Cinnamon", "teaspoons") +# Reusing 'Egg' and 'Milk' from previous datasets if they exist +addIngredientToRecipe(14, r5, 2.0) # Brioche +addIngredientToRecipe(2, r5, 2.0) # Egg (zid 2) +addIngredientToRecipe(15, r5, 0.5) # Cinnamon + +# --- 6. Margherita Pizza --- +r6 = addRecipe("Margherita Pizza", "Simple pizza with tomato, mozzarella, and basil.", 306) +i16 = addIngredient("Pizza Dough", "grams") +i17 = addIngredient("Mozzarella", "grams") +addIngredientToRecipe(i16, r6, 250.0) +addIngredientToRecipe(i17, r6, 120.0) +addIngredientToRecipe(i2, r6, 10.0) # Basil + +# --- 7. Salmon with Asparagus --- +r7 = addRecipe("Baked Salmon", "Lemon-butter salmon with roasted asparagus.", 307) +i18 = addIngredient("Salmon Fillet", "grams") +i19 = addIngredient("Asparagus", "grams") +i20 = addIngredient("Lemon", "pieces") +addIngredientToRecipe(i18, r7, 200.0) +addIngredientToRecipe(i19, r7, 150.0) +addIngredientToRecipe(i20, r7, 0.5) + +# --- 8. Chicken Stir-fry --- +r8 = addRecipe("Chicken Stir-fry", "Quick soy-ginger chicken and veggies.", 308) +i21 = addIngredient("Soy Sauce", "ml") +i22 = addIngredient("Ginger", "grams") +i23 = addIngredient("Broccoli", "grams") +addIngredientToRecipe(5, r8, 200.0) # Chicken Breast (zid 5) +addIngredientToRecipe(i21, r8, 30.0) +addIngredientToRecipe(i22, r8, 10.0) +addIngredientToRecipe(i23, r8, 100.0) + +# --- 9. Guacamole --- +r9 = addRecipe("Guacamole", "Chunky avocado dip with lime.", 309) +i24 = addIngredient("Avocado", "pieces") +i25 = addIngredient("Lime Juice", "ml") +addIngredientToRecipe(i24, r9, 3.0) +addIngredientToRecipe(i25, r9, 15.0) +addIngredientToRecipe(4, r9, 0.25) # Salt + +# --- 10. Chocolate Chip Cookies --- +r10 = addRecipe("Chocolate Cookies", "Chewy cookies with dark chocolate chips.", 310) +i26 = addIngredient("Chocolate Chips", "grams") +i27 = addIngredient("Vanilla Extract", "ml") +addIngredientToRecipe(1, r10, 250.0) # Flour +addIngredientToRecipe(14, r10, 150.0) # Butter +addIngredientToRecipe(11, r10, 100.0) # Sugar +addIngredientToRecipe(i26, r10, 100.0) +addIngredientToRecipe(i27, r10, 5.0) +""" + + +# ── Password-Reset-Token-Operationen ─────────────────────────── + + +def savePasswordResetToken(kontoID: int, tokenHash: str, expiresAt: str) -> None: + """Invalidiert alte Tokens des Kontos und speichert einen neuen.""" + with getDB() as con: + cur = con.cursor() + # Alte, ungenutzte Tokens für dieses Konto invalidieren + cur.execute( + "UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP " + "WHERE kontoID = ? AND usedAt IS NULL", + (kontoID,), + ) + cur.execute( + "INSERT INTO PasswordResetToken (kontoID, tokenHash, expiresAt) VALUES (?, ?, ?)", + (kontoID, tokenHash, expiresAt), + ) + + +def getPasswordResetToken(tokenHash: str) -> dict | None: + con = getConnection() + try: + cur = con.cursor() + cur.execute( + "SELECT id, kontoID, tokenHash, expiresAt, usedAt " + "FROM PasswordResetToken WHERE tokenHash = ?", + (tokenHash,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + con.close() + + +def markResetTokenUsed(tokenID: int) -> None: + with getDB() as con: + cur = con.cursor() + cur.execute( + "UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP WHERE id = ?", + (tokenID,), + ) + + +def updateKontoPassword(konto_id: int, hashed_password: str) -> None: + with getDB() as con: + cur = con.cursor() + cur.execute( + "UPDATE Account SET hashedPassword = ? WHERE id = ?", + (hashed_password, konto_id), + ) + + +# ── Ingredient-Usage-Operationen ─────────────────────────────── + + +def incrementIngredientUsage(AccountID: int, name: str, unit: str | None) -> None: + """Erhöht den Usage-Counter für eine Zutat um 1, aktualisiert lastUnit und lastUsedAt. + Legt einen neuen Eintrag an, falls die Zutat für diesen Account noch nicht existiert. + Der Name wird normalisiert (trim + lowercase) für den Primary Key, displayName behält + die Originalschreibweise der letzten Eingabe. + """ + displayName = (name or "").strip() + if not displayName: + return + normalizedName = displayName.lower() + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + INSERT INTO IngredientUsage (AccountID, name, displayName, count, lastUnit, lastUsedAt) + VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP) + ON CONFLICT(AccountID, name) DO UPDATE SET + count = count + 1, + displayName = excluded.displayName, + lastUnit = excluded.lastUnit, + lastUsedAt = CURRENT_TIMESTAMP + """, + (AccountID, normalizedName, displayName, unit), + ) + + +def getTopIngredients(AccountID: int, limit: int = 5) -> list[dict]: + """Liefert die meistgenutzten Zutaten des Users. + Sortierung Hybrid: erst nach count DESC, dann nach lastUsedAt DESC als Tie-Breaker. + """ + con = getConnection() + try: + cur = con.cursor() + cur.execute( + """ + SELECT displayName, lastUnit, count, lastUsedAt + FROM IngredientUsage + WHERE AccountID = ? + ORDER BY count DESC, lastUsedAt DESC + LIMIT ? + """, + (AccountID, limit), + ) + return [dict(row) for row in cur.fetchall()] + finally: + con.close() + + +def getAccountById(konto_id: int) -> dict | None: + """Gibt Account-Daten anhand der ID zurück, oder None.""" + con = getConnection() + try: + cur = con.cursor() + cur.execute( + "SELECT id, email, name, hashedPassword FROM Account WHERE id = ?", + (konto_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + con.close() diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py index 96d1a23..d978e0d 100644 --- a/project/backend/EmailService.py +++ b/project/backend/EmailService.py @@ -32,3 +32,83 @@ def sendPasswordChangedEmail(to_email: str, name: str) -> None: except Exception as e: print(f"E-Mail Fehler: {e}") raise + + +def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = "Passwort zurücksetzen – Lazy Cook" + msg["From"] = gmailUser + msg["To"] = to_email + + html = f""" +
+

Hallo {name},

+

du hast angefordert, dein Passwort bei Lazy Cook zurückzusetzen.

+

Klicke auf den Button, um ein neues Passwort festzulegen:

+

+ + Passwort zurücksetzen + +

+

+ Oder kopiere diesen Link in deinen Browser:
+ {resetLink} +

+

Der Link ist 30 Minuten gültig.

+

Falls du das nicht angefordert hast, ignoriere diese E-Mail einfach – dein Passwort bleibt unverändert.

+
+

– Das Lazy Cook Team

+
+ """ + msg.attach(MIMEText(html, "html")) + + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(gmailUser, gmailPassword) + server.sendmail(gmailUser, to_email, msg.as_string()) + + except Exception as e: + print(f"E-Mail Fehler: {e}") + raise + + +def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = "Passwort zurücksetzen – Lazy Cook" + msg["From"] = gmailUser + msg["To"] = to_email + + html = f""" +
+

Hallo {name},

+

du hast angefordert, dein Passwort bei Lazy Cook zurückzusetzen.

+

Klicke auf den Button, um ein neues Passwort festzulegen:

+

+ + Passwort zurücksetzen + +

+

+ Oder kopiere diesen Link in deinen Browser:
+ {resetLink} +

+

Der Link ist 30 Minuten gültig.

+

Falls du das nicht angefordert hast, ignoriere diese E-Mail einfach – dein Passwort bleibt unverändert.

+
+

– Das Lazy Cook Team

+
+ """ + msg.attach(MIMEText(html, "html")) + + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(gmailUser, gmailPassword) + server.sendmail(gmailUser, to_email, msg.as_string()) + + except Exception as e: + print(f"E-Mail Fehler: {e}") + raise diff --git a/project/backend/Ingredient.py b/project/backend/Ingredient.py new file mode 100644 index 0000000..154caba --- /dev/null +++ b/project/backend/Ingredient.py @@ -0,0 +1,36 @@ +from typing import List, Dict, Any +from Database import addIngredient + + +class Ingredient: + def __init__(self, name: str, amount: float): + self.__name = name + self.__amount = amount + + def getName(self) -> str: + return self.__name + + def setName(self, name: str): + self.__name = name + + def getAmount(self) -> float: + return self.__amount + + def setAmount(self, amount: float): + self.__amount = amount + + def setAmountType(self, amountType: str): + self.__amountType = amountType + + def getAmountType(self): + return self.__amountType + + def saveInDB(self): + if not self.__amountType: + return False + else: + zid = addIngredient(self.__name, self.__amountType) + if not zid: + return False + else: + return True diff --git a/project/backend/Ingridient.py b/project/backend/Ingridient.py deleted file mode 100644 index 232b9f3..0000000 --- a/project/backend/Ingridient.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import List, Dict, Any - - -class Ingridient: - def __init__(self, name: str, amount: float): - self.__name = name - self.__amount = amount - - def getName(self) -> str: - return self.__name - - def setName(self, name: str): - self.__name = name - - def getAmount(self) -> float: - return self.__amount - - def setAmount(self, amount: float): - self.__amount = amount diff --git a/project/backend/LazyCookAdministration.py b/project/backend/LazyCookAdministration.py index bb0802c..9e38512 100644 --- a/project/backend/LazyCookAdministration.py +++ b/project/backend/LazyCookAdministration.py @@ -1,3 +1,4 @@ +import os from contextlib import asynccontextmanager from fastapi import FastAPI @@ -6,6 +7,9 @@ from Database import initDB from Routes import router +# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000) +FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000") + @asynccontextmanager async def lifespan(app: FastAPI): @@ -17,7 +21,7 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:8000"], + allow_origins=[FRONTEND_URL], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/project/backend/Models.py b/project/backend/Models.py index 5e15029..462b9db 100644 --- a/project/backend/Models.py +++ b/project/backend/Models.py @@ -4,6 +4,7 @@ class Token(BaseModel): access_token: str + refresh_token: str token_type: str @@ -24,3 +25,29 @@ class RefreshRequest(BaseModel): class LogoutRequest(BaseModel): refresh_token: str + + +class ForgotPasswordRequest(BaseModel): + email: str + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + + +class UpdateUser(BaseModel): + email: str | None = None + currentPassword: str | None = None + newPassword: str | None = None + + +class IngredientSearch(BaseModel): + name: str + amount: float + unit: str + + +class RecipeSearchRequest(BaseModel): + zutaten: list[IngredientSearch] + servings: int diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index a255948..b9ce137 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -1,53 +1,66 @@ -from Database import Database -from Ingridient import Ingridient +from Ingredient import Ingredient +from Database import addIngredientToRecipe, addRecipe, getIngridientByName class Recipe: - def __init__(self, name: str, ingridients: list[Ingridient], description: str): + def __init__(self, name: str, ingredients: list[Ingredient], description: str): self.__name = name - self.__Ingridienten = ingridients + self.__ingredients = ingredients self.__description = description self.__original = "" self.__duration = "" - self.__rating = 0 + self.__rating = 0.0 self.__countPersons = 1 - self.__database = Database() + self.__matching = 0 def saveInDB(self) -> bool: + rid = addRecipe(self.__name, self.__description, None) + for ingridient in self.__ingredients: + zid = getIngridientByName(ingridient.getName()) + if not zid: + return False + else: + addIngredientToRecipe(zid, rid, ingridient.getAmount()) return True def getName(self) -> str: - return self.name + return self.__name def setName(self, name: str): - self.name = name + self.__name = name def getOriginal(self) -> str: - return self.original + return self.__original def setOriginal(self, original: str): - self.original = original + self.__original = original def getDescription(self) -> str: - return self.description + return self.__description def setDescription(self, description: str): - self.description = description + self.__description = description + + def getRating(self) -> float: + return self.__rating + + def setRating(self, rating: float): + self.__rating = rating - def getRating(self) -> int: - return self.rating + def getMatching(self) -> int: + return self.__matching - def setRating(self, rating: int): - self.rating = rating + def incrementMatching(self): + self.__matching += 1 def getDuration(self) -> str: - return self.duration + return self.__duration def setDuration(self, duration: str): - self.duration = duration + self.__duration = duration - def getIngridient(self) -> list[Ingridient]: - return self.ingridients + def getingredients(self) -> list[Ingredient]: + return self.__ingredients - def setIngridient(self, ingridients: list[Ingridient]): - self.ingridients = ingridients + def setIngredient(self, ingredients: list[Ingredient]): + self.__ingredients = ingredients diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py new file mode 100644 index 0000000..56834e3 --- /dev/null +++ b/project/backend/RecipeSucuk.py @@ -0,0 +1,63 @@ +import Database +from Ingredient import Ingredient +from Recipe import Recipe +from Database import getAllRecipes, getAllIngredientsForRecipe + + +def findRecipes(ingriedents: list[Ingredient]) -> list[Recipe]: + recipes = __initRecipes() + + for Ingredient in ingriedents: + recipes = __filterRecipes(recipes, Ingredient) + + recipes.sort(key=lambda x: x.getRating(), reverse=True) + + for recipe in recipes[::-1]: + if recipe.getRating() < 0.3: + recipes.remove(recipe) + else: + break + return recipes + + +def __filterRecipes(recipes: list[Recipe], Ingredient: Ingredient) -> list[Recipe]: + for recipe in recipes: + for recipeIngredient in recipe.getIngredients(): + if recipeIngredient.getName() == Ingredient.getName(): + recipe.incrementMatching() + recipe.setRating(recipe.getMatching() / len(recipe.getIngredients())) + return recipes + + +def __initRecipes() -> list[Recipe]: + recipesRaw = getAllRecipes() + recipes = [] + for recipeRaw in recipesRaw: + recipe = Recipe( + recipeRaw["name"], + __formatIngredients(recipeRaw["id"]), + recipeRaw["description"], + ) + recipes.append(recipe) + return recipes + + +def __formatIngredients(id: int) -> list[Ingredient]: + IngredientsRaw = getAllIngredientsForRecipe(id) + Ingredients = [] + for IngredientRaw in IngredientsRaw: + Ingredients.append(Ingredient(IngredientRaw["name"], IngredientRaw["amount"])) + return Ingredients + + +ini = [] +ini.append(Ingredient("Linguine", 10)) +ini.append(Ingredient("Fresh Basil", 10)) +ini.append(Ingredient("Pine Nuts", 10)) +ini.append(Ingredient("Parmesan", 10)) +ini.append(Ingredient("Ground Beef", 10)) +ini.append(Ingredient("Cumin", 10)) +ini.append(Ingredient("Cucumber", 10)) +arr = findRecipes(ini) +for i in arr: + print(i.getName() + " " + str(i.getRating())) diff --git a/project/backend/Routes.py b/project/backend/Routes.py index 1850bc8..868108d 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -1,8 +1,8 @@ +import os from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi.security import OAuth2PasswordRequestForm -from EmailService import sendPasswordChangedEmail -from pydantic import BaseModel as _BaseModel +from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail from Auth import ( @@ -13,18 +13,37 @@ validatePassword, validateRefreshToken, verifyPassword, - createAccessToken, + createPasswordResetToken, + validatePasswordResetToken, ) from Database import ( createAccount, getAccountByEmail, + getAccountById, deleteRefreshToken, deleteAllRefreshTokens, deleteAccount, updateAccount, + markResetTokenUsed, + updateKontoPassword, + incrementIngredientUsage, + getTopIngredients, ) -from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest +from Models import ( + User, + Token, + UserCreate, + RefreshRequest, + LogoutRequest, + ForgotPasswordRequest, + ResetPasswordRequest, + UpdateUser, + RecipeSearchRequest, +) + +# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000) +FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000") router = APIRouter() @@ -79,7 +98,7 @@ async def refresh(body: RefreshRequest): deleteRefreshToken(body.refresh_token) # Account-Daten für neues Token-Paar zusammenstellen - Account = {"id": entry["konto_id"], "email": entry["email"]} + Account = {"id": entry["AccountID"], "email": entry["email"]} return createTokenPair(Account) @@ -107,12 +126,6 @@ async def deleteCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser) # ── Account aktualisieren ──────────────────────────────────────── -class UpdateUser(_BaseModel): - email: str | None = None - currentPassword: str | None = None - newPassword: str | None = None - - @router.patch("/users/me") async def updateCurrentUser( data: UpdateUser, currentUser: Annotated[User, Depends(getCurrentUser)] @@ -129,7 +142,7 @@ async def updateCurrentUser( # Passwort ändern if data.currentPassword and data.newPassword: if not verifyPassword(data.currentPassword, Account["hashedPassword"]): - raise HTTPException(status_code=401, detail="Falsches Passwort") + raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch") error = validatePassword(data.newPassword) if error: @@ -146,3 +159,111 @@ async def updateCurrentUser( print(f"E-Mail konnte nicht gesendet werden: {e}") return {"success": True} + + +# ------------ Passwort vergessen ----------------- # + + +@router.post("/auth/forgot-password") +async def forgotPassword(body: ForgotPasswordRequest): + """Schritt 1: User gibt E-Mail ein, bekommt Reset-Link per Mail.""" + konto = getAccountByEmail(body.email) + + # Token nur erzeugen wenn Konto existiert – aber IMMER gleiche Antwort senden! + if konto is not None: + token = createPasswordResetToken(konto["id"]) + resetLink = f"{FRONTEND_URL}/reset-password?token={token}" + try: + sendPasswordResetEmail(body.email, konto["name"], resetLink) + except Exception as e: + # Mail-Fehler darf das Response nicht beeinflussen → User-Enumeration vermeiden + print(f"Reset-Mail konnte nicht gesendet werden: {e}") + + return {"detail": "Falls die E-Mail existiert, wurde ein Link versendet."} + + +@router.post("/auth/reset-password") +async def resetPassword(body: ResetPasswordRequest): + """Schritt 2: User setzt mit Token aus Mail das neue Passwort.""" + pwError = validatePassword(body.new_password) + if pwError: + raise HTTPException(status_code=400, detail=pwError) + + entry = validatePasswordResetToken(body.token) + if entry is None: + raise HTTPException( + status_code=400, + detail="Link ungültig oder abgelaufen. Bitte neuen anfordern.", + ) + + updateKontoPassword(entry["kontoID"], hashPassword(body.new_password)) + markResetTokenUsed(entry["id"]) + # Sicherheitsmaßnahme: Alle aktiven Sessions ungültig machen + deleteAllRefreshTokens(entry["kontoID"]) + + # Bestätigungsmail an den Konto-Inhaber + konto = getAccountById(entry["kontoID"]) + if konto is not None: + try: + sendPasswordChangedEmail(konto["email"], konto["name"]) + except Exception as e: + print(f"Bestätigungsmail konnte nicht gesendet werden: {e}") + + return {"detail": "Passwort erfolgreich zurückgesetzt."} + + +# ── Recipe-Suche ─────────────────────────────────────────────── + + +@router.post("/recipes/search") +async def searchRecipes( + body: RecipeSearchRequest, + currentUser: Annotated[User, Depends(getCurrentUser)], +): + """Sucht Rezepte basierend auf den übergebenen Zutaten. + + Hinweis: Aktuell nur Usage-Tracking implementiert – die eigentliche + Rezept-Suche folgt. Jede gesuchte Zutat erhöht den persönlichen Counter + des Users (IngredientUsage), damit später die Top-5-Vorschläge daraus + abgeleitet werden können. + """ + Account = getAccountByEmail(currentUser.email) + if Account is None: + raise HTTPException(status_code=404, detail="Account nicht gefunden") + + # Usage-Tracking: jede gesuchte Zutat hochzählen (Hybrid: count + lastUsedAt + lastUnit) + for zutat in body.zutaten: + incrementIngredientUsage(Account["id"], zutat.name, zutat.unit) + + # Aktualisierte Top 5 direkt mit zurückgeben → Frontend muss keinen Extra-Request machen + topRows = getTopIngredients(Account["id"], limit=5) + topIngredients = [ + {"name": r["displayName"], "unit": r["lastUnit"]} for r in topRows + ] + + # TODO: eigentliche Rezept-Suche implementieren + return {"rezepte": [], "topIngredients": topIngredients} + + +# ── Ingredient-Vorschläge ────────────────────────────────────── + + +@router.get("/ingredients/top") +async def getTopIngredientsForUser( + response: Response, + currentUser: Annotated[User, Depends(getCurrentUser)], + limit: int = 5, +): + """Liefert die meistgenutzten Zutaten des aktuellen Users für die Vorschlags-Badges.""" + Account = getAccountByEmail(currentUser.email) + if Account is None: + raise HTTPException(status_code=404, detail="Account nicht gefunden") + + # Caching unterbinden – die Liste ändert sich mit jeder Suche + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + + rows = getTopIngredients(Account["id"], limit=limit) + return { + "ingredients": [{"name": r["displayName"], "unit": r["lastUnit"]} for r in rows] + } diff --git a/project/compose.yaml b/project/compose.yaml index 6bffc1c..c63814c 100644 --- a/project/compose.yaml +++ b/project/compose.yaml @@ -17,6 +17,7 @@ services: - PYTHONUNBUFFERED=1 - GMAIL_USER=${GMAIL_USER} - GMAIL_PASSWORD=${GMAIL_PASSWORD} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000} volumes: - ./data:/data ports: diff --git a/project/frontend/app/components/fields.tsx b/project/frontend/app/components/fields.tsx index 9e3fdae..9227038 100644 --- a/project/frontend/app/components/fields.tsx +++ b/project/frontend/app/components/fields.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; import styles from "../homepage/page.module.css"; export default function Field({ label, type = "text", value, onChange, placeholder, onKeyDown, onBlur, state = "default" }: { @@ -7,6 +9,10 @@ export default function Field({ label, type = "text", value, onChange, placehold onBlur?: () => void; state?: "default" | "error" | "success"; }) { + const [showPassword, setShowPassword] = useState(false); + const isPassword = type === "password"; + const effectiveType = isPassword && showPassword ? "text" : type; + const inputClass = state === "error" ? styles.inputError : state === "success" ? styles.inputSuccess : @@ -15,15 +21,40 @@ export default function Field({ label, type = "text", value, onChange, placehold return (
- onChange(e.target.value)} - placeholder={placeholder} - onKeyDown={onKeyDown} - onBlur={onBlur} - /> +
+ onChange(e.target.value)} + placeholder={placeholder} + onKeyDown={onKeyDown} + onBlur={onBlur} + style={isPassword ? { paddingRight: 38 } : undefined} + /> + {isPassword && ( + + )} +
); -} \ No newline at end of file +} diff --git a/project/frontend/app/global.css b/project/frontend/app/globals.css similarity index 99% rename from project/frontend/app/global.css rename to project/frontend/app/globals.css index fddc17d..83073d3 100644 --- a/project/frontend/app/global.css +++ b/project/frontend/app/globals.css @@ -40,7 +40,7 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); - --text-base: 10 px; + --text-base: 10px; --text-2xl: 30px; --text-lg: 20px; --text-xl:25px; diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx new file mode 100644 index 0000000..cd2ec07 --- /dev/null +++ b/project/frontend/app/homepage/forgotPassword.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import Field from "@/app/components/fields"; +import styles from "./page.module.css"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () => void; onBack: () => void; }) { + const [email, setEmail] = useState(""); + const [busy, setBusy] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [emailBlurred, setEmailBlurred] = useState(false); + const [error, setError] = useState(""); + + const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const handleSubmit = async () => { + setEmailBlurred(true); + setError(""); + if (!emailValid) return; + setBusy(true); + try { + const res = await fetch(`${API_URL}/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.detail ?? `Fehler beim Anfordern (Status ${res.status}).`); + return; + } + setSubmitted(true); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + setError( + msg.includes("Failed to fetch") || msg === "Network Error" + ? "Backend nicht erreichbar." + : (msg || "Unbekannter Fehler.") + ); + } finally { + setBusy(false); + } + }; + + if (submitted) { + return ( +
+

E-Mail versendet

+

+ Falls ein Konto mit dieser E-Mail existiert, haben wir dir einen + Link zum Zurücksetzen geschickt. Prüfe auch deinen Spam-Ordner. +

+ +
+ ); + } + + return ( +
+

Passwort vergessen

+

+ Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum + Zurücksetzen. +

+ {error &&

{error}

} + setEmail(v)} + placeholder="Email" + onBlur={() => setEmailBlurred(true)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + state={ + emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default" + } + /> + +

+ +

+
+ ); +} \ No newline at end of file diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index 5c04f67..c00423a 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -7,11 +7,12 @@ import {useCallback, useState} from "react"; import Modal from "@/app/components/modal"; import LoginForm from "@/app/homepage/signin"; import RegisterForm from "@/app/homepage/signup"; +import ForgotPasswordForm from "@/app/homepage/forgotPassword"; export default function Homepage() { - const [modal, setModal] = useState<"login" | "register" | null>(null); + const [modal, setModal] = useState<"login" | "register" | "forgot"| null>(null); const close = useCallback(() => setModal(null), []); @@ -26,6 +27,7 @@ export default function Homepage() { @@ -191,12 +193,16 @@ export default function Homepage() { {/* ── Modals ──────────────────────────────────────────── */} - setModal("register")} /> + setModal("register")} onForgot={() => setModal("forgot")} /> setModal("login")} /> + + + setModal("login")} /> + ); } \ No newline at end of file diff --git a/project/frontend/app/homepage/signin.tsx b/project/frontend/app/homepage/signin.tsx index 1e40ca9..dcdd673 100644 --- a/project/frontend/app/homepage/signin.tsx +++ b/project/frontend/app/homepage/signin.tsx @@ -5,7 +5,7 @@ import Field from "@/app/components/fields" import styles from "./page.module.css"; import {useRouter} from "next/navigation"; -export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; onSwitch: () => void }) { +export default function LoginForm({ onClose, onSwitch, onForgot}: { onClose: () => void; onSwitch: () => void; onForgot: () => void }) { const { login } = useAuth(); const router = useRouter(); const [email, setEmail] = useState(""); @@ -33,8 +33,18 @@ export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; await login(email, password); onClose(); router.push("/recipeFinder"); - } catch { - setError("E-Mail oder Passwort falsch."); + } catch (err) { + // Klassifizieren statt pauschal "Passwort falsch" + const msg = err instanceof Error ? err.message : ""; + if (msg === "Login fehlgeschlagen") { + setError("E-Mail oder Passwort falsch."); + } else if (msg.includes("Backend-Response unvollständig")) { + setError("Server-Antwort unvollständig. Backend-Container neu bauen?"); + } else if (msg.includes("Failed to fetch") || msg === "Network Error") { + setError("Backend nicht erreichbar."); + } else { + setError(msg || "Unbekannter Fehler beim Anmelden."); + } } finally { setBusy(false); } @@ -47,6 +57,9 @@ export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; {error &&

{error}

} { setEmail(v); if (emailBlurred) setEmailBlurred(false); }} placeholder="Email" onBlur={() => setEmailBlurred(true)} state={emailState()} /> e.key === "Enter" && handleSubmit()} onBlur={() => setPwBlurred(true)} state={pwBlurred && !password ? "error" : "default"} /> +

+ +

diff --git a/project/frontend/app/layout.tsx b/project/frontend/app/layout.tsx index 407b567..4f16d75 100644 --- a/project/frontend/app/layout.tsx +++ b/project/frontend/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import "./global.css"; +import "./globals.css"; import { AuthProvider } from "@/lib/auth"; const geistSans = Geist({ @@ -23,13 +23,13 @@ export default function RootLayout({children,}: Readonly<{ }>) { return ( - - - {children} - - + + + {children} + + ); } \ No newline at end of file diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx new file mode 100644 index 0000000..1d1dd3b --- /dev/null +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -0,0 +1,57 @@ +import {Button} from "@/app/components/ui/button"; +import {fetchWithAuth} from "@/lib/auth"; +import {useState} from "react"; +import "../recipeFinder/style.css" + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; +export default function ChangeEmail (){ + + + const [newEmail, setNewEmail] = useState(""); + const [emailMsg, setEmailMsg] = useState(""); + + async function handleEmailChange() { + setEmailMsg(""); + try { + const res = await fetchWithAuth(`${API_URL}/users/me`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: newEmail }), + }); + if (!res.ok) { + const err = await res.json(); + setEmailMsg(`${err.detail}`); + return; + } + setEmailMsg("E-Mail erfolgreich geändert."); + setNewEmail(""); + } catch { + setEmailMsg("Unbekannter Fehler."); + } + } + + return ( +
+

E-Mail ändern

+
+ setNewEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleEmailChange()} + className="popup__input" + /> +
+ {emailMsg &&

{emailMsg}

} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx new file mode 100644 index 0000000..0eccf7f --- /dev/null +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -0,0 +1,171 @@ +import {fetchWithAuth} from "@/lib/auth"; +import {useState} from "react"; +import {Button} from "@/app/components/ui/button"; +import {Eye, EyeOff} from "lucide-react"; +import "../recipeFinder/style.css" +import Field from "@/app/components/fields"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +function PasswordInput({ value, onChange, placeholder, onKeyDown, ariaLabel }: { + value: string; + onChange: (v: string) => void; + placeholder: string; + onKeyDown?: (e: React.KeyboardEvent) => void; + ariaLabel?: string; +}) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + onKeyDown={onKeyDown} + className="popup__input" + style={{ width: "100%", paddingRight: 38 }} + /> + +
+ ); +} + +type Modus = "change" | "forgot"; + +interface ChangePasswordProps { + modus: Modus; + onSuccess?: () => void; +} + +export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps) { + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordMsg, setPasswordMsg] = useState(""); + const [pwBlurred, setPwBlurred] = useState(false); + + const isForgot = modus === "forgot"; + + async function handlePasswordChange() { + setPasswordMsg(""); + + // Pflichtfelder im "change"-Modus + if (!isForgot && !currentPassword) { + setPasswordMsg("Bitte das aktuelle Passwort eingeben."); + return; + } + if (!newPassword) { + setPasswordMsg("Bitte ein neues Passwort eingeben."); + return; + } + if (!confirmPassword) { + setPasswordMsg("Bitte das neue Passwort bestätigen."); + return; + } + + // Bestätigung prüfen + if (newPassword !== confirmPassword) { + setPasswordMsg("Die Passwörter stimmen nicht überein."); + return; + } + + try { + const endpoint = isForgot + ? `${API_URL}/users/forgot-password` + : `${API_URL}/users/me`; + + const body = isForgot + ? { newPassword } + : { currentPassword, newPassword }; + + const fetcher = isForgot ? fetch : fetchWithAuth; + + const res = await fetcher(endpoint, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.json(); + setPasswordMsg(`❌ ${err.detail}`); + return; + } + + setPasswordMsg( + isForgot + ? "Passwort erfolgreich zurückgesetzt." + : "Passwort erfolgreich geändert." + ); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + onSuccess?.(); + } catch { + setPasswordMsg('❌ Unbekannter Fehler'); + } + } + + return ( +
+

+ {isForgot ? "Passwort zurücksetzen" : "Passwort ändern"} +

+ +
+ {!isForgot && ( + setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} />)} + + + e.key === "Enter" && handlePasswordChange()} + /> + + e.key === "Enter" && handlePasswordChange()} + /> +
+ + {passwordMsg &&

{passwordMsg}

} + +
+ +
+
+ + ); +} \ No newline at end of file diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index 1f5a269..a6be68f 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -1,76 +1,44 @@ "use client"; -import { useRef, useState} from "react"; +import {useEffect, useState} from "react"; import { useAuth, fetchWithAuth } from "@/lib/auth"; import { useRouter } from "next/navigation"; import {ChefHat} from "lucide-react"; import { Button } from '../components/ui/button'; import ProfileDropdown from "@/app/components/profile_dropdown"; +import Modal from "@/app/components/modal"; +import ChangeEmail from "@/app/profile/changeEmailPopup"; +import ChangePassword from "@/app/profile/changePasswordPopup"; + +import "../recipeFinder/style.css" const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function Profile() { - - const menuRef = useRef(null); - const { user, logout } = useAuth(); + const { user, loading, logout } = useAuth(); const router = useRouter(); + const [showConfirm, setShowConfirm] = useState(false); const [showEmailModal, setShowEmailModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); - // E-Mail ändern - const [newEmail, setNewEmail] = useState(""); - const [emailMsg, setEmailMsg] = useState(""); - - // Passwort ändern - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [passwordMsg, setPasswordMsg] = useState(""); - - if (!user) return null; - - async function handleEmailChange() { - setEmailMsg(""); - try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: newEmail }), - }); - if (!res.ok) { - const err = await res.json(); - setEmailMsg(`${err.detail}`); - return; - } - setEmailMsg("E-Mail erfolgreich geändert."); - setNewEmail(""); - } catch { - setEmailMsg("Unbekannter Fehler."); + useEffect(() => { + if (!loading && !user) { + router.replace("/"); } - } + }, [loading, user, router]); - async function handlePasswordChange() { - setPasswordMsg(""); - try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currentPassword, newPassword }), - }); - if (!res.ok) { - const err = await res.json(); - setPasswordMsg(`❌ ${err.detail}`); - return; - } - setPasswordMsg("Passwort erfolgreich geändert."); - setCurrentPassword(""); - setNewPassword(""); - } catch { - setPasswordMsg("Unbekannter Fehler."); - } + if (loading) { + return ( +
+

Laden…

+
+ ); } + if (!user) return null; + async function handleAccountDeletion() { const res = await fetchWithAuth(`${API_URL}/users/me`, { method: "DELETE" }); if (!res.ok) throw new Error("Konto löschen fehlgeschlagen"); @@ -111,13 +79,13 @@ export default function Profile() {

Einstellungen

- {/* E-Mail Modal */} - {showEmailModal && ( -
setShowEmailModal(false)} - > -
e.stopPropagation()} - > -

E-Mail ändern

- setNewEmail(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - {emailMsg &&

{emailMsg}

} -
- - -
-
-
- )} + setShowConfirm(false)}> +
+

+ Konto wirklich löschen? +

+

+ + +

- {/* Passwort Modal */} - {showPasswordModal && ( -
setShowPasswordModal(false)} - > -
e.stopPropagation()} - > -

Passwort ändern

- setCurrentPassword(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - setNewPassword(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - {passwordMsg &&

{passwordMsg}

} -
- - -
-
- )} + + + setShowEmailModal(false)}> + + + + setShowPasswordModal(false)}> + +
); } diff --git a/project/frontend/app/recipeFinder/page.tsx b/project/frontend/app/recipeFinder/page.tsx index a0a276b..458c09f 100644 --- a/project/frontend/app/recipeFinder/page.tsx +++ b/project/frontend/app/recipeFinder/page.tsx @@ -3,7 +3,7 @@ import {useEffect, useRef, useState} from "react"; import { useRouter } from "next/navigation"; import {fetchWithAuth, useAuth} from "@/lib/auth"; -import {ChefHat, LogOut, X, User, UserCircle, Search, Plus} from "lucide-react"; +import {ChefHat, X, Search, Plus} from "lucide-react"; import "./style.css" import {Button} from "@/app/components/ui/button"; import Modal from "@/app/components/modal"; @@ -18,8 +18,33 @@ interface IngredientInput { unit: string; } +interface Suggestion { + name: string; + unit: string | null; +} + +// Reine Helper-Funktion auf Modulebene – schließt keinen Component-State ein, +// damit useEffect/useCallback keine ändernden Closure-Variablen aufnehmen müssen. +async function loadTopIngredients(): Promise { + try { + const res = await fetchWithAuth(`/ingredients/top?limit=5&_=${Date.now()}`, { + cache: "no-store", + headers: { "Cache-Control": "no-cache" }, + }); + if (!res.ok) return null; + const data = await res.json(); + if (Array.isArray(data.ingredients)) { + localStorage.setItem("ingredientSuggestions", JSON.stringify(data.ingredients)); + return data.ingredients as Suggestion[]; + } + return null; + } catch { + return null; + } +} + export default function RecipeFinder() { - const { user, loading, logout } = useAuth(); + const { user, loading } = useAuth(); const router = useRouter(); const [open, setOpen] = useState(false); @@ -47,12 +72,43 @@ export default function RecipeFinder() { const [modalOpen, setModalOpen] = useState(false); + // Vorschläge: localStorage als sofortiger Initialwert (instant beim Öffnen), + // im Hintergrund per useEffect aktualisiert. + const [suggestions, setSuggestions] = useState(() => { + try { + const saved = localStorage.getItem("ingredientSuggestions"); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + useEffect(() => { if (!loading && !user) { router.replace("/"); } }, [loading, user, router]); + // Initial nach dem Login laden (damit sie beim ersten Popup-Öffnen sofort da sind) + useEffect(() => { + if (!user) return; + let cancelled = false; + loadTopIngredients().then(result => { + if (!cancelled && result) setSuggestions(result); + }); + return () => { cancelled = true; }; + }, [user]); + + // Bei jedem Öffnen des Popups erneut laden – aktuelle Daten nach jeder Suche + useEffect(() => { + if (!modalOpen || !user) return; + let cancelled = false; + loadTopIngredients().then(result => { + if (!cancelled && result) setSuggestions(result); + }); + return () => { cancelled = true; }; + }, [modalOpen, user]); + useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { @@ -115,6 +171,12 @@ export default function RecipeFinder() { }); const data = await res.json(); setResults(data.rezepte ?? []); + // Aktualisierte Top 5 direkt aus der Search-Response übernehmen – + // beim nächsten Popup-Öffnen sind die Vorschläge sofort aktuell, ohne extra Roundtrip + if (Array.isArray(data.topIngredients)) { + setSuggestions(data.topIngredients); + localStorage.setItem("ingredientSuggestions", JSON.stringify(data.topIngredients)); + } } catch { setSearchError("Suche fehlgeschlagen."); } finally { @@ -150,7 +212,15 @@ export default function RecipeFinder() {

Zutaten

- @@ -240,6 +310,7 @@ export default function RecipeFinder() { onAdd={handleAdd} servings={servings} onServingsChange={setServings} + suggestions={suggestions} />
diff --git a/project/frontend/app/recipeFinder/popup.tsx b/project/frontend/app/recipeFinder/popup.tsx index ec2f81b..444545b 100644 --- a/project/frontend/app/recipeFinder/popup.tsx +++ b/project/frontend/app/recipeFinder/popup.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import "./style.css"; const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; @@ -9,18 +9,25 @@ export interface IngredientInput { unit: string; } +export interface Suggestion { + name: string; + unit: string | null; +} + interface Props { ingredients: IngredientInput[]; onAdd: (ingredient: IngredientInput) => void; servings: number; onServingsChange: (s: number) => void; + suggestions?: Suggestion[]; } -export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { +export default function AddIngredientsPopup({ ingredients, onAdd, suggestions = [] }: Props) { const [ingredientName, setIngredientName] = useState(""); const [ingredientAmount, setIngredientAmount] = useState(""); const [ingredientUnit, setIngredientUnit] = useState("Stück"); const [inputError, setInputError] = useState(""); + const amountInputRef = useRef(null); const handleAddIngredient = () => { const trimmedName = ingredientName.trim(); @@ -40,6 +47,21 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { setInputError(""); }; + const handleSuggestionClick = (s: Suggestion) => { + setIngredientName(s.name); + if (s.unit && EINHEITEN.includes(s.unit)) { + setIngredientUnit(s.unit); + } + setInputError(""); + // Fokus aufs Mengen-Feld – User muss nur noch die Menge tippen + setTimeout(() => amountInputRef.current?.focus(), 0); + }; + + // Vorschläge bleiben sichtbar – bereits hinzugefügte werden disabled markiert, + // damit das Popup seine Größe behält. + const isAlreadyAdded = (s: Suggestion) => + ingredients.some(z => z.name.toLowerCase() === s.name.toLowerCase()); + return (

Zutaten Hinzufügen

@@ -54,6 +76,7 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { className="popup__input" /> setIngredientAmount(e.target.value)} @@ -71,6 +94,29 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
+ {suggestions.length > 0 && ( +
+

Häufig verwendet

+
+ {suggestions.map(s => { + const added = isAlreadyAdded(s); + return ( + + ); + })} +
+
+ )} + {inputError &&

{inputError}

}
); -} \ No newline at end of file +} diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css index fdc8996..63041c9 100644 --- a/project/frontend/app/recipeFinder/style.css +++ b/project/frontend/app/recipeFinder/style.css @@ -94,6 +94,10 @@ gap: 10px; } +.popup__fields--stacked { + flex-direction: column; +} + .popup__input { flex: 1; padding: 10px 12px; @@ -143,6 +147,62 @@ margin: 0; } +/* ── Zutaten-Vorschläge (Top 5 Badges) ─────────────────────── */ +.popup__suggestions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.popup__suggestions-label { + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + font-family: system-ui; +} + +.popup__suggestions-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.popup__suggestion-badge { + padding: 5px 12px; + background: #f3f3f5; + border: 1px solid #e5e7eb; + border-radius: 999px; + font-size: 13px; + font-family: system-ui; + color: #111; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.popup__suggestion-badge:hover { + background: #030213; + border-color: #030213; + color: #fff; +} + +.popup__suggestion-badge:focus-visible { + outline: 2px solid #030213; + outline-offset: 2px; +} + +.popup__suggestion-badge--added, +.popup__suggestion-badge--added:hover { + background: #f3f3f5; + border-color: #e5e7eb; + color: #9ca3af; + cursor: default; + text-decoration: line-through; + opacity: 0.6; +} + .popup__btn { display: block; margin: 0 auto; diff --git a/project/frontend/app/reset-password/page.tsx b/project/frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..aacc4d0 --- /dev/null +++ b/project/frontend/app/reset-password/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import React, { Suspense, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Field from "@/app/components/fields"; +import styles from "@/app/homepage/page.module.css"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +function ResetPasswordContent() { + const params = useSearchParams(); + const router = useRouter(); + const token = params.get("token") ?? ""; + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + + const pwValid = + password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /[0-9]/.test(password) && + /[^A-Za-z0-9]/.test(password); + const pwMatch = password === confirm && confirm.length > 0; + + const handleSubmit = async () => { + setError(""); + if (!pwValid) { + setError("Passwort erfüllt nicht alle Anforderungen."); + return; + } + if (!pwMatch) { + setError("Passwörter stimmen nicht überein."); + return; + } + setBusy(true); + try { + const res = await fetch(`${API_URL}/auth/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, new_password: password }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.detail ?? "Fehler beim Zurücksetzen."); + return; + } + setDone(true); + setTimeout(() => router.push("/"), 2500); + } catch { + setError("Verbindungsfehler."); + } finally { + setBusy(false); + } + }; + + if (!token) { + return ( +
+

Ungültiger Link

+

+ Der Link ist unvollständig. Bitte fordere einen neuen an. +

+
+ ); + } + + if (done) { + return ( +
+

Erledigt!

+

+ Dein Passwort wurde zurückgesetzt. Du wirst gleich weitergeleitet… +

+
+ ); + } + + return ( +
+

Neues Passwort festlegen

+

Wähle ein sicheres neues Passwort.

+ {error &&

{error}

} + e.key === "Enter" && handleSubmit()} + state={password && !pwValid ? "error" : "default"} + /> + e.key === "Enter" && handleSubmit()} + state={confirm && !pwMatch ? "error" : pwMatch ? "success" : "default"} + /> + +
+ ); +} + +export default function ResetPasswordPage() { + return ( + +

Wird geladen…

+ + } + > + +
+ ); +} \ No newline at end of file diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index cae660e..64c79d9 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -6,11 +6,10 @@ import { useState, useEffect, useCallback, - useRef, type ReactNode, } from "react"; -const API_URL = "http://localhost:3000"; +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; // ── Typen ───────────────────────────────────────────────────── export interface User { @@ -39,6 +38,9 @@ function getRefreshToken(): string | null { } function saveTokens(accessToken: string, refreshToken: string) { + if (!accessToken || !refreshToken) { + throw new Error("Tokens fehlen — Backend-Response unvollständig"); + } sessionStorage.setItem("access_token", accessToken); localStorage.setItem("refresh_token", refreshToken); } @@ -47,6 +49,7 @@ function clearTokens() { sessionStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); localStorage.removeItem("ingredients"); + localStorage.removeItem("ingredientSuggestions"); } // ── API-Aufrufe ─────────────────────────────────────────────── @@ -105,7 +108,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise