diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd68e63..a682cc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,37 +48,41 @@ jobs: - name: Run smoke test frontend run: curl -fsSL -o /dev/null http://localhost:8000/ - tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Docker Compose (tests job) - run: | - sudo apt-get update - sudo apt-get install -y docker-compose - - - name: Pull images (if available) or build - run: | - # Versuche remote images zu ziehen; falls nicht vorhanden, baue lokal neu. - docker-compose -f project/compose.yaml pull || docker-compose -f project/compose.yaml build - - - name: Start services - run: | - docker-compose -f project/compose.yaml down --remove-orphans --volumes || true - docker-compose -f project/compose.yaml up -d backend-dev - - - name: Wait for backend to be ready - run: | - for i in $(seq 1 20); do - docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -c 'print(\"ok\")'" >/dev/null 2>&1 && break - sleep 1 - done - - name: Run backend tests run: | docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v" +# tests: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# +# - name: Install Docker Compose (tests job) +# run: | +# sudo apt-get update +# sudo apt-get install -y docker-compose +# +# - name: Pull images (if available) or build +# run: | +# # Versuche remote images zu ziehen; falls nicht vorhanden, baue lokal neu. +# docker-compose -f project/compose.yaml pull || docker-compose -f project/compose.yaml build +# +# - name: Start services +# run: | +# docker-compose -f project/compose.yaml down --remove-orphans --volumes || true +# docker-compose -f project/compose.yaml up -d backend-dev +# +# - name: Wait for backend to be ready +# run: | +# for i in $(seq 1 20); do +# docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -c 'print(\"ok\")'" >/dev/null 2>&1 && break +# sleep 1 +# done +# +# - name: Run backend tests +# run: | +# docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v" + # lint: # runs-on: ubuntu-latest # needs: build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e198633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# LazyCook root ignore rules + +# Python +__pycache__/ +*.py[cod] +*.pyd +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Environment files +**/.env +**/.env.* + +# Node / frontend artifacts +node_modules/ +.next/ +out/ +coverage/ +*.tsbuildinfo + +# Build / packaging artifacts +dist/ +build/ + +# Project data / local databases +project/data/*.sqlite3 +project/data/*.sqlite +project/data/*.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS-specific files +.DS_Store +Thumbs.db +Desktop.ini + +# IDE configuration +.idea/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/LazyCook.iml b/.idea/LazyCook.iml deleted file mode 100644 index 17e0cf7..0000000 --- a/.idea/LazyCook.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml deleted file mode 100644 index 4ea72a9..0000000 --- a/.idea/copilot.data.migration.agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml deleted file mode 100644 index 7ef04e2..0000000 --- a/.idea/copilot.data.migration.ask.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml deleted file mode 100644 index 1f2ea11..0000000 --- a/.idea/copilot.data.migration.ask2agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml deleted file mode 100644 index 8648f94..0000000 --- a/.idea/copilot.data.migration.edit.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 9170a85..0000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/Project/backend/LazyCookDB.sqlite3 - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml deleted file mode 100644 index 5a20425..0000000 --- a/.idea/db-forest-config.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 0236026..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index a1f8a93..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml deleted file mode 100644 index 6ca4331..0000000 --- a/.idea/sqldialects.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index f644c44..978af29 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ 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. ## Features -- [ ] [#23](https://github.com/GalacticCodeGambit/LazyCook/issues/23) Zutaten hinzufügen können -- [ ] [#24](https://github.com/GalacticCodeGambit/LazyCook/issues/24) Zutaten entfernen können +- [x] [#23](https://github.com/GalacticCodeGambit/LazyCook/issues/23) Zutaten hinzufügen können +- [x] [#24](https://github.com/GalacticCodeGambit/LazyCook/issues/24) Zutaten entfernen können - [ ] [#25](https://github.com/GalacticCodeGambit/LazyCook/issues/25) Rezepte/Gerichte automatisch vorgeschlagen bekommen auf Grundlage der eingetragenen Zutaten - [ ] [#30](https://github.com/GalacticCodeGambit/LazyCook/issues/30) Personenzahl hinzufügen können und vorgeschlagene Rezept/Gericht Größe automatisch anpassen - [x] [#27](https://github.com/GalacticCodeGambit/LazyCook/issues/27) Als User mit Konto anmelden können @@ -18,8 +18,8 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - [ ] [#46](https://github.com/GalacticCodeGambit/LazyCook/issues/46) Konto löschen/bearbeiten können - [ ] Fehlende Zutaten werden von einem Rezept in einer Einkaufliste aufgelistet - [ ] Der Nutzer kann Rezepte in die Datenbank hinzufügen/erstellen -- [ ] [#47](https://github.com/GalacticCodeGambit/LazyCook/issues/47) Der Nutzer kann bereits hinzugefügte Zutaten bearbeiten, wie z.B. Menge, Einheit -- [ ] [#45](https://github.com/GalacticCodeGambit/LazyCook/issues/45) Passwörter "sicher" in Datenbank Abspeichern als Hash und vom Frontend/Backend "sicher" behandeln +- [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 ## Verwendete Technologien - Frontend: HTML, CSS, TypeScript/React diff --git a/docs/RMMM-Liste.pdf b/docs/RMMM-Liste.pdf deleted file mode 100644 index 25c55db..0000000 Binary files a/docs/RMMM-Liste.pdf and /dev/null differ diff --git a/docs/Risikomanagement/RMMM-Liste.pdf b/docs/Risikomanagement/RMMM-Liste.pdf new file mode 100644 index 0000000..ef0130c Binary files /dev/null and b/docs/Risikomanagement/RMMM-Liste.pdf differ diff --git a/docs/Risikomanagement/RMMM-Liste.xlsx b/docs/Risikomanagement/RMMM-Liste.xlsx new file mode 100644 index 0000000..a0acf2c Binary files /dev/null and b/docs/Risikomanagement/RMMM-Liste.xlsx differ diff --git a/project/Dockerfile-backend b/project/Dockerfile-backend index 43aecac..1b39aa3 100644 --- a/project/Dockerfile-backend +++ b/project/Dockerfile-backend @@ -2,17 +2,16 @@ FROM python:3.11.10-slim WORKDIR /app COPY backend/requirements.txt /app/requirements.txt -RUN python -m pip install --upgrade pip -RUN python -m pip install --no-cache-dir -r /app/requirements.txt -# Sicherstellen, dass uvicorn vorhanden ist, damit Gunicorn den UvicornWorker laden kann -RUN python -m pip install --no-cache-dir "uvicorn[standard]" +RUN python -m pip install --upgrade pip && \ + python -m pip install --no-cache-dir -r /app/requirements.txt COPY backend/ /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app +ENV WORKERS=4 #EXPOSE 3000 -CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:3000", "--workers", "4", "LazyCookVerwaltung:app"] +CMD ["sh", "-c", "gunicorn -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:3000 --workers ${WORKERS:-4} LazyCookAdministration:app"] diff --git a/project/backend/Admin.py b/project/backend/Admin.py index 2a5bdae..673bfad 100644 --- a/project/backend/Admin.py +++ b/project/backend/Admin.py @@ -1,13 +1,13 @@ -from project.backend.Person import Person -from project.backend.Rezept import Rezept +from Person import Person +from Recipe import Recipe class Admin(Person): - def __init__(self, name: str, email: str, passwort: str): - super().__init__(name, email, passwort) - self.__rezepte = [] + def __init__(self, name: str, email: str, password: str): + super().__init__(name, email, password) + self.__ricepes = [] super().setRole("Admin") - def fuegeRezeptHinzu(self, rezept: Rezept): - self.__rezepte.append(rezept) - print(f"Rezept '{rezept.getName()}' wurde zu Benutzer '{self.name}' hinzugefügt.") + def addRecipe(self, recipe: Recipe): + self.__ricepes.append(recipe) + \ No newline at end of file diff --git a/project/backend/Benutzer.py b/project/backend/Benutzer.py deleted file mode 100644 index b4b8a37..0000000 --- a/project/backend/Benutzer.py +++ /dev/null @@ -1,9 +0,0 @@ -from project.backend.Person import Person -from project.backend.Rezept import Rezept - - -class Benutzer(Person): - def __init__(self, name: str, email: str, passwort: str): - super().__init__(name, email, passwort) - super().setRole("User") - diff --git a/project/backend/Datenbank.py b/project/backend/Database.py similarity index 53% rename from project/backend/Datenbank.py rename to project/backend/Database.py index 2f3dd3b..0d469cf 100644 --- a/project/backend/Datenbank.py +++ b/project/backend/Database.py @@ -5,7 +5,7 @@ DB_PATH = Path("/data/LazyCookDB.sqlite3") -def get_connection() -> sqlite3.Connection: +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 @@ -15,9 +15,9 @@ def get_connection() -> sqlite3.Connection: @contextmanager -def get_db(): +def getDB(): """Context-Manager: öffnet Connection, committed bei Erfolg, rollt bei Fehler zurück.""" - con = get_connection() + con = getConnection() try: yield con con.commit() @@ -28,104 +28,104 @@ def get_db(): con.close() -def init_db(): +def initDB(): """Erstellt alle Tabellen, falls sie noch nicht existieren.""" - with get_db() as con: + with getDB() as con: cur = con.cursor() cur.execute(""" - CREATE TABLE IF NOT EXISTS Konto ( + CREATE TABLE IF NOT EXISTS Account ( id INTEGER PRIMARY KEY AUTOINCREMENT, email VARCHAR(250) NOT NULL UNIQUE, name TEXT NOT NULL, - hashed_password TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + hashedPassword TEXT NOT NULL, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - # Refresh Tokens – pro Konto können mehrere existieren (z.B. verschiedene Geräte) + # Refresh Tokens – pro Account können mehrere existieren (z.B. verschiedene Geräte) cur.execute(""" CREATE TABLE IF NOT EXISTS RefreshToken ( id INTEGER PRIMARY KEY AUTOINCREMENT, - konto_id INTEGER NOT NULL, + AccountID INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (konto_id) REFERENCES Konto (id) ON DELETE CASCADE + expiresAt TIMESTAMP NOT NULL, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (AccountID) REFERENCES Account (id) ON DELETE CASCADE ) """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Zutat ( + CREATE TABLE IF NOT EXISTS Ingridient ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, - mengenArt VARCHAR(30) NOT NULL + amountType VARCHAR(30) NOT NULL ) """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Verfasser ( + CREATE TABLE IF NOT EXISTS Author ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL ) """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Rezept ( + CREATE TABLE IF NOT EXISTS Recipe ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, vid INTEGER NOT NULL, - FOREIGN KEY (vid) REFERENCES Verfasser (id) ON DELETE CASCADE + FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE ) """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Besteht_Aus ( + CREATE TABLE IF NOT EXISTS Exists_from ( zid INTEGER NOT NULL, rid INTEGER NOT NULL, - menge DECIMAL(10,2) NOT NULL, - FOREIGN KEY (zid) REFERENCES Zutat (id) ON DELETE CASCADE, - FOREIGN KEY (rid) REFERENCES Rezept (id) ON DELETE CASCADE, + amount DECIMAL(10,2) NOT NULL, + FOREIGN KEY (zid) REFERENCES Ingridient (id) ON DELETE CASCADE, + FOREIGN KEY (rid) REFERENCES Recipe (id) ON DELETE CASCADE, UNIQUE (zid, rid) ) """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Favoriten ( - konto_id INTEGER NOT NULL, + CREATE TABLE IF NOT EXISTS Favorites ( + AccountID INTEGER NOT NULL, rid INTEGER NOT NULL, - FOREIGN KEY (konto_id) REFERENCES Konto (id) ON DELETE CASCADE, - FOREIGN KEY (rid) REFERENCES Rezept (id) ON DELETE CASCADE, - UNIQUE (konto_id, rid) + FOREIGN KEY (AccountID) REFERENCES Account (id) ON DELETE CASCADE, + FOREIGN KEY (rid) REFERENCES Recipe (id) ON DELETE CASCADE, + UNIQUE (AccountID, rid) ) """) print("✅ Datenbank-Tabellen erfolgreich initialisiert") -# ── Konto-Operationen ────────────────────────────────────────── +# ── Account-Operationen ────────────────────────────────────────── -def create_konto(email: str, name: str, hashed_password: str) -> dict | None: - """Legt ein neues Konto an. Gibt die Konto-Daten zurück oder None bei Duplikat.""" - with get_db() as con: +def createAccount(email: str, name: str, hashedPassword: str) -> dict | None: + """Legt ein neues Account an. Gibt die Account-Daten zurück oder None bei Duplikat.""" + with getDB() as con: cur = con.cursor() - cur.execute("SELECT 1 FROM Konto WHERE email = ? LIMIT 1", (email,)) + cur.execute("SELECT 1 FROM Account WHERE email = ? LIMIT 1", (email,)) if cur.fetchone(): return None cur.execute( - "INSERT INTO Konto (email, name, hashed_password) VALUES (?, ?, ?)", - (email, name, hashed_password), + "INSERT INTO Account (email, name, hashedPassword) VALUES (?, ?, ?)", + (email, name, hashedPassword), ) return {"id": cur.lastrowid, "email": email, "name": name} -def get_konto_by_email(email: str) -> dict | None: - """Gibt Konto-Daten inkl. hashed_password zurück, oder None.""" - con = get_connection() +def getAccountByEmail(email: str) -> dict | None: + """Gibt Account-Daten inkl. hashedPassword zurück, oder None.""" + con = getConnection() try: cur = con.cursor() cur.execute( - "SELECT id, email, name, hashed_password FROM Konto WHERE email = ?", + "SELECT id, email, name, hashedPassword FROM Account WHERE email = ?", (email,), ) row = cur.fetchone() @@ -136,24 +136,24 @@ def get_konto_by_email(email: str) -> dict | None: # ── Refresh-Token-Operationen ────────────────────────────────── -def save_refresh_token(konto_id: int, token: str, expires_at: str) -> None: +def saveRefreshToken(AccountID: int, token: str, expiresAt: str) -> None: """Speichert einen neuen Refresh Token in der Datenbank.""" - with get_db() as con: + with getDB() as con: cur = con.cursor() cur.execute( - "INSERT INTO RefreshToken (konto_id, token, expires_at) VALUES (?, ?, ?)", - (konto_id, token, expires_at), + "INSERT INTO RefreshToken (AccountID, token, expiresAt) VALUES (?, ?, ?)", + (AccountID, token, expiresAt), ) -def get_refresh_token(token: str) -> dict | None: +def getRefreshToken(token: str) -> dict | None: """Gibt den Refresh-Token-Eintrag zurück, oder None.""" - con = get_connection() + con = getConnection() try: cur = con.cursor() cur.execute( - "SELECT rt.id, rt.konto_id, rt.token, rt.expires_at, k.email, k.name " - "FROM RefreshToken rt JOIN Konto k ON rt.konto_id = k.id " + "SELECT rt.id, rt.AccountID, rt.token, rt.expiresAt, k.email, k.name " + "FROM RefreshToken rt JOIN Account k ON rt.AccountID = k.id " "WHERE rt.token = ?", (token,), ) @@ -163,30 +163,44 @@ def get_refresh_token(token: str) -> dict | None: con.close() -def delete_refresh_token(token: str) -> None: +def deleteRefreshToken(token: str) -> None: """Löscht einen einzelnen Refresh Token (Logout).""" - with get_db() as con: + with getDB() as con: cur = con.cursor() cur.execute("DELETE FROM RefreshToken WHERE token = ?", (token,)) -def delete_all_refresh_tokens(konto_id: int) -> None: - """Löscht alle Refresh Tokens eines Kontos (Logout von allen Geräten).""" - with get_db() as con: +def deleteAllRefreshTokens(AccountID: int) -> None: + """Löscht alle Refresh Tokens eines Accounts (Logout von allen Geräten).""" + with getDB() as con: cur = con.cursor() - cur.execute("DELETE FROM RefreshToken WHERE konto_id = ?", (konto_id,)) + cur.execute("DELETE FROM RefreshToken WHERE AccountID = ?", (AccountID,)) -def delete_konto(email: str) -> bool: - """Löscht ein Konto anhand der E-Mail. Refresh Tokens werden via CASCADE mitgelöscht.""" - with get_db() as con: +def deleteAccount(email: str) -> bool: + """Löscht ein Account anhand der E-Mail. Refresh Tokens werden via CASCADE mitgelöscht.""" + with getDB() as con: cur = con.cursor() - cur.execute("DELETE FROM Konto WHERE email = ?", (email,)) + cur.execute("DELETE FROM Account WHERE email = ?", (email,)) return cur.rowcount > 0 - -def cleanup_expired_tokens() -> None: +def updateAccount(konto_id: int, email: str = None, password_hash: str = None) -> None: + """Aktualisiert E-Mail und/oder Passwort eines Accounts.""" + with getDB() as con: + cur = con.cursor() + if email: + cur.execute( + "UPDATE Account SET email = ? WHERE id = ?", + (email, konto_id) + ) + if password_hash: + cur.execute( + "UPDATE Account SET hashedPassword = ? WHERE id = ?", + (password_hash, konto_id) + ) + +def cleanupExpiredTokens() -> None: """Löscht alle abgelaufenen Refresh Tokens.""" - with get_db() as con: + with getDB() as con: cur = con.cursor() - cur.execute("DELETE FROM RefreshToken WHERE expires_at < datetime('now')") \ No newline at end of file + cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") \ No newline at end of file diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py new file mode 100644 index 0000000..912c79b --- /dev/null +++ b/project/backend/EmailService.py @@ -0,0 +1,33 @@ +import smtplib +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +GMAIL_USER = os.environ.get("GMAIL_USER") +GMAIL_PASSWORD = os.environ.get("GMAIL_PASSWORD") + +def sendPasswordChangedEmail(to_email: str, name: str) -> None: + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = "Dein Passwort wurde geändert" + msg["From"] = GMAIL_USER + msg["To"] = to_email + + html = f""" +
+

Hallo {name},

+

dein Passwort bei Lazy Cook wurde soeben geändert.

+

Falls du das nicht warst, kontaktiere uns sofort.

+
+

– Das Lazy Cook Team

+
+ """ + msg.attach(MIMEText(html, "html")) + + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(GMAIL_USER, GMAIL_PASSWORD) + server.sendmail(GMAIL_USER, to_email, msg.as_string()) + + except Exception as e: + print(f"E-Mail Fehler: {e}") + raise \ No newline at end of file diff --git a/project/backend/Ingridient.py b/project/backend/Ingridient.py new file mode 100644 index 0000000..896471e --- /dev/null +++ b/project/backend/Ingridient.py @@ -0,0 +1,19 @@ +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/LazyCookVerwaltung.py b/project/backend/LazyCookAdministration.py similarity index 86% rename from project/backend/LazyCookVerwaltung.py rename to project/backend/LazyCookAdministration.py index 5d18696..8f7df0d 100644 --- a/project/backend/LazyCookVerwaltung.py +++ b/project/backend/LazyCookAdministration.py @@ -3,13 +3,13 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from Datenbank import init_db -from routes import router +from Database import initDB +from Routes import router @asynccontextmanager async def lifespan(app: FastAPI): - init_db() + initDB() yield diff --git a/project/backend/Person.py b/project/backend/Person.py index 4e0cb00..970cb44 100644 --- a/project/backend/Person.py +++ b/project/backend/Person.py @@ -1,11 +1,11 @@ class Person: - def __init__(self, name: str, email: str, passwort: str): + def __init__(self, name: str, email: str, password: str): self.__name = name self.__email = email - self.__passwort = passwort + self.__password = password self.__role = "User" - def speichernInDB(self) -> bool: + def saveInDB(self) -> bool: return True def getName(self) -> str: @@ -20,11 +20,11 @@ def getEmail(self) -> str: def setEmail(self, email: str): self.__email = email - def getPasswort(self) -> str: - return self.__passwort + def getPassword(self) -> str: + return self.__password - def setPasswort(self, passwort: str): - self.__passwort = passwort + def setPassword(self, password: str): + self.__password = password def getRole(self) -> str: return self.__role diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py new file mode 100644 index 0000000..6578c7b --- /dev/null +++ b/project/backend/Recipe.py @@ -0,0 +1,54 @@ +from Database import Database +from Ingridient import Ingridient + + +class Recipe: + def __init__(self, name: str, ingridients: list[Ingridient], description: str): + self.__name = name + self.__Ingridienten = ingridients + self.__description = description + self.__original = "" + self.__duration = "" + self.__rating = 0 + self.__countPersons = 1 + self.__database = Database() + + def saveInDB(self) -> bool: + return True + + def getName(self) -> str: + return self.name + + def setName(self, name: str): + self.name = name + + def getOriginal(self) -> str: + return self.original + + def setOriginal(self, original: str): + self.original = original + + def getDescription(self) -> str: + return self.description + + def setDescription(self, description: str): + self.description = description + + def getRating(self) -> int: + return self.rating + + def setRating(self, rating: int): + self.rating = rating + + def getDuration(self) -> str: + return self.duration + + def setDuration(self, duration: str): + self.duration = duration + + def getIngridient(self) -> list[Ingridient]: + return self.ingridients + + def setIngridient(self, ingridients: list[Ingridient]): + self.ingridients = ingridients + diff --git a/project/backend/Rezept.py b/project/backend/Rezept.py deleted file mode 100644 index a0c09b0..0000000 --- a/project/backend/Rezept.py +++ /dev/null @@ -1,54 +0,0 @@ -from project.backend.Datenbank import Datenbank -from project.backend.Zutat import Zutat - - -class Rezept: - def __init__(self, name: str, zutaten: list[Zutat], zubereitung: str): - self.__name = name - self.__zutaten = zutaten - self.__zubereitung = zubereitung - self.__original = "" - self.__dauer = "" - self.__bewertung = 0 - self.__anzPersonen = 1 - self.__datenbank = Datenbank() - - def speichernInDB(self) -> bool: - return True - - def getName(self) -> str: - return self.name - - def setName(self, name: str): - self.name = name - - def getOriginal(self) -> str: - return self.original - - def setOriginal(self, original: str): - self.original = original - - def getZubereitung(self) -> str: - return self.zubereitung - - def setZubereitung(self, zubereitung: str): - self.zubereitung = zubereitung - - def getBewertung(self) -> int: - return self.bewertung - - def setBewertung(self, bewertung: int): - self.bewertung = bewertung - - def getDauer(self) -> str: - return self.dauer - - def setDauer(self, dauer: str): - self.dauer = dauer - - def getZutaten(self) -> list[Zutat]: - return self.zutaten - - def setZutaten(self, zutaten: list[Zutat]): - self.zutaten = zutaten - diff --git a/project/backend/User.py b/project/backend/User.py new file mode 100644 index 0000000..622cf03 --- /dev/null +++ b/project/backend/User.py @@ -0,0 +1,9 @@ +from Person import Person +from Recipe import Recipe + + +class User(Person): + def __init__(self, name: str, email: str, password: str): + super().__init__(name, email, password) + super().setRole("User") + diff --git a/project/backend/Zutat.py b/project/backend/Zutat.py deleted file mode 100644 index 041d395..0000000 --- a/project/backend/Zutat.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import List, Dict, Any - -class Zutat: - def __init__(self, name: str, menge: float): - self.__name = name - self.__menge = menge - - def getName(self) -> str: - return self.__name - - def setName(self, name: str): - self.__name = name - - def getMenge(self) -> float: - return self.__menge - - def setMenge(self, menge: float): - self.__menge = menge - diff --git a/project/backend/auth.py b/project/backend/auth.py index d1529f1..9cd5c9d 100644 --- a/project/backend/auth.py +++ b/project/backend/auth.py @@ -13,13 +13,15 @@ import bcrypt from pydantic import BaseModel -from Datenbank import get_konto_by_email, save_refresh_token, get_refresh_token +from Models import Token, User + +from Database import getAccountByEmail, saveRefreshToken, getRefreshToken # ── Konfiguration ────────────────────────────────────────────── SECRET_KEY = "dein-geheimer-schlüssel-hier-ändern" # In Produktion: aus Umgebungsvariable laden! ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 10 # kurze Laufzeit für Access Token -REFRESH_TOKEN_EXPIRE_DAYS = 7 # lange Laufzeit für Refresh Token +refreshToken_EXPIRE_DAYS = 7 # lange Laufzeit für Refresh Token # ── Modelle ──────────────────────────────────────────────────── class Token(BaseModel): @@ -43,20 +45,20 @@ class LogoutRequest(BaseModel): refresh_token: str # ── Passwort-Hashing ────────────────────────────────────────── -def hash_password(password: str) -> str: +def hashPassword(password: str) -> str: return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() -def verify_password(plain: str, hashed: str) -> bool: +def verifyPassword(plain: str, hashed: str) -> bool: return bcrypt.checkpw(plain.encode(), hashed.encode()) # ── Access Token (JWT) ───────────────────────────────────────── -def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: - to_encode = data.copy() +def createAccessToken(data: dict, expires_delta: timedelta | None = None) -> str: + toEncode = data.copy() expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) - to_encode.update({"exp": expire}) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + toEncode.update({"exp": expire}) + return jwt.encode(toEncode, SECRET_KEY, algorithm=ALGORITHM) -def decode_token(token: str) -> str | None: +def decodeToken(token: str) -> str | None: """Dekodiert ein JWT und gibt die E-Mail (sub) zurück, oder None.""" try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) @@ -65,44 +67,44 @@ def decode_token(token: str) -> str | None: return None # ── Refresh Token ────────────────────────────────────────────── -def create_refresh_token(konto_id: int) -> str: +def createRefreshToken(konto_id: int) -> str: """Erstellt einen kryptografisch sicheren Refresh Token und speichert ihn in der DB.""" token = secrets.token_urlsafe(64) - expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) - save_refresh_token(konto_id, token, expires_at.isoformat()) + expiresAt = datetime.now(timezone.utc) + timedelta(days=refreshToken_EXPIRE_DAYS) + saveRefreshToken(konto_id, token, expiresAt.isoformat()) return token -def validate_refresh_token(token: str) -> dict | None: +def validateRefreshToken(token: str) -> dict | None: """Prüft ob ein Refresh Token gültig und nicht abgelaufen ist. Gibt Konto-Daten zurück.""" - entry = get_refresh_token(token) + entry = getRefreshToken(token) if entry is None: return None - expires_at = datetime.fromisoformat(entry["expires_at"]) - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at < datetime.now(timezone.utc): + 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 # ── Token-Paar erstellen ────────────────────────────────────── -def create_token_pair(konto: dict) -> Token: +def createTokenPair(konto: dict) -> Token: """Erstellt ein Access + Refresh Token-Paar für ein Konto.""" - access_token = create_access_token( + access_token = createAccessToken( data={"sub": konto["email"]}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), ) - refresh_token = create_refresh_token(konto["id"]) - return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") + refreshToken = createRefreshToken(konto["id"]) + return Token(access_token=access_token, refresh_token=refreshToken, token_type="bearer") # ── Validierung ──────────────────────────────────────────────── EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") -def validate_email(email: str) -> str | None: +def validateEmail(email: str) -> str | None: if not EMAIL_RE.match(email): return "Ungültige E-Mail-Adresse." return None -def validate_password(pw: str) -> str | None: +def validatePassword(pw: str) -> str | None: if len(pw) < 8: return "Passwort muss mindestens 8 Zeichen lang sein." if not re.search(r"[A-Z]", pw): @@ -118,17 +120,17 @@ def validate_password(pw: str) -> str | None: # ── Auth-Dependency ──────────────────────────────────────────── oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User: +async def getCurrentUser(token: Annotated[str, Depends(oauth2_scheme)]) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültige Anmeldedaten", headers={"WWW-Authenticate": "Bearer"}, ) - email = decode_token(token) + email = decodeToken(token) if email is None: raise credentials_exception - konto = get_konto_by_email(email) + konto = getAccountByEmail(email) if konto is None: raise credentials_exception diff --git a/project/backend/models.py b/project/backend/models.py index b37f549..a4fafcc 100644 --- a/project/backend/models.py +++ b/project/backend/models.py @@ -12,4 +12,10 @@ class User(BaseModel): class UserCreate(BaseModel): email: str name: str - password: str \ No newline at end of file + password: str + +class RefreshRequest(BaseModel): + refresh_token: str + +class LogoutRequest(BaseModel): + refresh_token: str \ No newline at end of file diff --git a/project/backend/routes.py b/project/backend/routes.py index 1a12df5..fe42bef 100644 --- a/project/backend/routes.py +++ b/project/backend/routes.py @@ -1,31 +1,36 @@ from typing import Annotated - from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm +from EmailService import sendPasswordChangedEmail +from pydantic import BaseModel as _BaseModel + -from auth import ( +from Auth import ( Token, User, UserCreate, RefreshRequest, LogoutRequest, - create_token_pair, - get_current_user, - hash_password, - validate_email, - validate_password, - validate_refresh_token, - verify_password, - create_access_token, + createTokenPair, + getCurrentUser, + hashPassword, + validateEmail, + validatePassword, + validateRefreshToken, + verifyPassword, + createAccessToken, ) -from Datenbank import ( - create_konto, - get_konto_by_email, - delete_refresh_token, - delete_all_refresh_tokens, - delete_konto, +from Database import ( + createAccount, + getAccountByEmail, + deleteRefreshToken, + deleteAllRefreshTokens, + deleteAccount, + updateAccount, ) +from Models import User, Token, UserCreate + router = APIRouter() @@ -35,39 +40,39 @@ async def register(user: UserCreate): if not user.name.strip(): raise HTTPException(status_code=400, detail="Name darf nicht leer sein.") - email_error = validate_email(user.email) - if email_error: - raise HTTPException(status_code=400, detail=email_error) - pw_error = validate_password(user.password) - if pw_error: - raise HTTPException(status_code=400, detail=pw_error) - - konto = create_konto( + emailError = validateEmail(user.email) + if emailError: + raise HTTPException(status_code=400, detail=emailError) + pwError = validatePassword(user.password) + if pwError: + raise HTTPException(status_code=400, detail=pwError) + + Account = createAccount( email=user.email, name=user.name, - hashed_password=hash_password(user.password), + hashedPassword=hashPassword(user.password), ) - if konto is None: + if Account is None: raise HTTPException(status_code=400, detail="E-Mail bereits registriert") - return User(email=konto["email"], name=konto["name"]) + return User(email=Account["email"], name=Account["name"]) @router.post("/auth/login", response_model=Token) -async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): - konto = get_konto_by_email(form_data.username) - if not konto or not verify_password(form_data.password, konto["hashed_password"]): +async def login(formData: Annotated[OAuth2PasswordRequestForm, Depends()]): + Account = getAccountByEmail(formData.username) + if not Account or not verifyPassword(formData.password, Account["hashedPassword"]): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="E-Mail oder Passwort falsch", ) - return create_token_pair(konto) + return createTokenPair(Account) @router.post("/auth/refresh", response_model=Token) async def refresh(body: RefreshRequest): """Tauscht einen gültigen Refresh Token gegen ein neues Token-Paar.""" - entry = validate_refresh_token(body.refresh_token) + entry = validateRefreshToken(body.refresh_token) if entry is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -75,29 +80,71 @@ async def refresh(body: RefreshRequest): ) # Alten Refresh Token löschen (Rotation – jeder Token ist nur einmal verwendbar) - delete_refresh_token(body.refresh_token) + deleteRefreshToken(body.refresh_token) - # Konto-Daten für neues Token-Paar zusammenstellen - konto = {"id": entry["konto_id"], "email": entry["email"]} - return create_token_pair(konto) + # Account-Daten für neues Token-Paar zusammenstellen + Account = {"id": entry["konto_id"], "email": entry["email"]} + return createTokenPair(Account) @router.post("/auth/logout") async def logout(body: LogoutRequest): """Löscht den Refresh Token serverseitig → Token wird ungültig.""" - delete_refresh_token(body.refresh_token) + deleteRefreshToken(body.refresh_token) return {"detail": "Erfolgreich abgemeldet"} # ── Geschützte Endpunkte ─────────────────────────────────────── @router.get("/users/me", response_model=User) -async def read_current_user(current_user: Annotated[User, Depends(get_current_user)]): +async def readCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser)]): """Nur mit gültigem Access Token erreichbar.""" - return current_user + return currentUser @router.delete("/users/me", status_code=status.HTTP_204_NO_CONTENT) -async def delete_current_user(current_user: Annotated[User, Depends(get_current_user)]): - """Löscht das eigene Konto inkl. aller Refresh Tokens (CASCADE).""" - delete_konto(current_user.email) \ No newline at end of file +async def deleteCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser)]): + """Löscht das eigene Account inkl. aller Refresh Tokens (CASCADE).""" + deleteAccount(currentUser.email) + + # ── 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, + current_user: Annotated[User, Depends(getCurrentUser)] +): + Account = getAccountByEmail(current_user.email) + + # E-Mail ändern + if data.email: + fehler = validateEmail(data.email) + if fehler: + raise HTTPException(status_code=400, detail=fehler) + updateAccount(Account["id"], email=data.email) + + # Passwort ändern + if data.currentPassword and data.newPassword: + if not verifyPassword(data.currentPassword, Account["hashedPassword"]): + raise HTTPException(status_code=401, detail="Falsches Passwort") + + fehler = validatePassword(data.newPassword) + if fehler: + raise HTTPException(status_code=400, detail=fehler) + + neuer_hash = hashPassword(data.newPassword) + updateAccount(Account["id"], password_hash=neuer_hash) + + # NEU: try/catch um echten Fehler zu sehen + empfaenger_email = data.email if data.email else Account["email"] + try: + sendPasswordChangedEmail(empfaenger_email, Account["name"]) + except Exception as e: + print(f"E-Mail konnte nicht gesendet werden: {e}") + + return {"success": True} \ No newline at end of file diff --git a/project/compose.yaml b/project/compose.yaml index 3861840..6bffc1c 100644 --- a/project/compose.yaml +++ b/project/compose.yaml @@ -15,6 +15,8 @@ services: working_dir: /app environment: - PYTHONUNBUFFERED=1 + - GMAIL_USER=${GMAIL_USER} + - GMAIL_PASSWORD=${GMAIL_PASSWORD} volumes: - ./data:/data ports: diff --git a/project/data/LazyCookDB.sqlite3 b/project/data/LazyCookDB.sqlite3 new file mode 100644 index 0000000..3aa5d49 Binary files /dev/null and b/project/data/LazyCookDB.sqlite3 differ diff --git a/project/frontend/app/components/modal.tsx b/project/frontend/app/components/modal.tsx index b7fa542..dded141 100644 --- a/project/frontend/app/components/modal.tsx +++ b/project/frontend/app/components/modal.tsx @@ -3,7 +3,7 @@ import { useEffect, type ReactNode } from "react"; import styles from "../homepage/page.module.css"; -export default function Modal({ open, onCloseAction, children }: { open: boolean; onCloseAction: () => void; children: ReactNode }) { +export default function Modal({ open, onCloseAction, children}: { open: boolean; onCloseAction: () => void; children: ReactNode;}) { useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => e.key === "Escape" && onCloseAction(); diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index bd8c06e..5c04f67 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -11,7 +11,7 @@ import RegisterForm from "@/app/homepage/signup"; export default function Homepage() { - const [modal, setModal] = useState<"login" | "register" | null>(null); + const [modal, setModal] = useState<"login" | "register" | null>(null); const close = useCallback(() => setModal(null), []); diff --git a/project/frontend/app/homepage/page.module.css b/project/frontend/app/homepage/page.module.css index 1ecbc91..f148a16 100644 --- a/project/frontend/app/homepage/page.module.css +++ b/project/frontend/app/homepage/page.module.css @@ -25,8 +25,8 @@ background: #fff; border-radius: 12px; padding: 2rem 2rem 1.75rem; - width: 100%; - max-width: 420px; + width: auto; + max-width: 520px; position: relative; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12); animation: slideUp 0.2s ease-out; diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index 9ddec4b..46e9882 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -13,8 +13,58 @@ export default function Profile() { const router = useRouter(); const [showConfirm, setShowConfirm] = 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."); + } + } + + 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."); + } + } + async function handleAccountDeletion() { const res = await fetchWithAuth(`${API_URL}/users/me`, { method: "DELETE" }); if (!res.ok) throw new Error("Konto löschen fehlgeschlagen"); @@ -31,11 +81,9 @@ export default function Profile() { Lazy Cook - - + {emailMsg &&

{emailMsg}

} + + + {/* Passwort ändern */} +
+

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}

} +
+ {/* Bestätigungs-Modal */} @@ -86,4 +183,4 @@ export default function Profile() { )} ); -} \ No newline at end of file +} diff --git a/project/frontend/app/recipeFinder/page.tsx b/project/frontend/app/recipeFinder/page.tsx index 0300efb..af0ec13 100644 --- a/project/frontend/app/recipeFinder/page.tsx +++ b/project/frontend/app/recipeFinder/page.tsx @@ -2,10 +2,20 @@ import {useEffect, useRef, useState} from "react"; import { useRouter } from "next/navigation"; -import { useAuth } from "@/lib/auth"; -import {ChefHat, LogOut} from "lucide-react"; -import { User, UserCircle } from "lucide-react"; +import {fetchWithAuth, useAuth} from "@/lib/auth"; +import {ChefHat, LogOut, X, User, UserCircle, Search, Plus} from "lucide-react"; import "./style.css" +import {Button} from "@/app/components/ui/button"; +import Modal from "@/app/components/modal"; +import AddIngredientsPopup from "@/app/recipeFinder/popup"; + +const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; + +interface IngredientInput { + name: string; + amount: number; + unit: string; +} export default function RecipeFinder() { const { user, loading, logout } = useAuth(); @@ -14,7 +24,28 @@ export default function RecipeFinder() { const [open, setOpen] = useState(false); const menuRef = useRef(null); - // Nicht eingeloggt → zurück zur Startseite + const [ingredients, setIngredients] = useState(() => { + try { + const saved = localStorage.getItem("ingredients"); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + const [servings, setServings] = useState(1); + + const [searching, setSearching] = useState(false); + const [results, setResults] = useState(null); + + const [editingIngredient, setEditingIngredient] = useState(null); + const [editAmount, setEditAmount] = useState(""); + const [editUnit, setEditUnit] = useState("Stück"); + + const [searchError, setSearchError] = useState(""); + + const [modalOpen, setModalOpen] = useState(false); + useEffect(() => { if (!loading && !user) { router.replace("/"); @@ -31,6 +62,10 @@ export default function RecipeFinder() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + useEffect(() => { + localStorage.setItem("ingredients", JSON.stringify(ingredients)); + }, [ingredients]); + if (loading) { return (
@@ -41,12 +76,61 @@ export default function RecipeFinder() { if (!user) return null; + const handleLogout = () => { + logout(); + router.replace("/"); + } const handleProfil = () => { setOpen(false); console.log("Profil ansehen"); router.push("/profile"); }; + const handleAdd = (ingredient: IngredientInput) => { + setIngredients(prev => [...prev, ingredient]); + }; + + const handleRemoveIngredient = (name: string) => { + setIngredients(prev => prev.filter(z => z.name !== name)); + } + + const handleEditSave = (name: string) => { + const amountNum = parseFloat(editAmount); + if (!editAmount || isNaN(amountNum) || amountNum <= 0) return; + setIngredients(prev => prev.map(i => + i.name === name ? { ...i, amount: amountNum, unit: editUnit } : i + )); + setEditingIngredient(null); + }; + + const handleEditStart = (i: IngredientInput) => { + setEditingIngredient(i.name); + setEditAmount(String(i.amount)); + setEditUnit(i.unit); + }; + + const handleSearch = async () => { + if (ingredients.length === 0) { + setSearchError("Bitte mindestens eine Zutat hinzufügen."); + return; + } + setSearchError(""); + setSearching(true); + try { + const res = await fetchWithAuth('/recipes/search', { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({zutaten: ingredients, servings}), + }); + const data = await res.json(); + setResults(data.rezepte ?? []); + } catch { + setSearchError("Suche fehlgeschlagen."); + } finally { + setSearching(false); + } + }; + return (
{/* Navbar */} @@ -58,9 +142,7 @@ export default function RecipeFinder() {
@@ -85,7 +167,7 @@ export default function RecipeFinder() { Profil ansehen
- {/* Dashboard Content */} -
-

Dashboard

-

Willkommen zurück, {user.name}!

+
+ {/* Sidebar */} +
+ + {/* Personenanzahl */} +
+

Personen

+
+ +
+
{servings}
+
Personen
+
+ +
+
+ + {/* Suche starten */} +
+ {searchError &&

{searchError}

} + +
+ + + setModalOpen(false)}> + + +
); } \ No newline at end of file diff --git a/project/frontend/app/recipeFinder/popup.tsx b/project/frontend/app/recipeFinder/popup.tsx new file mode 100644 index 0000000..ec2f81b --- /dev/null +++ b/project/frontend/app/recipeFinder/popup.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import "./style.css"; + +const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; + +export interface IngredientInput { + name: string; + amount: number; + unit: string; +} + +interface Props { + ingredients: IngredientInput[]; + onAdd: (ingredient: IngredientInput) => void; + servings: number; + onServingsChange: (s: number) => void; +} + +export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { + const [ingredientName, setIngredientName] = useState(""); + const [ingredientAmount, setIngredientAmount] = useState(""); + const [ingredientUnit, setIngredientUnit] = useState("Stück"); + const [inputError, setInputError] = useState(""); + + const handleAddIngredient = () => { + const trimmedName = ingredientName.trim(); + const amountNum = parseFloat(ingredientAmount); + + if (!trimmedName) { setInputError("Bitte eine Zutat eingeben."); return; } + if (!ingredientAmount || isNaN(amountNum) || amountNum <= 0) { setInputError("Bitte eine gültige Menge eingeben."); return; } + if (ingredients.some(z => z.name.toLowerCase() === trimmedName.toLowerCase())) { + setInputError("Diese Zutat wurde bereits hinzugefügt."); + return; + } + + onAdd({ name: trimmedName, amount: amountNum, unit: ingredientUnit }); + setIngredientName(""); + setIngredientAmount(""); + setIngredientUnit("Stück"); + setInputError(""); + }; + + return ( +
+

Zutaten Hinzufügen

+ +
+ setIngredientName(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleAddIngredient()} + placeholder="z.B. Tomaten" + className="popup__input" + /> + setIngredientAmount(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleAddIngredient()} + placeholder="Menge" + min={0} + className="popup__input popup__input--amount" + /> + +
+ + {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 888974b..fdc8996 100644 --- a/project/frontend/app/recipeFinder/style.css +++ b/project/frontend/app/recipeFinder/style.css @@ -71,4 +71,406 @@ opacity: 1; transform: translateY(0); } +} + +/* ── AddIngredientsPopup ───────────────────────────────────── */ +.popup { + display: flex; + flex-direction: column; + gap: 20px; +} + +.popup__title { + font-size: 20px; + font-weight: 600; + color: #111; + font-family: system-ui; + text-align: center; + margin: 0; +} + +.popup__fields { + display: flex; + gap: 10px; +} + +.popup__input { + flex: 1; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid #ddd; + font-size: 14px; + font-family: system-ui; + outline: none; + transition: border-color 0.15s; + background: #f3f3f5; + box-sizing: border-box; +} + +.popup__input:focus { + border-color: #030213; + background: #fff; +} + +.popup__input--amount { + flex: 0 0 90px; + width: 90px; + min-width: 0; +} + +.popup__select { + padding: 10px 12px; + border-radius: 6px; + border: 1px solid #ddd; + font-size: 14px; + font-family: system-ui; + outline: none; + background: #f3f3f5; + cursor: pointer; + transition: border-color 0.15s; +} + +.popup__select:focus { + border-color: #030213; + background: #fff; +} + +.popup__error { + font-size: 13px; + color: #b91c1c; + font-family: system-ui; + text-align: center; + margin: 0; +} + +.popup__btn { + display: block; + margin: 0 auto; + padding: 10px 32px; + background: #030213; + color: #fff; + border: none; + border-radius: 6px; + font-size: 14px; + font-family: system-ui; + cursor: pointer; + transition: background-color 0.15s; +} + +.popup__btn:hover { + background: rgba(3, 2, 19, 0.85); +} + +/* ── RecipeFinder Layout ───────────────────────────────────── */ +.finder-layout { + display: flex; + height: calc(100vh - 57px); +} + +.finder-sidebar { + width: 300px; + flex-shrink: 0; + border-right: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + background: #fff; +} + +.finder-sidebar__section { + padding: 20px; + border-bottom: 1px solid #e5e7eb; +} + +.finder-sidebar__title { + font-size: 13px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 12px 0; + font-family: system-ui; +} + +.finder-sidebar__add-btn { + flex: 1; + padding: 10px; + background: #030213; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-family: system-ui; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.15s; +} + +.finder-sidebar__add-btn:hover { + background: rgba(3, 2, 19, 0.85); +} + +.finder-sidebar__ingredient-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +.finder-sidebar__ingredient { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: #f3f3f5; + border-radius: 8px; + font-size: 13px; + font-family: system-ui; +} + +.finder-sidebar__ingredient-name { + font-weight: 500; + color: #111; +} + +.finder-sidebar__ingredient-amount { + color: #6b7280; + font-size: 12px; +} + +.finder-sidebar__ingredient-remove { + background: none; + border: none; + cursor: pointer; + color: #9ca3af; + display: flex; + align-items: center; + padding: 0; + transition: color 0.1s; +} + +.finder-sidebar__ingredient-remove:hover { + color: #ef4444; +} + +.finder-sidebar__empty { + font-size: 13px; + color: #9ca3af; + font-family: system-ui; + margin: 8px 0 0 0; +} + +/* Personenanzahl */ +.finder-sidebar__persons { + display: flex; + align-items: center; + gap: 16px; +} + +.finder-sidebar__persons-btn { + width: 32px; + height: 32px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s; +} + +.finder-sidebar__persons-btn:hover { + background: #f3f4f6; +} + +.finder-sidebar__persons-count { + font-size: 22px; + font-weight: 600; + font-family: system-ui; + color: #111; + min-width: 24px; + text-align: center; +} + +.finder-sidebar__persons-label { + font-size: 12px; + color: #6b7280; + font-family: system-ui; +} + +/* Suchen Button */ +.finder-sidebar__search-btn { + width: 100%; + padding: 12px; + background: #030213; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + font-family: system-ui; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background 0.15s; +} + +.finder-sidebar__search-btn:hover:not(:disabled) { + background: rgba(3, 2, 19, 0.85); +} + +.finder-sidebar__search-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Rezepte Bereich */ +.finder-content { + flex: 1; + overflow-y: auto; + padding: 24px; + background: #fafafa; +} + +.finder-content__empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #9ca3af; + font-size: 14px; + font-family: system-ui; +} + +.finder-content__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} + +.finder-content__card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + font-family: system-ui; +} + +.finder-content__card-name { + font-size: 15px; + font-weight: 600; + color: #111; + margin: 0; +} + +.finder-sidebar__clear-btn { + flex-shrink: 0; + padding: 10px; + background: #fff; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.finder-sidebar__clear-btn:hover { + background: #fef2f2; + color: #ef4444; + border-color: #ef4444; +} +.finder-sidebar__ingredient-right { + display: flex; + align-items: center; + gap: 8px; +} + +.finder-sidebar__ingredient-amount { + color: #6b7280; + font-size: 12px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: background 0.1s; +} + +.finder-sidebar__ingredient-amount:hover { + background: #e5e7eb; + color: #111; +} + +.finder-sidebar__ingredient-edit { + display: flex; + align-items: center; + gap: 4px; +} + +.finder-sidebar__edit-input { + width: 52px; + padding: 3px 6px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 12px; + font-family: system-ui; + outline: none; +} + +.finder-sidebar__edit-input:focus { + border-color: #030213; +} + +.finder-sidebar__edit-select { + padding: 3px 4px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 12px; + font-family: system-ui; + background: #fff; + outline: none; +} + +.finder-sidebar__edit-select:focus { + border-color: #030213; +} + +.finder-sidebar__edit-save { + background: #030213; + color: #fff; + border: none; + border-radius: 6px; + padding: 3px 7px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.finder-sidebar__edit-save:hover { + background: rgba(3, 2, 19, 0.8); +} + +.finder-sidebar__edit-cancel { + background: none; + color: #9ca3af; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 3px 7px; + font-size: 12px; + cursor: pointer; + transition: color 0.1s; +} + +.finder-sidebar__edit-cancel:hover { + color: #ef4444; + border-color: #ef4444; } \ No newline at end of file diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 03361db..cae660e 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -1,6 +1,3 @@ -// lib/auth.ts – Auth-Kontext mit Access + Refresh Token -// ────────────────────────────────────────────────────── - "use client"; import { @@ -13,7 +10,7 @@ import { type ReactNode, } from "react"; -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; +const API_URL = "http://localhost:3000"; // ── Typen ───────────────────────────────────────────────────── export interface User { @@ -49,6 +46,7 @@ function saveTokens(accessToken: string, refreshToken: string) { function clearTokens() { sessionStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); + localStorage.removeItem("ingredients"); } // ── API-Aufrufe ─────────────────────────────────────────────── @@ -125,7 +123,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise=0.10.0" } @@ -7631,6 +7642,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7659,13 +7671,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7798,7 +7812,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8504,6 +8519,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -8666,6 +8682,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9036,6 +9053,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }