From 38c9474a7e10444a4fbc2333899d007715964112 Mon Sep 17 00:00:00 2001
From: Samuel Goebel
Date: Mon, 4 May 2026 15:11:18 +0200
Subject: [PATCH 01/37] Added Algorithm, which returns a sorted list of recipes
of an matching Ingridients List
---
project/backend/Database.py | 53 +++++++++++++++++++++++++++++++++-
project/backend/Recipe.py | 25 ++++++++++------
project/backend/RecipeSucuk.py | 36 +++++++++++++++++++++++
3 files changed, 104 insertions(+), 10 deletions(-)
create mode 100644 project/backend/RecipeSucuk.py
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 0d469cf..379d999 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -74,6 +74,7 @@ def initDB():
CREATE TABLE IF NOT EXISTS Recipe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
+ description nvarchar(MAX),
vid INTEGER NOT NULL,
FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE
)
@@ -203,4 +204,54 @@ def cleanupExpiredTokens() -> None:
"""Löscht alle abgelaufenen Refresh Tokens."""
with getDB() as con:
cur = con.cursor()
- cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")
\ No newline at end of file
+ cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")
+
+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 getAllIngridientsForRecipe(id: int) -> list[dict]:
+ """Gibt eine Liste aller Zutaten zurück."""
+ with getDB() as con:
+ cur = con.cursor()
+ cur.execute("""
+ SELECT id, name, Exists_from.amount
+ FROM Ingridient
+ Inner Join Exists_from id = zid
+ Inner Join Recipe rid = id
+ Where id = ?
+ """, (id,))
+ 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 Ingridient on rid=id,
+ Where id = ?
+ """, (name,))
+ rows = cur.fetchall()
+ return [dict(row) for row in rows]
+
diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py
index 6578c7b..788664c 100644
--- a/project/backend/Recipe.py
+++ b/project/backend/Recipe.py
@@ -5,12 +5,13 @@
class Recipe:
def __init__(self, name: str, ingridients: list[Ingridient], description: str):
self.__name = name
- self.__Ingridienten = ingridients
+ self.__Ingridients = ingridients
self.__description = description
self.__original = ""
self.__duration = ""
- self.__rating = 0
+ self.__rating = 0.0
self.__countPersons = 1
+ self.__matching = 0
self.__database = Database()
def saveInDB(self) -> bool:
@@ -34,11 +35,17 @@ def getDescription(self) -> str:
def setDescription(self, description: str):
self.description = description
- def getRating(self) -> int:
- return self.rating
+ def getRating(self) -> float:
+ return self.__rating
- def setRating(self, rating: int):
- self.rating = rating
+ def setRating(self, rating: float):
+ self.__rating = rating
+
+ def getMatching(self) -> int:
+ return self.__matching
+
+ def incrementMatching(self):
+ self.__matching+=1
def getDuration(self) -> str:
return self.duration
@@ -46,9 +53,9 @@ def getDuration(self) -> str:
def setDuration(self, duration: str):
self.duration = duration
- def getIngridient(self) -> list[Ingridient]:
- return self.ingridients
+ def getIngridients(self) -> list[Ingridient]:
+ return self.__ingridients
def setIngridient(self, ingridients: list[Ingridient]):
- self.ingridients = ingridients
+ self.__Ingridients = ingridients
diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py
new file mode 100644
index 0000000..a43033e
--- /dev/null
+++ b/project/backend/RecipeSucuk.py
@@ -0,0 +1,36 @@
+import Database
+from Ingridient import Ingridient
+from Recipe import Recipe
+from Database import getAllRecipes, getAllIngridientsForRecipe
+
+def findRecipes(ingriedents: list[Ingridient])-> list[Recipe]:
+ recipes = __initRecipes()
+
+ for ingridient in ingriedents:
+ recipes = __filterRecipes(recipes)
+
+ recipes.sort(key=lambda x: x.getRating(), reverse=True)
+ return recipes
+
+def __filterRecipes(recipes: list[Recipe], ingridient: Ingridient)-> list[Recipe]:
+ for recipe in recipes:
+ for recipeIngridient in recipe.getIngridients():
+ if recipeIngridient.getName() == ingridient.getName():
+ recipe.incrementMatching()
+ recipe.setRating(recipe.getMatching/ len(recipe.getIngridients))
+ return recipes
+
+def __initRecipes()-> list[Recipe]:
+ recipesRaw = getAllRecipes
+ recipes = []
+ for recipeRaw in recipesRaw:
+ recipe = Recipe(recipeRaw["name"], __formatIngridients(recipeRaw["id"]), recipeRaw["description"])
+ recipes.append(recipe)
+ return recipes
+
+def __formatIngridients(id: int)-> list[Ingridient]:
+ ingridientsRaw = getAllIngridientsForRecipe(id)
+ ingridients = []
+ for ingridientRaw in ingridientsRaw:
+ ingridients.append(Ingridient(ingridientRaw["name"],ingridientRaw["amount"]))
+ return ingridients
\ No newline at end of file
From ef434217862e589abe4f410977f1d5e66526d8be Mon Sep 17 00:00:00 2001
From: Samuel Goebel
Date: Mon, 4 May 2026 15:33:13 +0200
Subject: [PATCH 02/37] Added Filtering
---
project/backend/RecipeSucuk.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py
index a43033e..25d1e0c 100644
--- a/project/backend/RecipeSucuk.py
+++ b/project/backend/RecipeSucuk.py
@@ -10,6 +10,12 @@ def findRecipes(ingriedents: list[Ingridient])-> list[Recipe]:
recipes = __filterRecipes(recipes)
recipes.sort(key=lambda x: x.getRating(), reverse=True)
+
+ for recipe in recipes[::-1]:
+ if recipe.getMatching < 3:
+ recipes.remove(recipe)
+ else:
+ break
return recipes
def __filterRecipes(recipes: list[Recipe], ingridient: Ingridient)-> list[Recipe]:
From e701e833b318f40542b18c947822c353d65b88a9 Mon Sep 17 00:00:00 2001
From: nicla
Date: Mon, 4 May 2026 15:44:37 +0200
Subject: [PATCH 03/37] =?UTF-8?q?Tests=20f=C3=BCr=20Database,=20passwort?=
=?UTF-8?q?=20=C3=BCberpr=C3=BCfung=20und=20email=20=C3=BCber=C3=BCrufung?=
=?UTF-8?q?=20wurden=20geschrieben.=20Die=20dazugeh=C3=B6rigen=20Abh=C3=A4?=
=?UTF-8?q?ngigkeiten=20in=20requirements.txt=20angepasst.=20Sie=20werden?=
=?UTF-8?q?=20in=20test=5Fmain.py=20aufgerufen.=20Namens=20faktorisierung?=
=?UTF-8?q?=20ist=20schwer=20bei=20den=20Klassennamen=20da=20bei=20tests?=
=?UTF-8?q?=20der=20pycharm=20Debugger=20eher=20auf=20klassen=20mit=20test?=
=?UTF-8?q?=5F=20vorne=20guckt.=20Deswegen=20leider=20test=5F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
project/backend/Database.py | 7 ++-
project/backend/requirements.txt | 3 +-
project/backend/tests/__init__.py | 0
project/backend/tests/test_database.py | 72 +++++++++++++++++++++++++
project/backend/tests/test_email.py | 21 ++++++++
project/backend/tests/test_main.py | 25 +++++----
project/backend/tests/test_password.py | 41 ++++++++++++++
project/data/LazyCookDB.sqlite3 | Bin 65536 -> 65536 bytes
8 files changed, 157 insertions(+), 12 deletions(-)
create mode 100644 project/backend/tests/__init__.py
create mode 100644 project/backend/tests/test_database.py
create mode 100644 project/backend/tests/test_email.py
create mode 100644 project/backend/tests/test_password.py
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 0d469cf..a2ab902 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -1,8 +1,13 @@
import sqlite3
from contextlib import contextmanager
from pathlib import Path
+import os
-DB_PATH = Path("/data/LazyCookDB.sqlite3")
+# Verwende die Datenbank aus dem data-Ordner
+DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3"
+
+# Stelle sicher, dass das Verzeichnis existiert
+DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def getConnection() -> sqlite3.Connection:
diff --git a/project/backend/requirements.txt b/project/backend/requirements.txt
index feac5cf..60137ce 100644
--- a/project/backend/requirements.txt
+++ b/project/backend/requirements.txt
@@ -4,4 +4,5 @@ uvicorn[standard]==0.38.0
gunicorn==23.0.0
bcrypt
python-jose[cryptography]
-python-multipart
\ No newline at end of file
+python-multipart
+pytest==9.0.3
\ No newline at end of file
diff --git a/project/backend/tests/__init__.py b/project/backend/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/project/backend/tests/test_database.py b/project/backend/tests/test_database.py
new file mode 100644
index 0000000..d6af6f1
--- /dev/null
+++ b/project/backend/tests/test_database.py
@@ -0,0 +1,72 @@
+import pytest
+import sys
+import os
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+
+import Database
+from Auth import hashPassword, verifyPassword
+from Database import createAccount, getAccountByEmail, deleteAccount, updateAccount
+
+@pytest.fixture(autouse=True)
+def useTestDb():
+ Database.initDB()
+ yield
+ # Tabellen nach jedem Test leeren
+ con = Database.getConnection()
+ con.execute("DELETE FROM Account")
+ con.commit()
+ con.close()
+
+
+class TestCreateAccount:
+ def testAccountErstellen(self):
+ result = createAccount("test@example.com", "Test User", "hashedPW")
+ assert result is not None
+ assert result["email"] == "test@example.com"
+
+ def testDuplikatEmail(self):
+ createAccount("test@example.com", "Test User", "hashedPW")
+ result = createAccount("test@example.com", "Anderer User", "hashedPW")
+ assert result is None
+
+
+class TestGetAccountByEmail:
+ def testAccountGefunden(self):
+ createAccount("test@example.com", "Test User", "hashedPW")
+ account = getAccountByEmail("test@example.com")
+ assert account is not None
+ assert account["email"] == "test@example.com"
+
+ def testAccountNichtGefunden(self):
+ result = getAccountByEmail("nichtvorhanden@example.com")
+ assert result is None
+
+
+class TestDeleteAccount:
+ def testAccountLoeschen(self):
+ createAccount("test@example.com", "Test User", "hashedPW")
+ result = deleteAccount("test@example.com")
+ assert result is True
+ assert getAccountByEmail("test@example.com") is None
+
+ def testNichtVorhandenenAccountLoeschen(self):
+ result = deleteAccount("nichtvorhanden@example.com")
+ assert result is False
+
+
+class TestUpdateAccount:
+ def testEmailAendern(self):
+ account = createAccount("alt@example.com", "Test User", "hashedPW")
+ updateAccount(account["id"], email="neu@example.com")
+ assert getAccountByEmail("neu@example.com") is not None
+ assert getAccountByEmail("alt@example.com") is None
+
+ def testPasswortAendern(self):
+ account = createAccount("test@example.com", "Test User", hashPassword("Alt1!"))
+ neuerHash = hashPassword("Neu1!")
+ updateAccount(account["id"], password_hash=neuerHash)
+ aktuell = getAccountByEmail("test@example.com")
+ # Note: The Database has 'hashedPassword' column
+ assert verifyPassword("Neu1!", aktuell["hashedPassword"]) is True
\ No newline at end of file
diff --git a/project/backend/tests/test_email.py b/project/backend/tests/test_email.py
new file mode 100644
index 0000000..7225231
--- /dev/null
+++ b/project/backend/tests/test_email.py
@@ -0,0 +1,21 @@
+import pytest
+import sys
+import os
+
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from Auth import validateEmail
+
+class TestValidateEmail:
+ def testGueltigeEmail(self):
+ assert validateEmail("test@example.com") is None
+
+ def testFehlendesAt(self):
+ assert validateEmail("testexample.com") is not None
+
+ def testFehlendeDomain(self):
+ assert validateEmail("test@") is not None
+
+ def testLeereEmail(self):
+ assert validateEmail("") is not None
\ No newline at end of file
diff --git a/project/backend/tests/test_main.py b/project/backend/tests/test_main.py
index d5af60a..9370439 100644
--- a/project/backend/tests/test_main.py
+++ b/project/backend/tests/test_main.py
@@ -1,10 +1,15 @@
-import unittest
-
-
-class MyTestCase(unittest.TestCase):
- def test_something(self):
- self.assertEqual(True, True) # add assertion here
-
-
-if __name__ == '__main__':
- unittest.main()
+"""
+Test Runner - Führe alle Tests aus
+Nutzt pytest zum Sammeln und Ausführen aller Tests im tests-Ordner
+"""
+import pytest
+import sys
+import os
+
+if __name__ == "__main__":
+ # Suche alle Test-Dateien im aktuellen Verzeichnis und führe sie aus
+ pytest.main([
+ os.path.dirname(__file__),
+ "-v",
+ "--tb=short"
+ ])
diff --git a/project/backend/tests/test_password.py b/project/backend/tests/test_password.py
new file mode 100644
index 0000000..65ec47c
--- /dev/null
+++ b/project/backend/tests/test_password.py
@@ -0,0 +1,41 @@
+import pytest
+import sys
+import os
+
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from Auth import validatePassword, hashPassword, verifyPassword
+
+class TestValidatePassword:
+ def testGueltigesPasswort(self):
+ assert validatePassword("Sicher1!") is None
+
+ def testZuKurz(self):
+ assert validatePassword("Ab1!") is not None
+
+ def testKeinGrossbuchstabe(self):
+ assert validatePassword("sicher1!") is not None
+
+ def testKeinKleinbuchstabe(self):
+ assert validatePassword("SICHER1!") is not None
+
+ def testKeineZahl(self):
+ assert validatePassword("SicherAB!") is not None
+
+ def testKeinSonderzeichen(self):
+ assert validatePassword("Sicher123") is not None
+
+
+class TestPasswortHashing:
+ def testHashIstVerschieden(self):
+ hashed = hashPassword("Sicher1!")
+ assert hashed != "Sicher1!"
+
+ def testVerifyKorrektesPasswort(self):
+ hashed = hashPassword("Sicher1!")
+ assert verifyPassword("Sicher1!", hashed) is True
+
+ def testVerifyFalschesPasswort(self):
+ hashed = hashPassword("Sicher1!")
+ assert verifyPassword("Falsch1!", hashed) is False
\ No newline at end of file
diff --git a/project/data/LazyCookDB.sqlite3 b/project/data/LazyCookDB.sqlite3
index 8f8acc3ee212d9a9495fca4da0907069ff828483..aa7b38f1a5172558800258b27978e6ccc2143d12 100644
GIT binary patch
delta 486
zcmZo@U}k)5T;g-EVrFB|m6tUx
zNi8mMNUccBEyzjLOU};?0rC|>i&Kj-5{olZQv$+`42;Zl4NP?nOcV?atxSxq3@jNK
z7@ka4kk6}U$k%5Fn$pOM+ZYw2Bo#v=6_
zl8O@loJdPU&kWmY_#+>W}0jj7dE-x*BB_t#v0)7rAei;V-K7N_af(iosECo#L43ib)^Xdy2
z*%?eF8(EF>GLv%>i}i97ORBO{9m-Ra^ionoQj1Fz+*FK`R1A$&^o$Fg{0j@x-JSK^
z3SCRfJWP@eO8pC5vb@|~$_xTZGb)|(iyRI8jWQGc&2mGlyu-8HgN+P~%ybP*bd5|D
z3=OP|EUio|xEL51nE1~!@So*>!LV7#-~d016tgAc^65`CJoY)+)$>1SBBh%zZ
z_DVwR+}{}ZbNO!Zx$$n|mE-B+{~-kW@;
z{GPnEd~f-e0u4{+om?9m!xGKXXgT?AYy^a185h9{5@DRY*Eft6BK9#3D(M>^!4d_O
K+`KYAd;tI|b#)d1
From ef3fd46c1ce80eb915d45a07d9e931fb9a909b7b Mon Sep 17 00:00:00 2001
From: nicla
Date: Mon, 4 May 2026 15:50:11 +0200
Subject: [PATCH 04/37] =?UTF-8?q?ci=20pipeline=20abh=C3=A4gigkeit=20mit=20?=
=?UTF-8?q?rein=20gemacht?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a682cc0..f2e2a00 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,7 +3,7 @@ on:
pull_request:
branches: [main, 'blatt*']
push:
- branches: [main, 'blatt*']
+ branches: [main, 'blatt*', 'feat/*']
permissions:
contents: read
@@ -50,7 +50,7 @@ jobs:
- name: Run backend tests
run: |
- docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v"
+ docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m pytest tests/ -v"
# tests:
# runs-on: ubuntu-latest
From d2c72c060761ab910add1fd0de6e509b8f5f6077 Mon Sep 17 00:00:00 2001
From: nicla
Date: Tue, 5 May 2026 10:12:41 +0200
Subject: [PATCH 05/37] Feat bei Ci yml entfernt
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f2e2a00..20dbc68 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,7 +3,7 @@ on:
pull_request:
branches: [main, 'blatt*']
push:
- branches: [main, 'blatt*', 'feat/*']
+ branches: [main, 'blatt*']
permissions:
contents: read
From 70b7d97c85b21ffadd03b68f8d21fa8ecfa9364e Mon Sep 17 00:00:00 2001
From: F <150372421+GalacticCodeGambit@users.noreply.github.com>
Date: Tue, 5 May 2026 16:09:38 +0200
Subject: [PATCH 06/37] Delete project/data/LazyCookDB.sqlite3
---
project/data/LazyCookDB.sqlite3 | Bin 65536 -> 0 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 project/data/LazyCookDB.sqlite3
diff --git a/project/data/LazyCookDB.sqlite3 b/project/data/LazyCookDB.sqlite3
deleted file mode 100644
index 8f8acc3ee212d9a9495fca4da0907069ff828483..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 65536
zcmeI)&2Qr99S3m83rsRYNDdX6PSx>fW~8JUsBK~lxpWMe_uybm2=qXCG2n#vcaXMH
zI=kCy)mG}c=l%oz6DswvhgA>N9@=A1z4TU9J+x8{gap#i>^4MVcD_gigNMgIe}2E`
z!PpNIjf9kzOysMLa=JxWHQO4kR`Zr1G#bs0df!&>lbc>$SeraiAGK4<^A>kBmaNub
z{HMmK&1tqw#?KAE-~657=NsSK{DuBskN&OO)c;}i*Xw`O=2naInt1Iea=*_DIqf6vDi6+f>gajuD&;BaYH|Bx=84|)@}>4?)TnIup3-bJQ~5@;|zM=QNLaomfhPt;ih2gIh)L0c+ZFiXpP7U*}QezA{
zlmF$sQkyD~6gBl^>z#8`Z+iMv`)O)aZsU$I{`+{Q$R=H8Zn57zW=$2rv8d)uOpH1{
z7n1~kSl!qWAC~5K=)2pmq{}iP@uw1T*-;lU@O7r5UPOB{<)=12Q+`~#RjfAVl-^wV
zz5T~J(+{4`%l$ZWDQf)qA7y;J$?@NfnT$_@nY-P)Got>BDZU3uy1I$E
zdA~LCz5R{m~h++V$)A8H*oSZLZioy5D#>
zmip@F>Io;he4MyzC9enMTXQk2pgpTzXY#(7SH0JsDe8Lp(I?(Dz3KI9?PueoD^boj
z
ze-2dVmp{9%GX-DIEC1<|9S-5wWv)A3)tUD9wTaQdba`;ulUXa3o_R%Q;-1Ybp35YuA7z{}fB*y_009U<00Izz00cfjf!c~*asvU?YYG2}yBQDCByBU32WFZjDEjb#KD1IbtzN@CRkv7CvZZv>qF$39oXc;!
za>kOACArxmJeOOvSnE!qUMqNA7EhgTcYFs~vMtoyX92IfLmsz_eP^}7Q39=`1zR~b
z2*%I6(W@;U5EOYxI}Q)*n`&NHj9+MsUl{-T0j3zc3;_s000Izz00bZa0SG_<0uXrr
z1iroM(B6KoV#Tz|Y}|HI!C?Hm`u+b)jqyw4U*5kRM#K<+00bZa0SG_<0uX=z1Rwx`
z4_M%_cJ-Nd@?(wdmDShU>7R=Xf5-o1qBETTuYddxjj_D-=Pi%nw}$UN`AmI^8w4N#
z0SG_<0uX=z1R(H@5xCgV>owX7$B}iV8!>ZozCgLXX1-jgDm`yIZ*zuXmGgA;EMA}k
z_E;n1J&E?ig{~{ujVj_ny&ZEO54=b2UcOHzs>M#U+DW*XXgUz~QGy`L?ZSAbF{4gz
zmMH5X6{Fkdk3uG?pf1PrfOxQWZEyX
zwMeGn4<*{k)KRV%q`Qeu?$qq5c*Q);M~-TaQofuLj_a=Ed9sksHcGU!7w@?fiqHrv
z%|3S{Cid`vu`qUqu~WCkG+ckNvp$Kbl$WE?)AOP)Cb>=$R4c(ApM=ArP*3F?y}Z+U
zYz_n*tY^SbzCqD^%1Cap8jy>rPFkcZa;6$Q9SG5UxZ(^s2b`?Ly9eH;c*BWg4oTKx
zvyoQXaa&BI^%py9lb9H(+RBk$b2*(WWzRx2m(&kdyh1R+x0rO(9JC9CQcf&(Yra~C
z7Ui?EQXn5Z8gzngGn-PX1G?%DIGb)Rau&6@QxPs)76#@SC;E_4r>5I16lJGu%zwvp
zx&C5jbrMr|U}MQj)*P*!7NQ{~$(TE|Gn+i2!|rIEVFyy8WVZ>fu%ABb7fupgccMur
zLVSR6^~(oT-Q5WWA|9_Cjq{$a=iHvn4+f>ujMI3oJZ+&F)P4~Io
z$=i8*JRgx2?#yZPdHcQ?7hr8+-*rfoq)w){QbQ)vMzZ#sA&n;6kJevotMmIuA@%ct
zdOH~k_{~BgVzskfGCu
zfB*y_009U<00Izz00bZafdvcT`u~DK(HsOI009U<00Izz00bZa0SG`~2?hQG0wt;;
From 7b20faf1bf15bcddb1ab94fe18366bc4d963a05a Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Tue, 5 May 2026 16:25:22 +0200
Subject: [PATCH 07/37] Feature: Forgot Password link in signin modal. Popup
for Email Input. Konto deletion reactivation.
---
project/backend/Auth.py | 34 +++-
project/backend/Database.py | 65 +++++++-
project/backend/Models.py | 12 ++
project/backend/Routes.py | 51 +++++-
.../frontend/app/homepage/forgotPassword.tsx | 74 +++++++++
project/frontend/app/homepage/homepage.tsx | 9 +-
project/frontend/app/homepage/signin.tsx | 5 +-
.../frontend/app/profile/changeEmailPopup.tsx | 53 +++++++
.../app/profile/changePasswordPopup.tsx | 114 ++++++++++++++
project/frontend/app/profile/page.tsx | 148 ++----------------
project/frontend/app/profile/style.css | 10 ++
project/frontend/app/reset-password/page.tsx | 122 +++++++++++++++
12 files changed, 552 insertions(+), 145 deletions(-)
create mode 100644 project/frontend/app/homepage/forgotPassword.tsx
create mode 100644 project/frontend/app/profile/changeEmailPopup.tsx
create mode 100644 project/frontend/app/profile/changePasswordPopup.tsx
create mode 100644 project/frontend/app/profile/style.css
create mode 100644 project/frontend/app/reset-password/page.tsx
diff --git a/project/backend/Auth.py b/project/backend/Auth.py
index 41283b0..1ffde5d 100644
--- a/project/backend/Auth.py
+++ b/project/backend/Auth.py
@@ -13,7 +13,11 @@
import bcrypt
from pydantic import BaseModel
-from Models import Token, User
+import hashlib
+
+PASSWORD_RESET_EXPIRE_MINUTES = 30
+
+from Models import Token, User, ForgotPasswordRequest, ResetPasswordRequest
from Database import getAccountByEmail, saveRefreshToken, getRefreshToken
@@ -116,3 +120,31 @@ 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["used_at"] is not None:
+ return None
+ expiresAt = datetime.fromisoformat(entry["expires_at"])
+ if expiresAt.tzinfo is None:
+ expiresAt = expiresAt.replace(tzinfo=timezone.utc)
+ if expiresAt < datetime.now(timezone.utc):
+ return None
+ return entry
\ No newline at end of file
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 0d469cf..4601e4c 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -99,6 +99,17 @@ def initDB():
UNIQUE (AccountID, rid)
)
""")
+ 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")
@@ -203,4 +214,56 @@ def cleanupExpiredTokens() -> None:
"""Löscht alle abgelaufenen Refresh Tokens."""
with getDB() as con:
cur = con.cursor()
- cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")
\ No newline at end of file
+ cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")
+
+
+
+# ── 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 used_at = 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 = ?",
+ (token_id,),
+ )
+
+
+def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
+ with getDB() as con:
+ cur = con.cursor()
+ cur.execute(
+ "UPDATE Account SET hashed_password = ? WHERE id = ?",
+ (hashed_password, konto_id),
+ )
\ No newline at end of file
diff --git a/project/backend/Models.py b/project/backend/Models.py
index 43c573e..86638ac 100644
--- a/project/backend/Models.py
+++ b/project/backend/Models.py
@@ -19,3 +19,15 @@ 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
\ No newline at end of file
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 0289641..1c9a077 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -14,6 +14,8 @@
validateRefreshToken,
verifyPassword,
createAccessToken,
+ createPasswordResetToken,
+ validatePasswordResetToken,
)
from Database import (
createAccount,
@@ -22,9 +24,13 @@
deleteAllRefreshTokens,
deleteAccount,
updateAccount,
+ markResetTokenUsed,
+ updateKontoPassword,
+ deleteAllRefreshTokens
+
)
-from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest
+from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
router = APIRouter()
@@ -104,10 +110,7 @@ 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(
@@ -143,3 +146,41 @@ 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"http://localhost:8000/reset-password?token={token}"
+ # TODO: Mail versenden – siehe Hinweis unten
+ print(f"[DEV] Reset-Link für {body.email}: {resetLink}")
+
+ 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["konto_id"], hashPassword(body.new_password))
+ markResetTokenUsed(entry["id"])
+ # Sicherheitsmaßnahme: Alle aktiven Sessions ungültig machen
+ deleteAllRefreshTokens(entry["konto_id"])
+
+ return {"detail": "Passwort erfolgreich zurückgesetzt."}
diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx
new file mode 100644
index 0000000..cd778d1
--- /dev/null
+++ b/project/frontend/app/homepage/forgotPassword.tsx
@@ -0,0 +1,74 @@
+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 emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+
+ const handleSubmit = async () => {
+ setEmailBlurred(true);
+ if (!emailValid) return;
+ setBusy(true);
+ try {
+ await fetch(`${API_URL}/auth/forgot-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+ setSubmitted(true);
+ } 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.
+
+
+ Schließen
+
+
+ );
+ }
+
+ return (
+
+
Passwort vergessen
+
+ Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum
+ Zurücksetzen.
+
+
setEmail(v)}
+ placeholder="Email"
+ onBlur={() => setEmailBlurred(true)}
+ state={
+ emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default"
+ }
+ />
+
+ {busy ? "Wird gesendet…" : "Link anfordern"}
+
+
+
+ Zurück zur Anmeldung
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx
index 5c04f67..6ad3795 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), []);
@@ -191,12 +192,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..04df94a 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("");
@@ -47,6 +47,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"} />
+
+ Passwort vergessen?
+
{busy ? "Wird geladen…" : "Anmelden"}
diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx
new file mode 100644
index 0000000..9b971ea
--- /dev/null
+++ b/project/frontend/app/profile/changeEmailPopup.tsx
@@ -0,0 +1,53 @@
+import {Button} from "@/app/components/ui/button";
+import {fetchWithAuth} from "@/lib/auth";
+import {useState} from "react";
+
+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)}
+ className="border rounded-lg px-3 py-2 text-sm w-full"
+ />
+ {emailMsg &&
{emailMsg}
}
+
+
+ Speichern
+
+
+
+ );
+}
\ 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..3c6bce4
--- /dev/null
+++ b/project/frontend/app/profile/changePasswordPopup.tsx
@@ -0,0 +1,114 @@
+import {fetchWithAuth} from "@/lib/auth";
+import {useState} from "react";
+import {Button} from "@/app/components/ui/button";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
+
+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 isForgot = modus === "forgot";
+
+ async function handlePasswordChange() {
+ setPasswordMsg("");
+
+ // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern)
+ 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 && (
+
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"
+ />
+
+
setConfirmPassword(e.target.value)}
+ className="border rounded-lg px-3 py-2 text-sm w-full"
+ />
+
+ {passwordMsg &&
{passwordMsg}
}
+
+
+
+ {isForgot ? "Zurücksetzen" : "Speichern"}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx
index 1f5a269..2147626 100644
--- a/project/frontend/app/profile/page.tsx
+++ b/project/frontend/app/profile/page.tsx
@@ -1,17 +1,18 @@
"use client";
-import { useRef, useState} from "react";
+import {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";
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 router = useRouter();
const [showConfirm, setShowConfirm] = useState(false);
@@ -19,58 +20,8 @@ export default function Profile() {
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.");
- }
- }
-
- 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");
@@ -111,13 +62,13 @@ export default function Profile() {
Einstellungen
{ setShowEmailModal(true); setEmailMsg(""); }}
+ onClick={() => { setShowEmailModal(true) }}
className="bg-black text-white hover:bg-gray-800 text-sm w-full"
>
E-Mail ändern
{ setShowPasswordModal(true); setPasswordMsg(""); }}
+ onClick={() => { setShowPasswordModal(true); }}
className="bg-black text-white hover:bg-gray-800 text-sm w-full"
>
Passwort ändern
@@ -125,93 +76,20 @@ export default function Profile() {
setShowConfirm(true)}
+ onClick={handleAccountDeletion}
>
Konto löschen
- {/* 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}
}
-
- setShowEmailModal(false)}
- >
- Abbrechen
-
-
- Speichern
-
-
-
-
- )}
+ setShowEmailModal(false)}>
+
+
- {/* 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}
}
-
- setShowPasswordModal(false)}
- >
- Abbrechen
-
-
- Speichern
-
-
-
-
- )}
+ setShowPasswordModal(false)}>
+
+
);
}
diff --git a/project/frontend/app/profile/style.css b/project/frontend/app/profile/style.css
new file mode 100644
index 0000000..ef2f8da
--- /dev/null
+++ b/project/frontend/app/profile/style.css
@@ -0,0 +1,10 @@
+.popup {
+ background: #fff;
+ border-radius: 12px;
+ padding: 2rem 2rem 1.75rem;
+ width: auto;
+ max-width: 520px;
+ position: relative;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12);
+ animation: slideUp 0.2s ease-out;
+}
\ No newline at end of file
diff --git a/project/frontend/app/reset-password/page.tsx b/project/frontend/app/reset-password/page.tsx
new file mode 100644
index 0000000..a56795c
--- /dev/null
+++ b/project/frontend/app/reset-password/page.tsx
@@ -0,0 +1,122 @@
+"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}
}
+
+
+
+ {busy ? "Wird gespeichert…" : "Passwort zurücksetzen"}
+
+
+ );
+}
+
+export default function ResetPasswordPage() {
+ return (
+
+ Wird geladen…
+
+ }
+ >
+
+
+ );
+}
\ No newline at end of file
From 08852138d9175e22a6461f621f34f3f526009e42 Mon Sep 17 00:00:00 2001
From: F <150372421+GalacticCodeGambit@users.noreply.github.com>
Date: Tue, 5 May 2026 16:56:43 +0200
Subject: [PATCH 08/37] Feat/ci cd add lint (#142)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Rename models.py to Models.py
* Rename auth.py to Auth.py
* Rename Routes.py to routes.py
* Added Refactoring Summary
* Refactor documentation for code naming conventions
* Fix spelling errors in refactoring documentation
Corrected spelling of 'Refaktorisierung' and 'Frontend'.
* Delete project/data/LazyCookDB.sqlite3
* AI Agent md's changed
* Passwort und Email Änderung zu Popups geändert. und neugeordnet untereinander
* Update README.md to mark tasks as completed for account management features
* Add CI/CD pipeline and Super Linter configuration
* Test für lint
* Test ci/cd lint.yml
* Test ci/cd lint.yml
* Test ci/cd lint.yml
* Test ci/cd lint.yml regex Filter
* Test ci/cd lint.yml
* Test ci/cd lint.yml
* Test ci/cd lint.yml regex Filter angepasst
* Tests für Database, passwort überprüfung und email überürufung wurden geschrieben. Die dazugehörigen Abhängigkeiten in requirements.txt angepasst. Sie werden in test_main.py aufgerufen.
Namens faktorisierung ist schwer bei den Klassennamen da bei tests der pycharm Debugger eher auf klassen mit test_ vorne guckt. Deswegen leider test_
* ci pipeline abhägigkeit mit rein gemacht
* Feat bei Ci yml entfernt
* Update FILTER_REGEX_INCLUDE pattern in lint.yml
* Update lint.yml
* Enable validation for the entire codebase
* Update lint.yml
* Update lint.yml
* Enable validation for all codebase in lint workflow
* Remove Python Black and Markdown validation
* Update lint workflow to include additional validations
* Update FILTER_REGEX_INCLUDE to support YAML files
* Update lint.yml
* Update lint.yml
* Refactor CI configuration to use Docker Compose v2 syntax and add GitHub Super Linter step
* Test ci.yml
* Update ci.yml
* Enable Flake8 validation for Python files
* Test ci.yml
* Remove 'feat/*' branch from push trigger in CI configuration
* Python black gelöst
---------
Co-authored-by: Samuel Goebel
Co-authored-by: Eden Tabea Bernhard <105359952+EdenBernhard@users.noreply.github.com>
Co-authored-by: Nicoolaus <162422307+Nicoolaus@users.noreply.github.com>
Co-authored-by: Hellocrafting
Co-authored-by: nicla
Co-authored-by: Hellocrafting <75727565+Hellocrafting@users.noreply.github.com>
---
.github/workflows/ci.yml | 105 ++++++++--------------
README.md | 4 +
project/backend/Admin.py | 1 -
project/backend/Auth.py | 28 ++++--
project/backend/Database.py | 22 +++--
project/backend/EmailService.py | 3 +-
project/backend/Ingridient.py | 2 +-
project/backend/LazyCookAdministration.py | 1 -
project/backend/Models.py | 5 ++
project/backend/Person.py | 2 +-
project/backend/Recipe.py | 1 -
project/backend/Routes.py | 7 +-
project/backend/User.py | 1 -
project/backend/requirements.txt | 3 +-
project/backend/tests/__init__.py | 0
project/backend/tests/test_database.py | 73 +++++++++++++++
project/backend/tests/test_email.py | 21 +++++
project/backend/tests/test_main.py | 22 ++---
project/backend/tests/test_password.py | 41 +++++++++
19 files changed, 241 insertions(+), 101 deletions(-)
create mode 100644 project/backend/tests/__init__.py
create mode 100644 project/backend/tests/test_database.py
create mode 100644 project/backend/tests/test_email.py
create mode 100644 project/backend/tests/test_password.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a682cc0..d4c93fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,31 +1,29 @@
name: "CI/CD Pipeline"
+
on:
pull_request:
branches: [main, 'blatt*']
push:
branches: [main, 'blatt*']
+
permissions:
contents: read
jobs:
- build:
+ build-tests:
+ name: Build stack and run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Install Docker Compose (build job)
- run: |
- sudo apt-get update
- sudo apt-get install -y docker-compose
-
- name: Build the stack
- run: docker-compose -f project/compose.yaml build
+ run: docker compose -f project/compose.yaml build
- name: Start services
run: |
# remove stale containers/volumes from previous runs that can cause compose to read invalid metadata
- docker-compose -f project/compose.yaml down --remove-orphans --volumes || true
- docker-compose -f project/compose.yaml up -d
+ docker compose -f project/compose.yaml down --remove-orphans --volumes || true
+ docker compose -f project/compose.yaml up -d
- name: Wait for frontend on :8000
run: |
@@ -42,69 +40,42 @@ jobs:
sleep $wait_seconds
done
echo "service not ready" >&2
- docker-compose -f project/compose.yaml ps || true
- docker-compose -f project/compose.yaml logs || true
+ docker compose -f project/compose.yaml ps || true
+ docker compose -f project/compose.yaml logs || true
exit 1
+
- name: Run smoke test frontend
run: curl -fsSL -o /dev/null http://localhost:8000/
- name: Run backend tests
run: |
- docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v"
+ docker compose -f project/compose.yaml exec -T backend-dev sh -c "python -m pytest 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"
+ linter:
+ name: Lint code base
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
-# lint:
-# runs-on: ubuntu-latest
-# needs: build
-# steps:
-# - name: Wait for frontend on :8000
-# run: |
-# PORT=8000
-# attempts=30
-# wait_seconds=3
-# for i in $(seq 1 $attempts); do
-# if curl -fsSL "http://localhost:${PORT}/" >/dev/null 2>&1; then
-# echo "ready"
-# exit 0
-# fi
-# echo "waiting... ($i)"
-# sleep $wait_seconds
-# done
-# echo "service not ready" >&2
-# docker-compose -f project/compose.yaml ps || true
-# docker-compose -f project/compose.yaml logs || true
-# exit 1
-# - name: Run linting tests
-# run: |
-# # startet einen einmaligen Container für den Service 'backend' und führt Linting-Tests aus (z.B. mit flake8)
-# docker-compose -f project/compose.yaml run --rm backend sh -c "flake8 ."
\ No newline at end of file
+ - name: Run GitHub Super Linter
+ uses: super-linter/super-linter/slim@v7
+ 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 65a05fa..5beb5c9 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,10 @@ 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.
+
+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
```
From 7f7f042042b51fe7e1edb3851735fb5b9e41d9da Mon Sep 17 00:00:00 2001
From: GalacticCodeGambit
<150372421+GalacticCodeGambit@users.noreply.github.com>
Date: Fri, 8 May 2026 15:21:19 +0200
Subject: [PATCH 14/37] Update Node.js version in Dockerfile-frontend to 24.1.0
---
project/Dockerfile-frontend | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 64be75daf053de761c57e311535cf34f4b52f6a6 Mon Sep 17 00:00:00 2001
From: PrussianBaron
Date: Sat, 9 May 2026 14:52:39 +0200
Subject: [PATCH 15/37] Tested the recipe algorithim
---
project/backend/Database.py | 155 ++++++++++++++++--
.../backend/{Ingridient.py => Ingredient.py} | 2 +-
project/backend/Recipe.py | 34 ++--
project/backend/RecipeSucuk.py | 48 ++++--
4 files changed, 187 insertions(+), 52 deletions(-)
rename project/backend/{Ingridient.py => Ingredient.py} (95%)
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 8736849..ae1621b 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -3,6 +3,7 @@
from pathlib import Path
import os
+
# Verwende die Datenbank aus dem data-Ordner
DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3"
@@ -61,7 +62,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,8 +80,8 @@ def initDB():
CREATE TABLE IF NOT EXISTS Recipe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
- description nvarchar(MAX),
- vid INTEGER NOT NULL,
+ description nvarchar(20000),
+ vid INTEGER,
FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE
)
""")
@@ -90,7 +91,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)
)
@@ -211,6 +212,33 @@ 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) -> bool:
+ 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) -> bool:
+ 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) -> bool:
+ with getDB() as con:
+ cur = con.cursor()
+ cur.execute(
+ "INSERT INTO Ingredient (name, amountType) VALUES (?, ?)",
+ (name, amountType),
+ )
+ return cur.lastrowid
+
def getRecipe(recipeID: int) -> dict | None:
"""Gibt eine Liste aller Rezepte zurück."""
@@ -220,7 +248,7 @@ def getRecipe(recipeID: int) -> dict | None:
SELECT name, description
FROM Recipe
WHERE id = ?
- """, (recipeID,))
+ """, (recipeID))
row = cur.fetchone()
return dict(row) if row else None
@@ -230,21 +258,22 @@ def getAllRecipes()-> list[dict]:
cur.execute("""
SELECT id, name, description
FROM Recipe
-
""")
rows = cur.fetchall()
return [dict(row) for row in rows]
-def getAllIngridientsForRecipe(id: int) -> list[dict]:
- """Gibt eine Liste aller Zutaten zurück."""
+def getAllIngredientsForRecipe(rid: int):
with getDB() as con:
cur = con.cursor()
cur.execute("""
- SELECT id, name, Exists_from.amount
- FROM Ingridient
- Inner Join Exists_from id = zid
- Inner Join Recipe rid = id
- Where id = ?
- """, (id,))
+ 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]
@@ -255,9 +284,103 @@ def getAllocatedRecipes(name: str)-> list[dict]:
cur.execute("""
SELECT rid
FROM Exists_from
- Inner Join Ingridient on rid=id,
- Where id = ?
+ Inner Join Ingredient on rid=id,
+ Where Exists_from.id = ?
""", (name,))
rows = cur.fetchall()
return [dict(row) for row in rows]
+initDB()
+"""
+# --- 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)
+"""
\ No newline at end of file
diff --git a/project/backend/Ingridient.py b/project/backend/Ingredient.py
similarity index 95%
rename from project/backend/Ingridient.py
rename to project/backend/Ingredient.py
index 232b9f3..c0ad485 100644
--- a/project/backend/Ingridient.py
+++ b/project/backend/Ingredient.py
@@ -1,7 +1,7 @@
from typing import List, Dict, Any
-class Ingridient:
+class Ingredient:
def __init__(self, name: str, amount: float):
self.__name = name
self.__amount = amount
diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py
index 788664c..14721e4 100644
--- a/project/backend/Recipe.py
+++ b/project/backend/Recipe.py
@@ -1,39 +1,39 @@
-from Database import Database
-from Ingridient import Ingridient
+
+from Ingredient import Ingredient
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.__Ingridients = ingridients
+ self.__Ingredients = Ingredients
self.__description = description
self.__original = ""
self.__duration = ""
self.__rating = 0.0
self.__countPersons = 1
self.__matching = 0
- self.__database = Database()
+
def saveInDB(self) -> bool:
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
@@ -48,14 +48,14 @@ 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 getIngridients(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
index 25d1e0c..64aafc3 100644
--- a/project/backend/RecipeSucuk.py
+++ b/project/backend/RecipeSucuk.py
@@ -1,42 +1,54 @@
import Database
-from Ingridient import Ingridient
+from Ingredient import Ingredient
from Recipe import Recipe
-from Database import getAllRecipes, getAllIngridientsForRecipe
+from Database import getAllRecipes, getAllIngredientsForRecipe
-def findRecipes(ingriedents: list[Ingridient])-> list[Recipe]:
+def findRecipes(ingriedents: list[Ingredient])-> list[Recipe]:
recipes = __initRecipes()
- for ingridient in ingriedents:
- recipes = __filterRecipes(recipes)
+ for Ingredient in ingriedents:
+ recipes = __filterRecipes(recipes, Ingredient)
recipes.sort(key=lambda x: x.getRating(), reverse=True)
for recipe in recipes[::-1]:
- if recipe.getMatching < 3:
+ if recipe.getRating() < 0.3:
recipes.remove(recipe)
else:
break
return recipes
-def __filterRecipes(recipes: list[Recipe], ingridient: Ingridient)-> list[Recipe]:
+def __filterRecipes(recipes: list[Recipe], Ingredient: Ingredient)-> list[Recipe]:
for recipe in recipes:
- for recipeIngridient in recipe.getIngridients():
- if recipeIngridient.getName() == ingridient.getName():
+ for recipeIngredient in recipe.getIngredients():
+ if recipeIngredient.getName() == Ingredient.getName():
recipe.incrementMatching()
- recipe.setRating(recipe.getMatching/ len(recipe.getIngridients))
+ recipe.setRating(recipe.getMatching()/ len(recipe.getIngredients()))
return recipes
def __initRecipes()-> list[Recipe]:
- recipesRaw = getAllRecipes
+ recipesRaw = getAllRecipes()
recipes = []
for recipeRaw in recipesRaw:
- recipe = Recipe(recipeRaw["name"], __formatIngridients(recipeRaw["id"]), recipeRaw["description"])
+ recipe = Recipe(recipeRaw["name"], __formatIngredients(recipeRaw["id"]), recipeRaw["description"])
recipes.append(recipe)
return recipes
-def __formatIngridients(id: int)-> list[Ingridient]:
- ingridientsRaw = getAllIngridientsForRecipe(id)
- ingridients = []
- for ingridientRaw in ingridientsRaw:
- ingridients.append(Ingridient(ingridientRaw["name"],ingridientRaw["amount"]))
- return ingridients
\ No newline at end of file
+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()))
\ No newline at end of file
From fb332478664ba1485c5c192df8bc48453d804b1b Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Sat, 9 May 2026 16:02:54 +0200
Subject: [PATCH 16/37] Feature: Fix problem with refresh token and include
show Password
---
project/backend/Models.py | 1 +
project/backend/Routes.py | 2 +-
project/frontend/app/components/fields.tsx | 50 ++++++++++++---
.../app/profile/changePasswordPopup.tsx | 62 +++++++++++++++----
project/frontend/lib/auth.tsx | 26 ++++----
5 files changed, 106 insertions(+), 35 deletions(-)
diff --git a/project/backend/Models.py b/project/backend/Models.py
index 86638ac..5ccd306 100644
--- a/project/backend/Models.py
+++ b/project/backend/Models.py
@@ -3,6 +3,7 @@
class Token(BaseModel):
access_token: str
+ refresh_token: str
token_type: str
class User(BaseModel):
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 1c9a077..570dda8 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -129,7 +129,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:
diff --git a/project/frontend/app/components/fields.tsx b/project/frontend/app/components/fields.tsx
index 9e3fdae..52ad3a4 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,41 @@ export default function Field({ label, type = "text", value, onChange, placehold
return (
{label}
-
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 && (
+ setShowPassword((s) => !s)}
+ aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
+ tabIndex={-1}
+ style={{
+ position: "absolute",
+ right: 8,
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 4,
+ display: "flex",
+ alignItems: "center",
+ color: "#666",
+ }}
+ >
+ {showPassword ? : }
+
+ )}
+
);
}
\ No newline at end of file
diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx
index 882669b..2e05d2c 100644
--- a/project/frontend/app/profile/changePasswordPopup.tsx
+++ b/project/frontend/app/profile/changePasswordPopup.tsx
@@ -1,8 +1,50 @@
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"
+function PasswordInput({ value, onChange, placeholder }: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder: string;
+}) {
+ const [show, setShow] = useState(false);
+ return (
+
+ onChange(e.target.value)}
+ className="popup__input"
+ style={{ width: "100%", paddingRight: 38 }}
+ />
+ setShow((s) => !s)}
+ aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
+ tabIndex={-1}
+ style={{
+ position: "absolute",
+ right: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ color: "#666",
+ }}
+ >
+ {show ? : }
+
+
+ );
+}
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
type Modus = "change" | "forgot";
@@ -63,7 +105,7 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
setConfirmPassword("");
onSuccess?.();
} catch {
- setPasswordMsg("Unbekannter Fehler.");
+ setPasswordMsg('❌ Unbekannter Fehler');
}
}
@@ -75,29 +117,23 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
{!isForgot && (
- setCurrentPassword(e.target.value)}
- className="popup__input"
+ onChange={setCurrentPassword}
/>
)}
- setNewPassword(e.target.value)}
- className="popup__input"
+ onChange={setNewPassword}
/>
- setConfirmPassword(e.target.value)}
- className="popup__input"
+ onChange={setConfirmPassword}
/>
diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx
index cae660e..2f151dd 100644
--- a/project/frontend/lib/auth.tsx
+++ b/project/frontend/lib/auth.tsx
@@ -39,6 +39,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);
}
@@ -118,23 +121,22 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise
Date: Sun, 10 May 2026 14:48:34 +0200
Subject: [PATCH 17/37] Feature: forgot password functionality works with
redirection to homepage
---
project/backend/Auth.py | 4 +-
project/backend/Database.py | 23 +++++++++--
project/backend/EmailService.py | 40 +++++++++++++++++++
project/backend/LazyCookAdministration.py | 6 ++-
project/backend/Routes.py | 30 ++++++++++----
project/compose.yaml | 1 +
.../frontend/app/homepage/forgotPassword.tsx | 1 +
.../frontend/app/profile/changeEmailPopup.tsx | 1 +
.../app/profile/changePasswordPopup.tsx | 20 +++++-----
project/frontend/app/reset-password/page.tsx | 2 +
project/frontend/lib/auth.tsx | 3 +-
11 files changed, 106 insertions(+), 25 deletions(-)
diff --git a/project/backend/Auth.py b/project/backend/Auth.py
index 1ffde5d..9e89313 100644
--- a/project/backend/Auth.py
+++ b/project/backend/Auth.py
@@ -140,9 +140,9 @@ def createPasswordResetToken(kontoId: int) -> str:
def validatePasswordResetToken(token: str) -> dict | None:
from Database import getPasswordResetToken
entry = getPasswordResetToken(hashResetToken(token))
- if entry is None or entry["used_at"] is not None:
+ if entry is None or entry["usedAt"] is not None:
return None
- expiresAt = datetime.fromisoformat(entry["expires_at"])
+ expiresAt = datetime.fromisoformat(entry["expiresAt"])
if expiresAt.tzinfo is None:
expiresAt = expiresAt.replace(tzinfo=timezone.utc)
if expiresAt < datetime.now(timezone.utc):
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 4601e4c..173b898 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -226,7 +226,7 @@ def savePasswordResetToken(kontoID: int, tokenHash: str, expiresAt: str) -> None
cur = con.cursor()
# Alte, ungenutzte Tokens für dieses Konto invalidieren
cur.execute(
- "UPDATE PasswordResetToken SET used_at = CURRENT_TIMESTAMP "
+ "UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP "
"WHERE kontoID = ? AND usedAt IS NULL",
(kontoID,),
)
@@ -256,7 +256,7 @@ def markResetTokenUsed(tokenID: int) -> None:
cur = con.cursor()
cur.execute(
"UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP WHERE id = ?",
- (token_id,),
+ (tokenID,),
)
@@ -264,6 +264,21 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
with getDB() as con:
cur = con.cursor()
cur.execute(
- "UPDATE Account SET hashed_password = ? WHERE id = ?",
+ "UPDATE Account SET hashedPassword = ? WHERE id = ?",
(hashed_password, konto_id),
- )
\ No newline at end of file
+ )
+
+
+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()
\ No newline at end of file
diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py
index caa8a0f..67506b5 100644
--- a/project/backend/EmailService.py
+++ b/project/backend/EmailService.py
@@ -28,6 +28,46 @@ def sendPasswordChangedEmail(to_email: str, name: str) -> None:
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
\ No newline at end of file
diff --git a/project/backend/LazyCookAdministration.py b/project/backend/LazyCookAdministration.py
index 8f7df0d..9750ae0 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/Routes.py b/project/backend/Routes.py
index 570dda8..7f28ef7 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -1,7 +1,8 @@
+import os
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
-from EmailService import sendPasswordChangedEmail
+from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail
from pydantic import BaseModel as _BaseModel
@@ -20,6 +21,7 @@
from Database import (
createAccount,
getAccountByEmail,
+ getAccountById,
deleteRefreshToken,
deleteAllRefreshTokens,
deleteAccount,
@@ -32,6 +34,9 @@
from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
+# 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()
@@ -84,7 +89,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)
@@ -157,9 +162,12 @@ async def forgotPassword(body: ForgotPasswordRequest):
# Token nur erzeugen wenn Konto existiert – aber IMMER gleiche Antwort senden!
if konto is not None:
token = createPasswordResetToken(konto["id"])
- resetLink = f"http://localhost:8000/reset-password?token={token}"
- # TODO: Mail versenden – siehe Hinweis unten
- print(f"[DEV] Reset-Link für {body.email}: {resetLink}")
+ 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."}
@@ -178,9 +186,17 @@ async def resetPassword(body: ResetPasswordRequest):
detail="Link ungültig oder abgelaufen. Bitte neuen anfordern.",
)
- updateKontoPassword(entry["konto_id"], hashPassword(body.new_password))
+ updateKontoPassword(entry["kontoID"], hashPassword(body.new_password))
markResetTokenUsed(entry["id"])
# Sicherheitsmaßnahme: Alle aktiven Sessions ungültig machen
- deleteAllRefreshTokens(entry["konto_id"])
+ 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."}
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/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx
index cd778d1..0186cac 100644
--- a/project/frontend/app/homepage/forgotPassword.tsx
+++ b/project/frontend/app/homepage/forgotPassword.tsx
@@ -57,6 +57,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
onChange={(v: string) => setEmail(v)}
placeholder="Email"
onBlur={() => setEmailBlurred(true)}
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
state={
emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default"
}
diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx
index cd6045e..1d1dd3b 100644
--- a/project/frontend/app/profile/changeEmailPopup.tsx
+++ b/project/frontend/app/profile/changeEmailPopup.tsx
@@ -39,6 +39,7 @@ export default function ChangeEmail (){
placeholder="Neue E-Mail-Adresse"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleEmailChange()}
className="popup__input"
/>
diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx
index 2e05d2c..7105c6b 100644
--- a/project/frontend/app/profile/changePasswordPopup.tsx
+++ b/project/frontend/app/profile/changePasswordPopup.tsx
@@ -3,11 +3,15 @@ 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";
-function PasswordInput({ value, onChange, placeholder }: {
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
+
+function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
value: string;
onChange: (v: string) => void;
placeholder: string;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
const [show, setShow] = useState(false);
return (
@@ -17,6 +21,7 @@ function PasswordInput({ value, onChange, placeholder }: {
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
+ onKeyDown={onKeyDown}
className="popup__input"
style={{ width: "100%", paddingRight: 38 }}
/>
@@ -45,8 +50,6 @@ function PasswordInput({ value, onChange, placeholder }: {
);
}
-const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
-
type Modus = "change" | "forgot";
interface ChangePasswordProps {
@@ -60,6 +63,7 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordMsg, setPasswordMsg] = useState("");
+ const [pwBlurred, setPwBlurred] = useState(false);
const isForgot = modus === "forgot";
@@ -117,23 +121,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
{!isForgot && (
-
- )}
+
setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} />)}
+
e.key === "Enter" && handlePasswordChange()}
/>
e.key === "Enter" && handlePasswordChange()}
/>
diff --git a/project/frontend/app/reset-password/page.tsx b/project/frontend/app/reset-password/page.tsx
index a56795c..aacc4d0 100644
--- a/project/frontend/app/reset-password/page.tsx
+++ b/project/frontend/app/reset-password/page.tsx
@@ -90,6 +90,7 @@ function ResetPasswordContent() {
value={password}
onChange={setPassword}
placeholder="••••••••"
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
state={password && !pwValid ? "error" : "default"}
/>
e.key === "Enter" && handleSubmit()}
state={confirm && !pwMatch ? "error" : pwMatch ? "success" : "default"}
/>
diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx
index 2f151dd..2d6c1ca 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 {
From bee70b0e79a03a7e7dcd313317dc862a49ea1d4d Mon Sep 17 00:00:00 2001
From: PrussianBaron
Date: Sun, 10 May 2026 15:19:17 +0200
Subject: [PATCH 18/37] added SaveInDB Methods
---
project/backend/Database.py | 20 +++++++++++++++-----
project/backend/Ingredient.py | 20 ++++++++++++++++++--
project/backend/Recipe.py | 22 ++++++++++++++--------
3 files changed, 47 insertions(+), 15 deletions(-)
diff --git a/project/backend/Database.py b/project/backend/Database.py
index ae1621b..9d76e36 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -213,7 +213,7 @@ def cleanupExpiredTokens() -> None:
cur = con.cursor()
cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")
-def addRecipe(name: str, description: str, vid: int) -> bool:
+def addRecipe(name: str, description: str, vid: int) -> int:
with getDB() as con:
cur = con.cursor()
cur.execute(
@@ -222,7 +222,7 @@ def addRecipe(name: str, description: str, vid: int) -> bool:
)
return cur.lastrowid
-def addIngredientToRecipe(zid: int, rid: int, amount: float) -> bool:
+def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int:
with getDB() as con:
cur = con.cursor()
cur.execute(
@@ -230,7 +230,7 @@ def addIngredientToRecipe(zid: int, rid: int, amount: float) -> bool:
(zid, rid, amount),
)
-def addIngredient(name: str, amountType: str) -> bool:
+def addIngredient(name: str, amountType: str) -> int:
with getDB() as con:
cur = con.cursor()
cur.execute(
@@ -238,7 +238,17 @@ def addIngredient(name: str, amountType: str) -> bool:
(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."""
@@ -290,7 +300,7 @@ def getAllocatedRecipes(name: str)-> list[dict]:
rows = cur.fetchall()
return [dict(row) for row in rows]
-initDB()
+
"""
# --- 1. Fresh Pesto Pasta ---
r1 = addRecipe("Fresh Pesto Pasta", "Classic Italian basil pesto with linguine.", 301)
diff --git a/project/backend/Ingredient.py b/project/backend/Ingredient.py
index c0ad485..ee9228d 100644
--- a/project/backend/Ingredient.py
+++ b/project/backend/Ingredient.py
@@ -1,11 +1,11 @@
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
@@ -17,3 +17,19 @@ def getAmount(self) -> float:
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/Recipe.py b/project/backend/Recipe.py
index 14721e4..51925e2 100644
--- a/project/backend/Recipe.py
+++ b/project/backend/Recipe.py
@@ -1,11 +1,10 @@
from Ingredient import Ingredient
-
-
+from Database import addIngredientToRecipe, addRecipe, getIngridientByName
class Recipe:
- def __init__(self, name: str, Ingredients: list[Ingredient], description: str):
+ def __init__(self, name: str, ingredients: list[Ingredient], description: str):
self.__name = name
- self.__Ingredients = Ingredients
+ self.__ingredients = ingredients
self.__description = description
self.__original = ""
self.__duration = ""
@@ -15,6 +14,13 @@ def __init__(self, name: str, Ingredients: list[Ingredient], description: str):
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:
@@ -53,9 +59,9 @@ def getDuration(self) -> str:
def setDuration(self, duration: str):
self.__duration = duration
- def getIngredients(self) -> list[Ingredient]:
- return self.__Ingredients
+ def getingredients(self) -> list[Ingredient]:
+ return self.__ingredients
- def setIngredient(self, Ingredients: list[Ingredient]):
- self.__Ingredients = Ingredients
+ def setIngredient(self, ingredients: list[Ingredient]):
+ self.__ingredients = ingredients
From 38dbb2dec668c1a1b5f53fba76954af4a9b7ecae Mon Sep 17 00:00:00 2001
From: Eden Tabea Bernhard <105359952+EdenBernhard@users.noreply.github.com>
Date: Sun, 10 May 2026 15:54:25 +0200
Subject: [PATCH 19/37] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
project/backend/Routes.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 7f28ef7..317496a 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -28,8 +28,6 @@
updateAccount,
markResetTokenUsed,
updateKontoPassword,
- deleteAllRefreshTokens
-
)
from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
From b09dcbde75604c141c850d9edea1ca4fa0fad1f8 Mon Sep 17 00:00:00 2001
From: Eden Tabea Bernhard <105359952+EdenBernhard@users.noreply.github.com>
Date: Sun, 10 May 2026 15:55:11 +0200
Subject: [PATCH 20/37] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
project/backend/Routes.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 317496a..e58d1ee 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -3,7 +3,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail
-from pydantic import BaseModel as _BaseModel
from Auth import (
From 833b80e393d2ff493b0c3305c0264547923ff3a9 Mon Sep 17 00:00:00 2001
From: Eden Tabea Bernhard <105359952+EdenBernhard@users.noreply.github.com>
Date: Sun, 10 May 2026 15:55:52 +0200
Subject: [PATCH 21/37] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
project/backend/Auth.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/project/backend/Auth.py b/project/backend/Auth.py
index 1efa486..4d9600f 100644
--- a/project/backend/Auth.py
+++ b/project/backend/Auth.py
@@ -17,7 +17,7 @@
PASSWORD_RESET_EXPIRE_MINUTES = 30
-from Models import Token, User, ForgotPasswordRequest, ResetPasswordRequest
+from Models import Token, User
from Database import getAccountByEmail, saveRefreshToken, getRefreshToken
From 9f34b74be6f454da4ae3161d6886809e40416b42 Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Sun, 10 May 2026 15:58:09 +0200
Subject: [PATCH 22/37] Fix: Copilot review suggestion
---
project/backend/Database.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 173b898..2da1bb3 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -10,7 +10,7 @@ def getConnection() -> sqlite3.Connection:
con = sqlite3.connect(str(DB_PATH), check_same_thread=False)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
- #con.execute("PRAGMA journal_mode = WAL")
+ con.execute("PRAGMA journal_mode = WAL")
return con
From bb1a733e25a85a5ff3797c1b0166b4056103a3ca Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Sun, 10 May 2026 16:02:41 +0200
Subject: [PATCH 23/37] Fix: Copilot review suggestion
---
project/frontend/app/profile/page.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx
index 81a3a0a..a6be68f 100644
--- a/project/frontend/app/profile/page.tsx
+++ b/project/frontend/app/profile/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import {useEffect, 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";
@@ -17,7 +17,6 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
export default function Profile() {
const { user, loading, logout } = useAuth();
const router = useRouter();
- const menuRef = useRef(null);
const [showConfirm, setShowConfirm] = useState(false);
From 86d97a2f666cc756477f4d3b9ce1c67de3af9e75 Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Sun, 10 May 2026 16:08:28 +0200
Subject: [PATCH 24/37] Fix: Copilot review suggestion
---
project/backend/Routes.py | 15 +++++++++++++++
.../frontend/app/profile/changePasswordPopup.tsx | 16 +++++++++++++++-
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index e58d1ee..9c27bfd 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -121,6 +121,21 @@ async def updateCurrentUser(
):
Account = getAccountByEmail(currentUser.email)
+ hasEmailChange = bool(data.email)
+ hasAnyPasswordField = bool(data.currentPassword) or bool(data.newPassword)
+ hasCompletePasswordChange = bool(data.currentPassword) and bool(data.newPassword)
+
+ # Mindestens eine vollständige Änderung muss übergeben werden
+ if not hasEmailChange and not hasCompletePasswordChange:
+ raise HTTPException(status_code=400, detail="Keine Änderungen übergeben.")
+
+ # Halb ausgefüllte Passwort-Felder ablehnen statt still zu ignorieren
+ if hasAnyPasswordField and not hasCompletePasswordChange:
+ raise HTTPException(
+ status_code=400,
+ detail="Bitte aktuelles und neues Passwort angeben.",
+ )
+
# E-Mail ändern
if data.email:
error = validateEmail(data.email)
diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx
index 7105c6b..ac74c7e 100644
--- a/project/frontend/app/profile/changePasswordPopup.tsx
+++ b/project/frontend/app/profile/changePasswordPopup.tsx
@@ -70,7 +70,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
async function handlePasswordChange() {
setPasswordMsg("");
- // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern)
+ // 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;
From a495626ddc17a4a5b485c27755531993110621aa Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Sun, 10 May 2026 16:43:55 +0200
Subject: [PATCH 25/37] Fix: Copilot review suggestion
---
project/frontend/app/components/fields.tsx | 8 +++++---
.../frontend/app/homepage/forgotPassword.tsx | 17 ++++++++++++++++-
project/frontend/app/homepage/signin.tsx | 14 ++++++++++++--
.../app/profile/changePasswordPopup.tsx | 6 ++++--
project/frontend/lib/auth.tsx | 11 +++++++++--
5 files changed, 46 insertions(+), 10 deletions(-)
diff --git a/project/frontend/app/components/fields.tsx b/project/frontend/app/components/fields.tsx
index 52ad3a4..141e156 100644
--- a/project/frontend/app/components/fields.tsx
+++ b/project/frontend/app/components/fields.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useId, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import styles from "../homepage/page.module.css";
@@ -9,6 +9,7 @@ export default function Field({ label, type = "text", value, onChange, placehold
onBlur?: () => void;
state?: "default" | "error" | "success";
}) {
+ const inputId = useId();
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const effectiveType = isPassword && showPassword ? "text" : type;
@@ -20,9 +21,10 @@ export default function Field({ label, type = "text", value, onChange, placehold
return (
-
{label}
+
{label}
setShowPassword((s) => !s)}
aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
- tabIndex={-1}
+ aria-pressed={showPassword}
style={{
position: "absolute",
right: 8,
diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx
index 0186cac..cd2ec07 100644
--- a/project/frontend/app/homepage/forgotPassword.tsx
+++ b/project/frontend/app/homepage/forgotPassword.tsx
@@ -9,20 +9,34 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
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 {
- await fetch(`${API_URL}/auth/forgot-password`, {
+ 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);
}
@@ -50,6 +64,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum
Zurücksetzen.
+ {error &&
{error}
}
void;
placeholder: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
+ ariaLabel?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -19,6 +20,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
onChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -29,7 +31,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
- tabIndex={-1}
+ aria-pressed={show}
style={{
position: "absolute",
right: 10,
diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx
index 2d6c1ca..e7ed140 100644
--- a/project/frontend/lib/auth.tsx
+++ b/project/frontend/lib/auth.tsx
@@ -101,13 +101,20 @@ async function apiGetMe(accessToken: string): Promise {
return res.json();
}
+// Relativen Pfad ("/users/me") an API_URL anhängen, absolute URLs ("http://…") unverändert lassen
+function resolveApiUrl(url: string): string {
+ return /^https?:\/\//i.test(url) ? url : `${API_URL}${url.startsWith("/") ? "" : "/"}${url}`;
+}
+
// ── Authentifizierter Fetch mit automatischer Token-Erneuerung ──
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise {
const accessToken = getAccessToken();
if (!accessToken) throw new Error("Nicht eingeloggt");
+ const target = resolveApiUrl(url);
+
// Erster Versuch mit aktuellem Access Token
- let res = await fetch(url, {
+ let res = await fetch(target, {
...options,
headers: {
...options.headers,
@@ -129,7 +136,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise
Date: Sun, 10 May 2026 16:56:59 +0200
Subject: [PATCH 26/37] Feat: forgot password functionality (#151)
* Feature: Forgot Password link in signin modal. Popup for Email Input. Konto deletion reactivation.
* Feature: Email and Password change popup width changed
* Feature: Fix problem with refresh token and include show Password
* Feature: forgot password functionality works with redirection to homepage
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fix: Copilot review suggestion
* fix: make password visibility toggle keyboard focusable
Agent-Logs-Url: https://github.com/GalacticCodeGambit/LazyCook/sessions/bf26f4ce-caa3-43fa-b940-13d7be23d7bf
Co-authored-by: EdenBernhard <105359952+EdenBernhard@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
---
project/backend/Auth.py | 33 +++-
project/backend/Database.py | 98 ++++++++--
project/backend/EmailService.py | 41 +++-
project/backend/LazyCookAdministration.py | 6 +-
project/backend/Models.py | 13 ++
project/backend/Routes.py | 78 ++++++--
project/compose.yaml | 1 +
project/frontend/app/components/fields.tsx | 51 ++++-
.../frontend/app/{global.css => globals.css} | 2 +-
.../frontend/app/homepage/forgotPassword.tsx | 75 ++++++++
project/frontend/app/homepage/homepage.tsx | 9 +-
project/frontend/app/homepage/signin.tsx | 5 +-
project/frontend/app/layout.tsx | 16 +-
.../frontend/app/profile/changeEmailPopup.tsx | 57 ++++++
.../app/profile/changePasswordPopup.tsx | 155 +++++++++++++++
project/frontend/app/profile/page.tsx | 179 +++++-------------
project/frontend/app/recipeFinder/style.css | 4 +
project/frontend/app/reset-password/page.tsx | 124 ++++++++++++
project/frontend/lib/auth.tsx | 29 +--
19 files changed, 778 insertions(+), 198 deletions(-)
rename project/frontend/app/{global.css => globals.css} (99%)
create mode 100644 project/frontend/app/homepage/forgotPassword.tsx
create mode 100644 project/frontend/app/profile/changeEmailPopup.tsx
create mode 100644 project/frontend/app/profile/changePasswordPopup.tsx
create mode 100644 project/frontend/app/reset-password/page.tsx
diff --git a/project/backend/Auth.py b/project/backend/Auth.py
index bbe28e5..696fce6 100644
--- a/project/backend/Auth.py
+++ b/project/backend/Auth.py
@@ -11,7 +11,10 @@
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
import bcrypt
-from pydantic import BaseModel
+
+import hashlib
+
+PASSWORD_RESET_EXPIRE_MINUTES = 30
from Models import Token, User
@@ -132,3 +135,31 @@ 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
\ No newline at end of file
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 5d35f3a..2da1bb3 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -1,13 +1,8 @@
import sqlite3
from contextlib import contextmanager
from pathlib import Path
-import os
-# Verwende die Datenbank aus dem data-Ordner
-DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3"
-
-# Stelle sicher, dass das Verzeichnis existiert
-DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+DB_PATH = Path("/data/LazyCookDB.sqlite3")
def getConnection() -> sqlite3.Connection:
@@ -15,7 +10,7 @@ def getConnection() -> sqlite3.Connection:
con = sqlite3.connect(str(DB_PATH), check_same_thread=False)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
- # con.execute("PRAGMA journal_mode = WAL")
+ con.execute("PRAGMA journal_mode = WAL")
return con
@@ -104,13 +99,23 @@ def initDB():
UNIQUE (AccountID, rid)
)
""")
+ 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")
# ── Account-Operationen ──────────────────────────────────────────
-
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:
@@ -142,7 +147,6 @@ def getAccountByEmail(email: str) -> dict | None:
# ── Refresh-Token-Operationen ──────────────────────────────────
-
def saveRefreshToken(AccountID: int, token: str, expiresAt: str) -> None:
"""Speichert einen neuen Refresh Token in der Datenbank."""
with getDB() as con:
@@ -191,22 +195,90 @@ def deleteAccount(email: str) -> bool:
cur.execute("DELETE FROM Account WHERE email = ?", (email,))
return cur.rowcount > 0
-
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))
+ 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),
+ (password_hash, konto_id)
)
-
def cleanupExpiredTokens() -> None:
"""Löscht alle abgelaufenen Refresh Tokens."""
with getDB() as con:
cur = con.cursor()
cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")
+
+
+
+# ── 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),
+ )
+
+
+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()
\ No newline at end of file
diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py
index 96d1a23..67506b5 100644
--- a/project/backend/EmailService.py
+++ b/project/backend/EmailService.py
@@ -6,7 +6,6 @@
gmailUser = os.environ.get("GMAIL_USER")
gmailPassword = os.environ.get("GMAIL_PASSWORD")
-
def sendPasswordChangedEmail(to_email: str, name: str) -> None:
try:
msg = MIMEMultipart("alternative")
@@ -32,3 +31,43 @@ 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
\ No newline at end of file
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..492d829 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,15 @@ 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
\ No newline at end of file
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 1850bc8..924071d 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.security import OAuth2PasswordRequestForm
-from EmailService import sendPasswordChangedEmail
-from pydantic import BaseModel as _BaseModel
+from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail
from Auth import (
@@ -13,25 +13,31 @@
validatePassword,
validateRefreshToken,
verifyPassword,
- createAccessToken,
+ createPasswordResetToken,
+ validatePasswordResetToken,
)
from Database import (
createAccount,
getAccountByEmail,
+ getAccountById,
deleteRefreshToken,
deleteAllRefreshTokens,
deleteAccount,
updateAccount,
+ markResetTokenUsed,
+ updateKontoPassword,
)
-from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest
+from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
+
+# 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()
# ── Auth-Endpunkte ─────────────────────────────────────────────
-
@router.post("/auth/register", response_model=User)
async def register(user: UserCreate):
if not user.name.strip():
@@ -79,7 +85,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)
@@ -92,7 +98,6 @@ async def logout(body: LogoutRequest):
# ── Geschützte Endpunkte ───────────────────────────────────────
-
@router.get("/users/me", response_model=User)
async def readCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser)]):
"""Nur mit gültigem Access Token erreichbar."""
@@ -107,15 +112,11 @@ 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)]
+ data: UpdateUser,
+ currentUser: Annotated[User, Depends(getCurrentUser)]
):
Account = getAccountByEmail(currentUser.email)
@@ -129,7 +130,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 +147,52 @@ 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."}
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 (
{label}
-
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 && (
+ setShowPassword((s) => !s)}
+ aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
+ style={{
+ position: "absolute",
+ right: 8,
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 4,
+ display: "flex",
+ alignItems: "center",
+ color: "#666",
+ }}
+ >
+ {showPassword ? : }
+
+ )}
+
);
-}
\ 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..0186cac
--- /dev/null
+++ b/project/frontend/app/homepage/forgotPassword.tsx
@@ -0,0 +1,75 @@
+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 emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+
+ const handleSubmit = async () => {
+ setEmailBlurred(true);
+ if (!emailValid) return;
+ setBusy(true);
+ try {
+ await fetch(`${API_URL}/auth/forgot-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+ setSubmitted(true);
+ } 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.
+
+
+ Schließen
+
+
+ );
+ }
+
+ return (
+
+
Passwort vergessen
+
+ Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum
+ Zurücksetzen.
+
+
setEmail(v)}
+ placeholder="Email"
+ onBlur={() => setEmailBlurred(true)}
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
+ state={
+ emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default"
+ }
+ />
+
+ {busy ? "Wird gesendet…" : "Link anfordern"}
+
+
+
+ Zurück zur Anmeldung
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx
index 5c04f67..6ad3795 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), []);
@@ -191,12 +192,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..04df94a 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("");
@@ -47,6 +47,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"} />
+
+ Passwort vergessen?
+
{busy ? "Wird geladen…" : "Anmelden"}
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}
}
+
+
+ Speichern
+
+
+
+ );
+}
\ 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..7105c6b
--- /dev/null
+++ b/project/frontend/app/profile/changePasswordPopup.tsx
@@ -0,0 +1,155 @@
+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 }: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder: string;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
+}) {
+ const [show, setShow] = useState(false);
+ return (
+
+ onChange(e.target.value)}
+ onKeyDown={onKeyDown}
+ className="popup__input"
+ style={{ width: "100%", paddingRight: 38 }}
+ />
+ setShow((s) => !s)}
+ aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
+ tabIndex={-1}
+ style={{
+ position: "absolute",
+ right: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ color: "#666",
+ }}
+ >
+ {show ? : }
+
+
+ );
+}
+
+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("");
+
+ // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern)
+ 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}
}
+
+
+
+ {isForgot ? "Zurücksetzen" : "Speichern"}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx
index 1f5a269..d4ac84f 100644
--- a/project/frontend/app/profile/page.tsx
+++ b/project/frontend/app/profile/page.tsx
@@ -1,76 +1,45 @@
"use client";
-import { useRef, useState} from "react";
+import {useEffect, useRef, 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 menuRef = useRef(null);
+
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 (
+
+ );
}
+ 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 +80,13 @@ export default function Profile() {
Einstellungen
{ setShowEmailModal(true); setEmailMsg(""); }}
+ onClick={() => { setShowEmailModal(true) }}
className="bg-black text-white hover:bg-gray-800 text-sm w-full"
>
E-Mail ändern
{ setShowPasswordModal(true); setPasswordMsg(""); }}
+ onClick={() => { setShowPasswordModal(true); }}
className="bg-black text-white hover:bg-gray-800 text-sm w-full"
>
Passwort ändern
@@ -132,86 +101,32 @@ export default function Profile() {
- {/* 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}
}
-
- setShowEmailModal(false)}
- >
- Abbrechen
-
-
- Speichern
-
-
-
-
- )}
+ setShowConfirm(false)}>
+
+
+ Konto wirklich löschen?
+
+
+
+ Bestätigen
+
+ setShowConfirm(false)}>
+ Abbrechen
+
+
- {/* 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}
}
-
- setShowPasswordModal(false)}
- >
- Abbrechen
-
-
- Speichern
-
-
-
- )}
+
+
+
setShowEmailModal(false)}>
+
+
+
+
setShowPasswordModal(false)}>
+
+
);
}
diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css
index fdc8996..f32da98 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;
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"}
+ />
+
+ {busy ? "Wird gespeichert…" : "Passwort zurücksetzen"}
+
+
+ );
+}
+
+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..2d6c1ca 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);
}
@@ -118,23 +120,22 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise
Date: Mon, 11 May 2026 07:34:28 +0200
Subject: [PATCH 27/37] Feat: show top 5 incredients as options (#152)
* Feature: Forgot Password link in signin modal. Popup for Email Input. Konto deletion reactivation.
* Feature: Email and Password change popup width changed
* Feature: Fix problem with refresh token and include show Password
* Feature: forgot password functionality works with redirection to homepage
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fix: Copilot review suggestion
* Fix: Copilot review suggestion
* Fix: Copilot review suggestion
* Fix: Copilot review suggestion
* Feat: Ingredients suggestions. Top 5 most typed shown in popup
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Feat: Ingredients suggestions. Top 5 most typed shown in popup
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
project/backend/Database.py | 65 +++++++++++++++-
project/backend/Models.py | 13 +++-
project/backend/Routes.py | 65 +++++++++++++++-
.../frontend/app/homepage/forgotPassword.tsx | 17 +++-
project/frontend/app/homepage/signin.tsx | 14 +++-
.../app/profile/changePasswordPopup.tsx | 22 +++++-
project/frontend/app/profile/page.tsx | 5 +-
project/frontend/app/recipeFinder/page.tsx | 77 ++++++++++++++++++-
project/frontend/app/recipeFinder/popup.tsx | 52 ++++++++++++-
project/frontend/app/recipeFinder/style.css | 56 ++++++++++++++
project/frontend/lib/auth.tsx | 3 +-
11 files changed, 369 insertions(+), 20 deletions(-)
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 2da1bb3..a20ad40 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -9,8 +9,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 foreign_keys = ON")
con.execute("PRAGMA journal_mode = WAL")
+ con.execute("PRAGMA foreign_keys = ON")
return con
@@ -99,6 +99,19 @@ 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,
@@ -269,6 +282,56 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
)
+# ── 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()
diff --git a/project/backend/Models.py b/project/backend/Models.py
index 492d829..12d4943 100644
--- a/project/backend/Models.py
+++ b/project/backend/Models.py
@@ -36,4 +36,15 @@ class ResetPasswordRequest(BaseModel):
class UpdateUser(BaseModel):
email: str | None = None
currentPassword: str | None = None
- newPassword: str | None = None
\ No newline at end of file
+ newPassword: str | None = None
+
+
+class IngredientSearch(BaseModel):
+ name: str
+ amount: float
+ unit: str
+
+
+class RecipeSearchRequest(BaseModel):
+ zutaten: list[IngredientSearch]
+ servings: int
\ No newline at end of file
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index 924071d..e2107e7 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -1,6 +1,6 @@
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, sendPasswordResetEmail
@@ -26,9 +26,11 @@
updateAccount,
markResetTokenUsed,
updateKontoPassword,
+ incrementIngredientUsage,
+ getTopIngredients,
)
-from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
+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")
@@ -196,3 +198,62 @@ async def resetPassword(body: ResetPasswordRequest):
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/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx
index 0186cac..cd2ec07 100644
--- a/project/frontend/app/homepage/forgotPassword.tsx
+++ b/project/frontend/app/homepage/forgotPassword.tsx
@@ -9,20 +9,34 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
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 {
- await fetch(`${API_URL}/auth/forgot-password`, {
+ 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);
}
@@ -50,6 +64,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum
Zurücksetzen.
+ {error && {error}
}
void;
placeholder: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
+ ariaLabel?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -19,6 +20,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
onChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -29,7 +31,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
- tabIndex={-1}
+ aria-pressed={show}
style={{
position: "absolute",
right: 10,
@@ -70,7 +72,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
async function handlePasswordChange() {
setPasswordMsg("");
- // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern)
+ // 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;
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx
index d4ac84f..a6be68f 100644
--- a/project/frontend/app/profile/page.tsx
+++ b/project/frontend/app/profile/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import {useEffect, 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";
@@ -17,7 +17,6 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
export default function Profile() {
const { user, loading, logout } = useAuth();
const router = useRouter();
- const menuRef = useRef(null);
const [showConfirm, setShowConfirm] = useState(false);
@@ -94,7 +93,7 @@ export default function Profile() {
setShowConfirm(true)}
+ onClick={() => setShowConfirm (true)}
>
Konto löschen
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
-
setModalOpen(true)} className="finder-sidebar__add-btn">
+ {
+ loadTopIngredients().then(result => {
+ if (result) setSuggestions(result);
+ });
+ }}
+ onClick={() => setModalOpen(true)}
+ className="finder-sidebar__add-btn"
+ >
Zutat hinzufügen
@@ -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 (
+ !added && handleSuggestionClick(s)}
+ className={`popup__suggestion-badge${added ? " popup__suggestion-badge--added" : ""}`}
+ title={added ? "Bereits hinzugefügt" : (s.unit ? `${s.name} (${s.unit})` : s.name)}
+ >
+ {s.name}
+
+ );
+ })}
+
+
+ )}
+
{inputError && {inputError}
}
@@ -78,4 +124,4 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
);
-}
\ No newline at end of file
+}
diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css
index f32da98..63041c9 100644
--- a/project/frontend/app/recipeFinder/style.css
+++ b/project/frontend/app/recipeFinder/style.css
@@ -147,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/lib/auth.tsx b/project/frontend/lib/auth.tsx
index 2d6c1ca..64c79d9 100644
--- a/project/frontend/lib/auth.tsx
+++ b/project/frontend/lib/auth.tsx
@@ -49,6 +49,7 @@ function clearTokens() {
sessionStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("ingredients");
+ localStorage.removeItem("ingredientSuggestions");
}
// ── API-Aufrufe ───────────────────────────────────────────────
@@ -107,7 +108,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise
Date: Mon, 11 May 2026 13:33:16 +0200
Subject: [PATCH 28/37] Black used
---
project/backend/Auth.py | 10 ++++--
project/backend/Database.py | 57 +++++++++++++++++++++++-----------
project/backend/Ingredient.py | 9 +++---
project/backend/Models.py | 5 ++-
project/backend/Recipe.py | 7 ++---
project/backend/RecipeSucuk.py | 45 ++++++++++++++++-----------
project/backend/Routes.py | 36 ++++++++++++---------
7 files changed, 108 insertions(+), 61 deletions(-)
diff --git a/project/backend/Auth.py b/project/backend/Auth.py
index 696fce6..2447716 100644
--- a/project/backend/Auth.py
+++ b/project/backend/Auth.py
@@ -136,8 +136,10 @@ async def getCurrentUser(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
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()
@@ -146,14 +148,18 @@ def hashResetToken(token: str) -> str:
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)
+ 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
@@ -162,4 +168,4 @@ def validatePasswordResetToken(token: str) -> dict | None:
expiresAt = expiresAt.replace(tzinfo=timezone.utc)
if expiresAt < datetime.now(timezone.utc):
return None
- return entry
\ No newline at end of file
+ return entry
diff --git a/project/backend/Database.py b/project/backend/Database.py
index 9ad9c44..580ad41 100644
--- a/project/backend/Database.py
+++ b/project/backend/Database.py
@@ -3,7 +3,6 @@
from pathlib import Path
import os
-
# Verwende die Datenbank aus dem data-Ordner
DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3"
@@ -236,9 +235,10 @@ 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:
+ with getDB() as con:
cur = con.cursor()
cur.execute(
"INSERT INTO Recipe (name, description) VALUES (?, ?)",
@@ -246,6 +246,7 @@ def addRecipe(name: str, description: str, vid: int) -> int:
)
return cur.lastrowid
+
def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int:
with getDB() as con:
cur = con.cursor()
@@ -253,7 +254,8 @@ def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int:
"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()
@@ -262,31 +264,40 @@ def addIngredient(name: str, amountType: str) -> int:
(name, amountType),
)
return cur.lastrowid
-
+
+
def getIngridientByName(name: str):
- with getDB() as con:
+ with getDB() as con:
cur = con.cursor()
- cur.execute("""
+ cur.execute(
+ """
SELECT id, amountType
FROM Recipe
WHERE id = ?
- """, (name))
+ """,
+ (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("""
+ cur.execute(
+ """
SELECT name, description
FROM Recipe
WHERE id = ?
- """, (recipeID))
+ """,
+ (recipeID),
+ )
row = cur.fetchone()
return dict(row) if row else None
-def getAllRecipes()-> list[dict]:
+
+def getAllRecipes() -> list[dict]:
with getDB() as con:
cur = con.cursor()
cur.execute("""
@@ -295,10 +306,13 @@ def getAllRecipes()-> list[dict]:
""")
rows = cur.fetchall()
return [dict(row) for row in rows]
+
+
def getAllIngredientsForRecipe(rid: int):
with getDB() as con:
cur = con.cursor()
- cur.execute("""
+ cur.execute(
+ """
SELECT
Ingredient.name,
Exists_from.amount,
@@ -307,20 +321,26 @@ def getAllIngredientsForRecipe(rid: int):
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!
+ """,
+ (rid,),
+ ) # Note the comma here!
rows = cur.fetchall()
return [dict(row) for row in rows]
-def getAllocatedRecipes(name: str)-> list[dict]:
+
+def getAllocatedRecipes(name: str) -> list[dict]:
with getDB() as con:
cur = con.cursor()
- cur.execute("""
+ cur.execute(
+ """
SELECT rid
FROM Exists_from
Inner Join Ingredient on rid=id,
Where Exists_from.id = ?
- """, (name,))
+ """,
+ (name,),
+ )
rows = cur.fetchall()
return [dict(row) for row in rows]
@@ -420,9 +440,9 @@ def getAllocatedRecipes(name: str)-> list[dict]:
"""
-
# ── 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:
@@ -474,6 +494,7 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
# ── 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.
@@ -534,4 +555,4 @@ def getAccountById(konto_id: int) -> dict | None:
row = cur.fetchone()
return dict(row) if row else None
finally:
- con.close()
\ No newline at end of file
+ con.close()
diff --git a/project/backend/Ingredient.py b/project/backend/Ingredient.py
index ee9228d..154caba 100644
--- a/project/backend/Ingredient.py
+++ b/project/backend/Ingredient.py
@@ -1,11 +1,12 @@
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
@@ -17,13 +18,13 @@ def getAmount(self) -> float:
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
diff --git a/project/backend/Models.py b/project/backend/Models.py
index 12d4943..462b9db 100644
--- a/project/backend/Models.py
+++ b/project/backend/Models.py
@@ -26,13 +26,16 @@ 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
@@ -47,4 +50,4 @@ class IngredientSearch(BaseModel):
class RecipeSearchRequest(BaseModel):
zutaten: list[IngredientSearch]
- servings: int
\ No newline at end of file
+ servings: int
diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py
index 51925e2..b9ce137 100644
--- a/project/backend/Recipe.py
+++ b/project/backend/Recipe.py
@@ -1,6 +1,7 @@
-
from Ingredient import Ingredient
from Database import addIngredientToRecipe, addRecipe, getIngridientByName
+
+
class Recipe:
def __init__(self, name: str, ingredients: list[Ingredient], description: str):
self.__name = name
@@ -11,7 +12,6 @@ def __init__(self, name: str, ingredients: list[Ingredient], description: str):
self.__rating = 0.0
self.__countPersons = 1
self.__matching = 0
-
def saveInDB(self) -> bool:
rid = addRecipe(self.__name, self.__description, None)
@@ -51,7 +51,7 @@ def getMatching(self) -> int:
return self.__matching
def incrementMatching(self):
- self.__matching+=1
+ self.__matching += 1
def getDuration(self) -> str:
return self.__duration
@@ -64,4 +64,3 @@ def getingredients(self) -> list[Ingredient]:
def setIngredient(self, ingredients: list[Ingredient]):
self.__ingredients = ingredients
-
diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py
index 64aafc3..56834e3 100644
--- a/project/backend/RecipeSucuk.py
+++ b/project/backend/RecipeSucuk.py
@@ -1,16 +1,17 @@
import Database
from Ingredient import Ingredient
from Recipe import Recipe
-from Database import getAllRecipes, getAllIngredientsForRecipe
+from Database import getAllRecipes, getAllIngredientsForRecipe
-def findRecipes(ingriedents: list[Ingredient])-> list[Recipe]:
+
+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)
@@ -18,37 +19,45 @@ def findRecipes(ingriedents: list[Ingredient])-> list[Recipe]:
break
return recipes
-def __filterRecipes(recipes: list[Recipe], Ingredient: Ingredient)-> list[Recipe]:
+
+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()))
+ recipe.setRating(recipe.getMatching() / len(recipe.getIngredients()))
return recipes
-def __initRecipes()-> list[Recipe]:
+
+def __initRecipes() -> list[Recipe]:
recipesRaw = getAllRecipes()
recipes = []
for recipeRaw in recipesRaw:
- recipe = Recipe(recipeRaw["name"], __formatIngredients(recipeRaw["id"]), recipeRaw["description"])
+ recipe = Recipe(
+ recipeRaw["name"],
+ __formatIngredients(recipeRaw["id"]),
+ recipeRaw["description"],
+ )
recipes.append(recipe)
return recipes
-def __formatIngredients(id: int)-> list[Ingredient]:
+
+def __formatIngredients(id: int) -> list[Ingredient]:
IngredientsRaw = getAllIngredientsForRecipe(id)
Ingredients = []
for IngredientRaw in IngredientsRaw:
- Ingredients.append(Ingredient(IngredientRaw["name"],IngredientRaw["amount"]))
+ 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 ))
+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()))
\ No newline at end of file
+ print(i.getName() + " " + str(i.getRating()))
diff --git a/project/backend/Routes.py b/project/backend/Routes.py
index b02bd65..868108d 100644
--- a/project/backend/Routes.py
+++ b/project/backend/Routes.py
@@ -30,7 +30,17 @@
getTopIngredients,
)
-from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser, RecipeSearchRequest
+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")
@@ -116,8 +126,6 @@ async def deleteCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser)
# ── Account aktualisieren ────────────────────────────────────────
-
-
@router.patch("/users/me")
async def updateCurrentUser(
data: UpdateUser, currentUser: Annotated[User, Depends(getCurrentUser)]
@@ -152,8 +160,10 @@ async def updateCurrentUser(
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."""
@@ -204,10 +214,11 @@ async def resetPassword(body: ResetPasswordRequest):
# ── Recipe-Suche ───────────────────────────────────────────────
+
@router.post("/recipes/search")
async def searchRecipes(
- body: RecipeSearchRequest,
- currentUser: Annotated[User, Depends(getCurrentUser)],
+ body: RecipeSearchRequest,
+ currentUser: Annotated[User, Depends(getCurrentUser)],
):
"""Sucht Rezepte basierend auf den übergebenen Zutaten.
@@ -227,8 +238,7 @@ async def searchRecipes(
# 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
+ {"name": r["displayName"], "unit": r["lastUnit"]} for r in topRows
]
# TODO: eigentliche Rezept-Suche implementieren
@@ -237,11 +247,12 @@ async def searchRecipes(
# ── Ingredient-Vorschläge ──────────────────────────────────────
+
@router.get("/ingredients/top")
async def getTopIngredientsForUser(
- response: Response,
- currentUser: Annotated[User, Depends(getCurrentUser)],
- limit: int = 5,
+ 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)
@@ -254,8 +265,5 @@ async def getTopIngredientsForUser(
rows = getTopIngredients(Account["id"], limit=limit)
return {
- "ingredients": [
- {"name": r["displayName"], "unit": r["lastUnit"]}
- for r in rows
- ]
+ "ingredients": [{"name": r["displayName"], "unit": r["lastUnit"]} for r in rows]
}
From 3c02e9f97ac379ddee9d341c56c81f06030ec537 Mon Sep 17 00:00:00 2001
From: Eden Bernhard
Date: Mon, 11 May 2026 13:42:41 +0200
Subject: [PATCH 29/37] Info: Standort von Impressum und Datenschutz
---
project/frontend/app/homepage/homepage.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx
index 6ad3795..c00423a 100644
--- a/project/frontend/app/homepage/homepage.tsx
+++ b/project/frontend/app/homepage/homepage.tsx
@@ -27,6 +27,7 @@ export default function Homepage() {
+ // TODO Datenschutz & Impressum
Datenschutz
Impressum
From a7ceb96941a22a4b774d39548e942594424008c5 Mon Sep 17 00:00:00 2001
From: F <150372421+GalacticCodeGambit@users.noreply.github.com>
Date: Tue, 12 May 2026 16:59:55 +0200
Subject: [PATCH 30/37] Added Review-Protokoll-01
---
docs/Review-Protokoll-01.md | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
create mode 100644 docs/Review-Protokoll-01.md
diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md
new file mode 100644
index 0000000..fe41c5c
--- /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: Samuel
+- Zeitwächter: Niclas
+- Notizen: Frederik
+- Teilnehmer: Eden, Alexander
+
+### **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)
From 42e06bb9fc07a69facf02f12b6bbd7089264068e Mon Sep 17 00:00:00 2001
From: F <150372421+GalacticCodeGambit@users.noreply.github.com>
Date: Tue, 12 May 2026 17:01:20 +0200
Subject: [PATCH 31/37] Clear participant roles in review protocol
Removed participant names from the review protocol.
---
docs/Review-Protokoll-01.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md
index fe41c5c..741f878 100644
--- a/docs/Review-Protokoll-01.md
+++ b/docs/Review-Protokoll-01.md
@@ -3,10 +3,10 @@
12.05.2026 16.:00 bis 16:20
### **Teilnehmende**:
-- Moderator: Samuel
-- Zeitwächter: Niclas
-- Notizen: Frederik
-- Teilnehmer: Eden, Alexander
+- Moderator:
+- Zeitwächter:
+- Notizen:
+- Teilnehmer:
### **Ziel**/**Schwerpunkt** des Reviews:
Schwerpunkt: Team übergreifenden Verständnis des Algorithmus Rezept Suche verbessern.
From e27f003e8ab661a3d8dc5f146e09062661e1b0c5 Mon Sep 17 00:00:00 2001
From: F <150372421+GalacticCodeGambit@users.noreply.github.com>
Date: Tue, 12 May 2026 17:03:10 +0200
Subject: [PATCH 32/37] Update participants and review focus in documentation
---
docs/Review-Protokoll-01.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md
index 741f878..5ce09da 100644
--- a/docs/Review-Protokoll-01.md
+++ b/docs/Review-Protokoll-01.md
@@ -3,10 +3,10 @@
12.05.2026 16.:00 bis 16:20
### **Teilnehmende**:
-- Moderator:
-- Zeitwächter:
-- Notizen:
-- Teilnehmer:
+- Moderator: PrussianBaron
+- Zeitwächter: Nicoolaus
+- Notizen: GalacticCodeGambit
+- Teilnehmer: EdenBernhard, Hellocrafting
### **Ziel**/**Schwerpunkt** des Reviews:
Schwerpunkt: Team übergreifenden Verständnis des Algorithmus Rezept Suche verbessern.
From 3792403483b39b8988c7434554f18f959f203659 Mon Sep 17 00:00:00 2001
From: Samuel Goebel
Date: Mon, 18 May 2026 12:40:40 +0200
Subject: [PATCH 33/37] Made the required RecipeSUCUK changes
---
project/backend/RecipeSucuk.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py
index 56834e3..b41c3e6 100644
--- a/project/backend/RecipeSucuk.py
+++ b/project/backend/RecipeSucuk.py
@@ -1,3 +1,4 @@
+# SUCUK = Search for Uncomplicated Cooking and User-friendly Kitchen recipes
import Database
from Ingredient import Ingredient
from Recipe import Recipe
@@ -12,11 +13,9 @@ def findRecipes(ingriedents: list[Ingredient]) -> list[Recipe]:
recipes.sort(key=lambda x: x.getRating(), reverse=True)
- for recipe in recipes[::-1]:
- if recipe.getRating() < 0.3:
- recipes.remove(recipe)
- else:
- break
+ if len(recipes) > 98:
+ return recipes[:98]
+
return recipes
From 5db1e8d89c81bdc6e4287083c83a76e07450adfd Mon Sep 17 00:00:00 2001
From: PrussianBaron
Date: Mon, 18 May 2026 12:45:29 +0200
Subject: [PATCH 34/37] Rename RecipeSucuk.py to RecipeSUCUK.py
---
project/backend/{RecipeSucuk.py => RecipeSUCUK.py} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename project/backend/{RecipeSucuk.py => RecipeSUCUK.py} (100%)
diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSUCUK.py
similarity index 100%
rename from project/backend/RecipeSucuk.py
rename to project/backend/RecipeSUCUK.py
From 74f6e207716e6cc3a61051d0c5ce41ef33e4d6f5 Mon Sep 17 00:00:00 2001
From: Samuel Goebel
Date: Mon, 18 May 2026 13:14:52 +0200
Subject: [PATCH 35/37] Added Search Algorithm for Recipe Name search
---
project/backend/SearchRecipeNames.py | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 project/backend/SearchRecipeNames.py
diff --git a/project/backend/SearchRecipeNames.py b/project/backend/SearchRecipeNames.py
new file mode 100644
index 0000000..b448372
--- /dev/null
+++ b/project/backend/SearchRecipeNames.py
@@ -0,0 +1,10 @@
+from Database import getAllRecipes
+
+def getMatchingRecipeNames(searchTerm: str) -> list[str]:
+ """Gibt eine Liste von Rezeptnamen zurück, die den Suchbegriff enthalten."""
+ searchTerm = searchTerm.lower()
+ matchingRecipes = []
+ for recipe in getAllRecipes():
+ if searchTerm in recipe["name"].lower():
+ matchingRecipes.append(recipe["name"])
+ return matchingRecipes
\ No newline at end of file
From 4f256137e89bda25c689f07ec5a4c629415a107a Mon Sep 17 00:00:00 2001
From: Samuel Goebel
Date: Mon, 18 May 2026 14:45:42 +0200
Subject: [PATCH 36/37] SearchRecipeNames now returns a list of recipe Objects
---
project/backend/SearchRecipeNames.py | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/project/backend/SearchRecipeNames.py b/project/backend/SearchRecipeNames.py
index b448372..1b51649 100644
--- a/project/backend/SearchRecipeNames.py
+++ b/project/backend/SearchRecipeNames.py
@@ -1,10 +1,19 @@
-from Database import getAllRecipes
+from Database import getAllRecipes, getAllIngredientsForRecipe
+from Ingredient import Ingredient
+from Recipe import Recipe
-def getMatchingRecipeNames(searchTerm: str) -> list[str]:
- """Gibt eine Liste von Rezeptnamen zurück, die den Suchbegriff enthalten."""
+def getMatchingRecipeNames(searchTerm: str) -> list[Recipe]:
+ """Gibt eine Liste von Rezepten zurück, die den Suchbegriff enthalten."""
searchTerm = searchTerm.lower()
matchingRecipes = []
for recipe in getAllRecipes():
if searchTerm in recipe["name"].lower():
- matchingRecipes.append(recipe["name"])
- return matchingRecipes
\ No newline at end of file
+ matchingRecipes.append(Recipe(recipe["name"], __formatIngredients(recipe["id"]), recipe["description"]))
+ return matchingRecipes
+
+def __formatIngredients(id: int) -> list[Ingredient]:
+ IngredientsRaw = getAllIngredientsForRecipe(id)
+ Ingredients = []
+ for IngredientRaw in IngredientsRaw:
+ Ingredients.append(Ingredient(IngredientRaw["name"], IngredientRaw["amount"]))
+ return Ingredients
\ No newline at end of file
From f271026f0cf46fe4e59c2747cb0bbb2063fb17d6 Mon Sep 17 00:00:00 2001
From: Hellocrafting <75727565+Hellocrafting@users.noreply.github.com>
Date: Wed, 20 May 2026 13:16:24 +0200
Subject: [PATCH 37/37] =?UTF-8?q?Datenschutz=20und=20Impressum=20MD's=20hi?=
=?UTF-8?q?nzugef=C3=BCgt=20und=20calls=20angepasst.=20(#162)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Datenschutz und Impressum MD's hinzugefügt und calls angepasst.
* Datenschutz und Impressum MD's hinzugefügt und calls angepasst. (Kommentar war invalide)
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Update navigation comments for Datenschutz and Impressum links
* Fix Datenschutz and Impressum, Add Datenschutz and Impressum pages with navigation links
* Add MarkdownRenderer component for rendering Markdown content in Impressum and Datenschutz pages
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* refactor: extract shared markdown reader helper
Agent-Logs-Url: https://github.com/GalacticCodeGambit/LazyCook/sessions/9460d714-3139-45d5-b2e2-e6ee6e3d8563
Co-authored-by: GalacticCodeGambit <150372421+GalacticCodeGambit@users.noreply.github.com>
* Update Impressum.md
---------
Co-authored-by: F <150372421+GalacticCodeGambit@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
---
.../app/components/markdown-renderer.tsx | 57 ++++
project/frontend/app/datenschutz/page.tsx | 18 +
.../app/homepage/MarkdownFiles/Datenschutz.md | 307 ++++++++++++++++++
.../app/homepage/MarkdownFiles/Impressum.md | 25 ++
project/frontend/app/homepage/homepage.tsx | 7 +-
project/frontend/app/impressum/page.tsx | 18 +
project/frontend/app/lib/read-markdown.ts | 14 +
project/frontend/package.json | 2 +
8 files changed, 445 insertions(+), 3 deletions(-)
create mode 100644 project/frontend/app/components/markdown-renderer.tsx
create mode 100644 project/frontend/app/datenschutz/page.tsx
create mode 100644 project/frontend/app/homepage/MarkdownFiles/Datenschutz.md
create mode 100644 project/frontend/app/homepage/MarkdownFiles/Impressum.md
create mode 100644 project/frontend/app/impressum/page.tsx
create mode 100644 project/frontend/app/lib/read-markdown.ts
diff --git a/project/frontend/app/components/markdown-renderer.tsx b/project/frontend/app/components/markdown-renderer.tsx
new file mode 100644
index 0000000..e64acf9
--- /dev/null
+++ b/project/frontend/app/components/markdown-renderer.tsx
@@ -0,0 +1,57 @@
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import type { ComponentPropsWithoutRef } from "react";
+
+type MarkdownRendererProps = {
+ content: string;
+};
+
+const markdownComponents = {
+ h1: (props: ComponentPropsWithoutRef<"h1">) => (
+
+ ),
+ h2: (props: ComponentPropsWithoutRef<"h2">) => (
+
+ ),
+ h3: (props: ComponentPropsWithoutRef<"h3">) => (
+
+ ),
+ p: (props: ComponentPropsWithoutRef<"p">) => (
+
+ ),
+ ul: (props: ComponentPropsWithoutRef<"ul">) => (
+
+ ),
+ ol: (props: ComponentPropsWithoutRef<"ol">) => (
+
+ ),
+ li: (props: ComponentPropsWithoutRef<"li">) => (
+
+ ),
+ strong: (props: ComponentPropsWithoutRef<"strong">) => (
+
+ ),
+ a: (props: ComponentPropsWithoutRef<"a">) => (
+
+ ),
+ blockquote: (props: ComponentPropsWithoutRef<"blockquote">) => (
+
+ ),
+ hr: () => ,
+ code: (props: ComponentPropsWithoutRef<"code">) => (
+
+ ),
+};
+
+export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
+ return (
+
+
+
+ {content}
+
+
+
+ );
+}
+
diff --git a/project/frontend/app/datenschutz/page.tsx b/project/frontend/app/datenschutz/page.tsx
new file mode 100644
index 0000000..336ec32
--- /dev/null
+++ b/project/frontend/app/datenschutz/page.tsx
@@ -0,0 +1,18 @@
+import MarkdownRenderer from "@/app/components/markdown-renderer";
+import { readMarkdownFile } from "@/app/lib/read-markdown";
+
+export default function DatenschutzPage() {
+ const content = readMarkdownFile("Datenschutz.md");
+
+ return (
+
+
+
Datenschutzerklärung
+
+ Die vollständige Datenschutzerklärung wird hier aus der bestehenden Markdown-Datei angezeigt.
+
+
+
+
+ );
+}
diff --git a/project/frontend/app/homepage/MarkdownFiles/Datenschutz.md b/project/frontend/app/homepage/MarkdownFiles/Datenschutz.md
new file mode 100644
index 0000000..8f91f93
--- /dev/null
+++ b/project/frontend/app/homepage/MarkdownFiles/Datenschutz.md
@@ -0,0 +1,307 @@
+# Datenschutzerklärung für Kund:innen
+
+Sofern wir dich nicht anders informieren, erklärt diese Datenschutzerklärung („Erklärung"), wie die Lazycook GmbH als Verantwortliche („Lazycook", „wir", „uns" oder „unser") personenbezogene Daten erhebt, verwendet, offenlegt und anderweitig verarbeitet. Sie bezieht sich auf die Verarbeitung personenbezogener Daten von Kund:innen, die eine unserer Plattformen und Produkte nutzen, einschließlich dieser Bestellwebseite, dieser App, dieser Plattform und Produkte sowie anderer verfügbarer Dienste der Lazycook-Plattform, wie Firmenkunden und die ihnen zugewiesenen Mitarbeitenden und Nutzer:innen, die eines unserer Lazycook for Business-Produkte und Dienstleistungen für Unternehmen („Lazycook for Business") nutzen (zusammen die „Dienste"), um Bestellungen bei einem Restaurantpartner, Lebensmittelgeschäft, Supermarkt oder einem anderen Geschäftspartner („Partner") aufzugeben, der Produkte oder Dienstleistungen zur Lieferung über unsere Dienste anbietet.
+
+Wir können deine personenbezogenen Daten verwenden, um Lieferdienste für Bestellungen bereitzustellen, die auf der eigenen Website, App oder Bestellplattform des Partners aufgegeben wurden. In solchen Fällen verarbeiten wir personenbezogene Daten, die wir von diesen Partnern erhalten, gemäß deren Anweisungen als Auftragsverarbeiter. Wir empfehlen dir, die Datenschutzerklärung des Partners (oder ein entsprechendes Dokument) zu lesen.
+
+Bei Lazycook setzen wir uns dafür ein, die Privatsphäre aller in unserer Gemeinschaft zu schützen. Wenn du Fragen zur Art und Weise hast, wie wir deine personenbezogenen Daten speichern, kannst du uns über unser Datenschutzformular kontaktieren.
+
+---
+
+## Welche personenbezogenen Daten verarbeiten wir und warum?
+
+Personenbezogene Daten, in dieser Erklärung auch als „deine Daten" bezeichnet, bezeichnen jede Information oder jede Informationsmenge, aus der wir dich direkt oder indirekt persönlich identifizieren können, insbesondere anhand einer Kennung, z. B. Vor- und Nachname, E-Mail-Adresse, Telefonnummer usw. Sie umfassen keine Daten, bei denen die Identität entfernt wurde (anonyme Daten).
+
+Wann immer du über unsere Dienste mit Lazycook interagierst, erfassen und verarbeiten wir deine personenbezogenen Daten. Lazycook kann deine personenbezogenen Daten für folgende Zwecke verarbeiten:
+
+### 1. Kundenkonten
+
+Wenn du mit unseren Diensten interagierst, erhältst du die Möglichkeit, ein Konto bei uns zu erstellen, entweder über unsere Plattformen oder über einen Drittanbieter-Social-Login-Service (z. B. Apple, Google, Facebook Login oder andere Plattformen), in der die Datenschutzerklärung des betreffenden Drittanbieters zusätzlich zu dieser Erklärung für dich gilt. Wenn du in der Vergangenheit ein Gastkonto erstellt hast, werden diese Daten für die Rechnungsstellung und für Dokumentationszwecke gespeichert. Abhängig von den Umständen können wir uns auf deine Einwilligung oder die Tatsache beziehen, dass die Verarbeitung zur Erfüllung eines Vertrags mit dir oder zur Einhaltung von Gesetzen erforderlich ist. Du kannst deine Einwilligung widerrufen und/oder dein Konto schließen lassen, indem du uns kontaktierst. Für Zwecke der Kundenkonten können wir die folgenden personenbezogenen Daten verarbeiten:
+
+- Name
+- Adressdaten
+- Kontaktdaten
+- IP-Adresse
+- Social-Media-Kontoinformationen, zum Beispiel wann du dich mit Social-Media-Zugangsdaten bei Lazycook anmeldest. Wir empfehlen dir, die Datenschutzerklärung deines Social-Media-Anbieters zu lesen, um zu verstehen, wie deine personenbezogenen Daten verwendet werden.
+- Anmeldedaten (gilt für Lazycook for Business)
+
+### 2. Bestellprozess
+
+Wir verarbeiten die personenbezogenen Daten, die du uns zur Verfügung stellst, wenn du deine Bestellung aufgibst. Diese personenbezogenen Daten sind erforderlich, um deine Bestellung auszuführen, deine Bestellung zu bestätigen und deine Bestellung, Zahlung und eine mögliche Rückerstattung zu bewerten. Deine Informationen (z. B. die Artikel, die du deinem Warenkorb hinzufügst) ermöglichen es uns, deine Bestellung zu bearbeiten und dir eine genaue Rechnung zuzusenden. Die Teilnahme an einer Gruppenbestellung bedeutet, dass deine Transaktionsdaten, einschließlich Bestelldetails und Versandstatus, mit der Person geteilt werden, die die Gruppenbestellung initiiert hat. Es hilft uns und allen Partnern oder Fahrer:innen auch, dich bei Bedarf zu kontaktieren, um die Lieferung durchzuführen. Einige Partner auf unserer Plattform nutzen externe Lieferunternehmen, die deine Informationen verwenden können, um dir Statusaktualisierungen zur Lieferung deiner Bestellung zukommen zu lassen. Wir können deine Standortdaten verwenden, um dir eine Schätzung der Lieferungszeiten vor und nach dem Bestellvorgang zu geben. Abhängig von den Umständen können wir uns auf deine Einwilligung oder die Tatsache verlassen, dass die Verarbeitung zur Erfüllung eines Vertrags mit dir oder zur Einhaltung von Gesetzen erforderlich ist. Im Rahmen des Bestellprozesses können wir die folgenden personenbezogenen Daten verarbeiten:
+
+- Name
+- Adressdaten
+- Standortdaten (sofern deine Zustimmung für Standortdaten vorliegt)
+- Alter und Identitätsnachweis (sofern erforderlich)
+- Kontaktdaten, einschließlich E-Mail-Adresse, Telefonnummern
+- Bestellungs- und Transaktionsdaten
+- Essensvorlieben
+- Zahlungs- und Gutscheindaten
+- IP-Adresse
+- Gerätekennungen wie IP-Adresse, Session-Tracking, SDKs, eindeutige Geräte-ID
+- Identitäts- und/oder Altersverifikationsdaten
+
+Im Rahmen unserer Dienste können wir Produkte verkaufen, die unbeabsichtigt über unsere Partner personenbezogene Daten aus besonderen Kategorien preisgeben, wie gesundheitsbezogene Informationen (z. B. Allergien oder Ernährungsbedürfnisse), Informationen über deine Religion (z. B. zur Einhaltung deiner religiösen Ernährungseinschränkungen), Informationen zu deinem gesundheitlichen Zustand (z. B. Apothekenprodukte, Medikamente, medizinische Geräte, medikamentöse Cremes, Nahrungsergänzungsmittel oder pflanzliche/homöopathische Produkte) oder über dein Sexualleben. Wir erheben und verarbeiten Gesundheitsdaten wie Medikamente mit deiner Zustimmung im Rahmen deiner Bestellung.
+
+Wir auch beschränkte Produkten oder Dienste wie Alkohol, E-Zigaretten oder Tabak verkaufen und liefern. Zur Erfüllung unserer rechtlichen Verpflichtungen nach geltendem Recht müssen wir für den Verkauf und die Lieferung solcher Produkte möglicherweise Verifizierungsverfahren einsetzen, wie unter anderem zur Verifizierung deines Alters und deiner Identität, indem unsere Fahrer:innen deinen Identitätsnachweis überprüfen, ggf. automatisch per Scan deines Ausweises. Zu diesem Zweck benötigen wir möglicherweise als Beweis ein gültiges, staatlich ausgestelltes Ausweisdokument. Bitte beachte, dass Lazycook, falls du dich dem Verifizierungsprozess verweigerst, nicht verpflichtet ist, dir die Produkte oder Dienste bereitzustellen. Die rechtliche Grundlage für diese Verarbeitung personenbezogener Daten ist die Einhaltung der gesetzlichen Verpflichtung.
+
+### 3. Bewertungen von Partnern und Fahrer:innen
+
+Wenn es das geltende Recht erlaubt oder im Rahmen unseres berechtigten Interesses, unsere Produkte und Dienstleistungen zu verbessern, können wir dich nach deiner Bestellung bitten, eine Bewertung der Partner und/oder Fahrer:innen abzugeben. Das ist vollkommen freiwillig.
+
+Lazycook verarbeitet die personenbezogenen Daten, die du bei der Einreichung einer Bewertung angibst. Du kannst die Löschung der Bewertung beantragen, indem du uns kontaktierst. Wir können dich auch nach einer Bewertung kontaktieren, um deine Erfahrung zu besprechen. Wenn du die Bewertung eines Partners und/oder eines/einer Fahrer:in postest, können wir die folgenden personenbezogenen Daten verarbeiten:
+
+- Name
+- Kontaktdaten
+- Bewertung
+
+Wenn deine Bewertung veröffentlicht wird, können die Bewertung und dein Vorname Fahrer:innen, Partnern und/oder der Öffentlichkeit zur Verfügung gestellt werden.
+
+### 4. Kundenservice und Support
+
+Wenn du unseren Kundenservice kontaktierst, verwenden wir die von dir bereitgestellten personenbezogenen Daten, um deine Frage zu beantworten oder deine Beschwerde zu bearbeiten. Wir können personenbezogene Daten im Rahmen von Anrufaufzeichnungen erfassen, um den Kundenservice bereitzustellen; hierzu zählen auch die Untersuchung und Behebung von Benutzerbedenken und die Überwachung und Verbesserung unserer Kundenservice-Antworten und -Prozesse. Wir können auch Anrufaufnahmen im Rahmen des eingehenden oder ausgehenden Kontakts aufnehmen, den wir mit dir haben.
+
+Außerdem können wir deine personenbezogenen Daten erheben, wenn du uns eine Mitteilung übermittelst, um auf das Vorhandensein eines Artikels in unseren Diensten hinzuweisen, den du im Sinne der EU-Verordnung 2022/2065 des Europäischen Parlaments und des Rates („Gesetz über digitale Dienste") oder nach der Definition eines sonstigen geltenden Gesetzes als „rechtswidrigen Inhalt" betrachtest; und um dir Zugang zu einem internen System zur Beschwerdebearbeitung zu bieten und/oder um erforderliche Aussetzungs- und Schutzmaßnahmen gegen den Missbrauch unserer Dienste zu ergreifen.
+
+Je nach Umstand können wir uns auf deine Zustimmung oder auf die Tatsache berufen, dass die Verarbeitung zur Erfüllung unseres Vertrags mit dir, auf der Grundlage unseres berechtigten Interesses oder zur Einhaltung von Gesetzen erforderlich ist. Wir können die folgenden personenbezogenen Daten zum Zweck des Kundenservices verarbeiten:
+
+- Name
+- Adressdaten (sofern zutreffend)
+- Kontaktdaten
+- Bestellungs- und Transaktionsdaten
+- Zahlungsdaten (sofern zutreffend)
+- Kommentare (sofern zutreffend)
+- Inhalt der Kommunikation zwischen dir und der Kundenservice-Abteilung, zum Beispiel Anrufe, Chats, E-Mails oder Briefe
+- Inhalt der Kommunikation zwischen dir und Fahrer:innen über Chat-Funktionen
+- Alle weiteren Inhalte, die zur Einhaltung der geltenden Gesetze und zur Bearbeitung deiner Anfrage erforderlich sein könnten
+
+### 5. Prävention von Betrug und Fehlverhalten
+
+Wir verarbeiten personenbezogene Daten auch, um unsere Plattformen sicher zu halten und Fehlverhalten auf und über unseren Diensten zu verhindern, auch durch automatisierte Prozesse, um betrügerische Behauptungen zu erkennen, zu verhindern und abzuwehren. Wir können auch gängige Betrugsprüfungen oder nationale Datenbanken wie CIFAS nutzen, um unsere Betrugsprüfungen zu verbessern. Wir können Cookies und Technologien zur Betrugsprävention verwenden, um ungewöhnliches Verhalten, Kontoaktivitäten, Analysen von Verlaufsdaten, Missbrauch von Rückerstattungen, Rückbuchungen oder andere Aktivitäten im Zusammenhang mit Zahlungen und Gutscheinen zu überwachen und zu erkennen. Dazu gehören Tracking-Informationen, die von Nutzer:innen gesammelt oder von ihnen generiert wurden, wie Standortdaten, Zahlungsinformationen und wie sie Lazycook verwenden. Indem wir aktuelle Aktivitäten mit früheren Daten vergleichen, können wir ungewöhnliche Muster schnell erkennen und melden. Je nach Umstand stützen wir uns darauf, dass die Verarbeitung zur Erfüllung unseres Vertrags mit dir notwendig ist, um unsere Plattform und Dienste zu sichern, das geltende Recht einzuhalten oder unser berechtigtes Interesse zu wahren (oder ein gleichwertiges nach geltendem Datenschutzrecht). Bei Betrugsverdacht können wir dein Konto sperren oder die Bereitstellung von Produkten oder Diensten für dich verweigern. Wir können folgende personenbezogene Daten zum Zweck der Betrugsprävention und des Fehlverhaltens verarbeiten:
+
+- Name
+- Adressdaten
+- Kontaktdaten
+- Identitäts- und/oder Altersverifikationsdaten
+- Kontonutzung und Anmeldedaten
+- Bestellungs- und Transaktionsdaten
+- Zahlungs- und Gutscheindaten
+- Bewertungen
+- Kundenkommunikation
+- Geräteinformationen
+- IP-Adresse
+- Browserinformationen
+
+### 6. Treueprogramme (in den entsprechenden Märkten)
+
+Lazycook hat mehrere Treueprogramme, wie Lazycook+, um dir Angebote, Gutschriften, Gutscheine und Rabatte anzubieten. Wenn du eines unserer Treueprogramme nutzt, verarbeiten wir deine personenbezogenen Daten, um dir die im jeweiligen Treueprogramm festgelegten Rabatte oder Angebote bereitzustellen.
+
+In Fällen, in denen ein Teil des Angebots eines Treueprogramms Rabatte für Dritte beinhaltet oder du Lazycook-Rabatte über Dritte in Anspruch nimmst, muss Lazycook möglicherweise deine Daten weitergeben, wenn du dich für diesen Dienst angemeldet hast. Im Rahmen des Anmeldedienstes wirst du informiert, mit wem deine Daten geteilt werden. Um dir Cashback und Prämien zu bieten, die du entweder direkt bei Lazycook oder bei einem externen Treueprogramm-Partner, bei dem du dich direkt angemeldet hast und der Lazycook-Rabatte anbietet, in Anspruch genommen hast, erheben wir deine Transaktionsdaten sicher und geben sie an unsere vertrauenswürdigen Partner weiter. Eines unserer Treueprogramme ist der Treueshop, in dem du Angebote von externen Partnern anfordern kannst. Einige der Treueshop-Partner benötigen deine personenbezogenen Daten für Versandzwecke. Je nach den Umständen können wir uns darauf verlassen, dass die Verarbeitung zur Erfüllung eines Vertrags mit dir, in unserem berechtigten Interesse oder zur Einhaltung gesetzlicher Vorschriften erforderlich ist. Wir können die folgenden personenbezogenen Daten für den Treueshop und den Versand deiner Bestellung verarbeiten:
+
+- Name
+- Adressdaten
+- Nutzerkennungen von Lazycook oder anderen Prämienprogrammen, für die du dich angemeldet hast
+- Bestelldaten (sofern zutreffend)
+- Kontaktdaten
+
+### 7. Kundenforschung
+
+Um sicherzustellen, dass unsere Dienste deinen Präferenzen entsprechen, können deine personenbezogenen Daten für Zwecke wie die Durchführung von Produkt-, Benutzerforschungs- oder Zufriedenheitsumfragen verwendet werden. Wenn wir Marktforschung durchführen, zum Beispiel nach Kontakt mit unseren Betriebsteams, oder wenn wir dich um deine Meinung zu unseren Produkten oder Dienstleistungen bitten, ist die Teilnahme freiwillig und beeinflusst nicht, wie wir dein Konto verwalten. Wir senden dir diese Art von Nachrichten nur auf der Grundlage einer spezifischen und anwendbaren Rechtsgrundlage, d. h. entweder deiner Einwilligung oder unseres berechtigten Interesses. Wenn die Rechtsgrundlage der Einwilligung angewendet wird, holen wir immer deine vorherige Einwilligung ein. Du kannst deine Einwilligung für Produkt-, Benutzerforschungs- oder Zufriedenheitsumfragen jederzeit widerrufen oder dich direkt in den Nachrichten selbst abmelden. Die Teilnahme an Umfragen zur Kundenzufriedenheit erfolgt vollkommen freiwillig.
+
+Bitte beachte, dass wir in einigen Fällen deine personenbezogenen Daten für diese Zwecke auf der Grundlage unserer berechtigten Interessen verarbeiten. Dies geschieht auf der Grundlage unseres berechtigten Geschäftsinteresses im Rahmen der Erfassung von Informationen über die Erfahrungen der Kund:innen mit unseren Produkten und Dienstleistungen, um bestimmte Interessensgebiete und Wünsche zu ermitteln, mit dem übergeordneten Ziel, unsere Produkte und Dienstleistungen so zu entwickeln und anzubieten, dass sie den Bedürfnissen der Kund:innen entsprechen. Du hast das Recht, der Datenverarbeitung in unserem berechtigten Interesse zu widersprechen, indem du dich unter den unten angegebenen Kontaktdaten an uns wendest.
+
+Zu diesen Zwecken können wir die folgenden personenbezogenen Daten verarbeiten:
+
+- Name
+- Kontaktdaten
+- Bestellungs- und Transaktionsdaten
+- Forschungsbeiträge
+- Feedback und Bewertungen, die von dir gegeben wurden
+- Um dir Angebote, Gutscheine oder Prämien bereitzustellen, die dir möglicherweise angeboten wurden
+
+### 8. Marketing und Personalisierung
+
+Wir verarbeiten deine personenbezogenen Daten, um dir (personalisierte) Marketingmitteilungen und Benachrichtigungen zur Verwaltung, Unterstützung, Verbesserung und Entwicklung unserer Dienste zuzusenden. Es kann Fälle geben, in denen Lazycook deine personenbezogenen Daten für die Bewerbung von Lazycook-Produkten und -Diensten verarbeitet, um dein Einkaufserlebnis zu personalisieren und dir auf dich zugeschnittene Werbung zu zeigen. Wir stützen uns auf deine Einwilligung, es sei denn, deine Einwilligung ist nach geltendem Recht nicht erforderlich. Du wirst immer die Möglichkeit haben, dich vom Erhalt dieser direkten Mitteilungen abzumelden.
+
+Für Marketingzwecke können wir die folgenden personenbezogenen Daten verarbeiten:
+
+- Name
+- Adressdaten
+- Kontaktdaten
+- Bestellungs- und Transaktionsdaten
+- Zahlungsdaten
+- Treueprogramme
+- Bewertung(en)
+- Kampagnendaten
+- Geräte-ID
+- Cookie- und Technologiedaten
+
+Zu den Medienpartnern, mit denen wir zusammenarbeiten, gehören:
+
+- Alphabet/Google/YouTube
+- Meta/Facebook/Instagram
+- PayPal
+- Pinterest
+- SnapChat
+- Reddit
+
+### 9. Marketing, Kampagnen und Gewinnspiele
+
+Lazycook kann spezifische Kampagnen oder Gewinnspiele im Zusammenhang mit seinen Diensten starten. Die Teilnahme an Lazycook-Kampagnen oder -Gewinnspielen erfolgt stets auf freiwilliger Basis. Lazycook sendet dir Direktmarketing-Mitteilungen per E-Mail, als Benachrichtigungen, SMS oder über In-App-Messaging. Wir arbeiten möglicherweise mit Drittanbietern zusammen, um Gewinnspiele oder Kampagnen durchzuführen.
+
+Zu Kampagnenzwecken können wir die folgenden personenbezogenen Daten verarbeiten:
+
+- Name
+- Kontaktdaten
+- Bestell- und Transaktionsdaten (optional)
+- Kampagnendaten (optional)
+
+### 10. Werbung und Einzelhandelsmedien
+
+Wie die meisten Unternehmen nutzen wir Werbung, um Menschen über unsere Dienste und Produkte zu informieren. Wir nutzen Informationen über dich, um dir Werbung anzuzeigen, die dich interessieren könnte. Dazu gehören Daten wie dein Standort und dein Bestellverlauf, was uns hilft, die Werbeanzeigen relevanter zu gestalten.
+
+Wenn du unsere Werbeanzeigen siehst und mit ihnen interagierst, messen wir, wie effektiv sie sind. Wir verfolgen zum Beispiel, wie lange du auf unserer Webseite bleibst, nachdem du auf eine Anzeige geklickt hast, und welche Kampagnen zu neuen App-Installationen führen. Wenn du diesem Tracking nicht zugestimmt hast, siehst du zwar weiterhin Werbung, aber sie ist nicht auf dich zugeschnitten.
+
+### 11. Cookies und ähnliche Technologien
+
+Lazycook verwendet Cookies oder ähnliche Technologien für funktionale, analytische und personalisierte Zwecke sowie für maßgeschneiderte Werbung. Die für diese Zwecke verarbeiteten Daten hängen von den von dir festgelegten Präferenzen ab.
+
+Je nach Einstellung deiner Cookie-Einwilligung können wir deine personenbezogenen Daten zu Analysezwecken an unsere externen Marketingpartner weitergeben. Weitere Informationen darüber, wie wir Cookies und/oder ähnliche Technologien verwenden, findest du in unserer Cookie-Erklärung.
+
+### 12. Analyse und Produktverbesserung
+
+Lazycook nutzt deine personenbezogenen Daten, um zu verstehen, wie du mit unseren Plattformen, Produkten und Diensten interagierst, um deren Leistung zu verbessern, um neue Funktionen zu entwickeln und um unseren Berichtspflichten gegenüber Dritten nachkommen zu können. Wenn möglich, werden diese Analysen mit aggregierten Daten durchgeführt.
+
+### 13. Lazycook for Business
+
+Wir verarbeiten personenbezogene Daten, a) wenn du dein Lazycook for Business-Guthaben nutzt, um eine Bestellung zu bezahlen, b) wenn du Produkte und Dienste von Lazycook for Business nutzt oder mit diesen interagierst, c) um die Lazycook for Business-Karte auszustellen und zu versenden und/oder d) um dir dein digitales Guthaben bereitzustellen. In Zusammenhang mit Lazycook for Business können folgende personenbezogene Daten verarbeitet werden:
+
+- Name
+- Adressdaten
+- Beschäftigungsdaten (Jobrolle, Abteilung, vertragliche Arbeitszeiten)
+- Kontaktdaten
+- Daten zum Guthaben
+- Bestellungs- und Transaktionsdaten
+- Informationen der Lazycook for Business-Karte
+- Zahlungsdaten
+
+### 14. Lazycook-Lieferung als Dienst
+
+Restaurants, Supermärkte, Lebensmittelgeschäfte und andere Unternehmen, die Produkte zur Auslieferung anbieten („Partner"), können die Lazycook-Plattform und Logistikdienste nutzen, um Lieferdienste für Bestellungen anzubieten, die auf der eigenen Website, Anwendung oder Bestellplattform des Partners aufgegeben werden. In diesem Fall werden wir die von diesen Partnern erhaltenen Daten gemäß den Anweisungen des Partners verarbeiten.
+
+---
+
+## Wie wir deine personenbezogenen Daten erheben
+
+Wir erfassen, verarbeiten und speichern deine personenbezogenen Daten sowie personenbezogene Daten über die Geräte, die du nutzt, um zu Lazycook-Plattformen zu navigieren.
+
+- **Personenbezogene Daten, die du freiwillig zur Verfügung stellst:** Wir können personenbezogene Daten von dir erhalten, wenn du deine Bestellung aufgibst, ein Konto bei uns erstellst oder in Zusammenhang mit unseren Diensten deine Marketingpräferenzen oder Kundenbewertungen bereitstellst.
+- **Personenbezogene Daten, die wir automatisch erheben:** Mit deiner vorherigen Einwilligung oder wenn dies nach geltendem Recht zulässig ist, können wir automatisch technische Daten über deine Geräte, Browseraktivitäten und Muster erfassen, während du auf unseren Plattformen browst.
+- **Personenbezogene Daten, die wir von Drittanbietern erhalten:** Wir können personenbezogene Daten von externen Quellen erhalten, darunter Werbenetzwerke und Social-Media-Plattformen oder technische Zahlungs- und Lieferdienstanbieter.
+
+---
+
+## Alter
+
+Unsere Dienste sind nicht für Personen bestimmt, die nach den geltenden lokalen Gesetzen als minderjährig definiert sind, noch beabsichtigen wir, personenbezogene Daten von Besuchern zu erfassen, die das gesetzliche Mindestalter noch nicht erreicht haben. Wenn du der Meinung bist, dass wir ohne Einwilligung personenbezogene Daten eines Minderjährigen erhoben haben, kontaktiere uns bitte mithilfe unseres Datenschutzformulars.
+
+---
+
+## Nutzung automatisierter Entscheidungsfindungs- und algorithmischer Tools
+
+Lazycook kann automatisierte Entscheidungsfindungs- und algorithmische Tools zur Erfüllung unseres Vertrags mit dir, zur Erfüllung unserer Verpflichtungen oder zur Verbesserung unserer Plattformen und Dienste verwenden. Dies gilt für folgende Zwecke:
+
+- Lazycook verwendet algorithmische Tools bei der Verarbeitung deiner Adressdaten und/oder Standortdaten, um verfügbare Partner in deiner Nähe auszuwählen und um Betrug, Geldwäsche, Terrorismusfinanzierung und andere Straftaten zu verhindern.
+- Lazycook verwendet algorithmische Tools für personalisierte Inhalte (Profilerstellung), Empfehlungen, die Berechnung der geschätzten Lieferzeiten vor dem Kauf und Smart-Chat-Funktionen.
+
+Du wirst keinen Entscheidungen unterworfen, die aufgrund automatisierter Entscheidungsfindung erhebliche Auswirkungen auf dich haben, es sei denn, wir haben deine vorherige Einwilligung eingeholt, erfüllen eine rechtliche Verpflichtung oder es liegt in unserem berechtigten Interesse.
+
+---
+
+## Wie lange wir deine Daten speichern
+
+Lazycook bewahrt deine personenbezogenen Daten nur so lange auf, wie es für die Erfüllung der Zwecke, für die wir sie erhoben haben, erforderlich ist, einschließlich der Erfüllung rechtlicher, steuerlicher, buchhalterischer oder meldepflichtiger Anforderungen. Zur Bestimmung der angemessenen Aufbewahrungsfrist berücksichtigen wir unter anderem:
+
+- Art deiner personenbezogenen Daten
+- Rechtsstreitigkeiten und Streitbeilegung
+- Geltende Rechtsgrundlage
+- Sicherheitsüberlegungen und -anforderungen
+- Interne Richtlinien und Verfahren
+- Deine letzte Aktivität
+- Ende deines Vertrags mit Lazycook
+- Betrugsprävention und andere Compliance-Anforderungen
+- Der Zeitraum, der für den jeweiligen Geschäftszweck erforderlich ist
+
+---
+
+## Weitergabe deiner personenbezogenen Daten
+
+### An Partner
+
+Wenn du unsere Bestelldienste nutzt, kann Lazycook deine personenbezogenen Daten (Bestellung, Name, Adresse oder Kontaktdaten) an den von dir ausgewählten Partner weitergeben, damit der Partner deine Bestellung ausliefern und gegebenenfalls Dienstleistungen erbringen kann.
+
+### Im Rahmen von Lazycook for Business
+
+Wenn du dein Lazycook for Business-Guthaben nutzt, gibt Lazycook zur Erfüllung unseres Vertrags personenbezogene Daten (wie E-Mail-Adresse und Bestelleinzelheiten sowie dein Guthaben) an die Person weiter, die dir das Guthaben gewährt.
+
+### An andere
+
+Deine personenbezogenen Daten können aus folgenden Gründen an verschiedene Parteien weitergegeben werden:
+
+- **Verbundene Unternehmen und Konzerngesellschaften:** Wir geben Daten an verbundene Unternehmen und Tochtergesellschaften von Lazycook sowie der Naspers/Prosus-Gruppe weiter.
+- **Operative Anbieter:** Informationen werden an Softwareanbieter (in den Bereichen Hosting, Sicherheit, Tools und Logistik) weitergegeben.
+- **Marketing und Werbung:** Wir geben Daten an Marketinganbieter weiter, um relevante Produkte zu bewerben.
+- **Ausführung von Bestellungen:** Personenbezogene Daten werden Logistikpartnern zur Verfügung gestellt.
+- **Finanz- und Sicherheitsdienste:** Wir geben Informationen an Händler, Zahlungsabwickler und Anbieter zur Betrugsprävention weiter.
+- **Serviceverbesserung:** Daten können an Marktforschungsunternehmen und Umfragefirmen weitergegeben werden.
+- **Rechtliche und professionelle Aufsicht:** Wir geben Daten an professionelle Berater sowie Strafverfolgungs- und Regulierungsbehörden weiter.
+- **Änderungen im Unternehmen:** Im Falle eines Verkaufs oder Vermögenstransfers können Daten an potenzielle Käufer weitergegeben werden.
+- **Einvernehmliche Weitergabe:** Deine Daten dürfen an Dritte weitergegeben werden, sofern du ausdrücklich zugestimmt hast.
+
+---
+
+## Informationen zur Übermittlung deiner Daten
+
+Lazycook ist weltweit tätig und verarbeitet personenbezogene Daten weltweit. Lazycook überträgt Daten nur dann in andere Länder, wenn die gesetzlichen Anforderungen der geltenden Datenschutzgesetze erfüllt sind. Wenn wir personenbezogene Daten in nicht geeignete Länder übermitteln, halten wir uns an geeignete Schutzmaßnahmen und Übermittlungsmechanismen, darunter Standardvertragsklauseln der Europäischen Kommission sowie den EU-US-Datenschutzrahmen.
+
+---
+
+## Deine Datenschutzrechte
+
+Du kannst uns über unser Datenschutzformular oder die unten angegebenen Kontaktdaten kontaktieren, um folgende Rechte auszuüben:
+
+- **Recht auf Auskunft:** Du hast das Recht, Auskunft über die von uns verarbeiteten personenbezogenen Daten zu verlangen.
+- **Recht auf Widerruf der Einwilligung:** Falls die Verarbeitung auf einer Einwilligung beruht, kannst du deine Einwilligung jederzeit kostenlos widerrufen.
+- **Recht auf Berichtigung:** Du hast das Recht, falsche oder unvollständige personenbezogene Daten berichtigen oder vervollständigen zu lassen.
+- **Recht auf Löschung:** Du kannst uns bitten, deine personenbezogenen Daten aus unseren Systemen zu löschen.
+- **Widerspruchsrecht:** Du hast das Recht, einer bestimmten allgemeinen Verwendung deiner personenbezogenen Daten zu widersprechen.
+- **Recht auf Einschränkung der Verarbeitung:** Du kannst von uns verlangen, dass die Verarbeitung personenbezogener Daten eingeschränkt wird.
+- **Recht auf Datenübertragbarkeit:** Du hast das Recht, deine personenbezogenen Daten in einem maschinenlesbaren Format zu erhalten.
+- **Recht auf Einreichen einer Beschwerde:** Du hast das Recht, eine Beschwerde bei der zuständigen Aufsichtsbehörde einzureichen.
+
+---
+
+## Sicherheit
+
+Lazycook nimmt den Schutz personenbezogener Daten ernst und ergreift angemessene Maßnahmen, um deine personenbezogenen Daten vor Missbrauch, Verlust, unbefugtem Zugriff, unerwünschter Offenlegung und unbefugter Veränderung zu schützen. Bei Lazycook beschränken wir den Zugriff auf deine Daten so weit wie möglich.
+
+---
+
+## Kontakt
+
+Wenn du uns kontaktieren musst, um Anfragen zu stellen, oder wenn du Fragen und Bedenken zu dieser Erklärung hast, kontaktiere uns bitte über unser Datenschutzformular oder per E-Mail:
+
+**Lazycook GmbH**
+Erzbergerstraße 121
+76133 Karlsruhe
+Deutschland
+
+---
+
+## Aktualisierung dieser Datenschutzerklärung
+
+Diese Erklärung kann sich von Zeit zu Zeit ändern. Wenn wir wesentliche Änderungen an der Art und Weise vornehmen, wie wir deine personenbezogenen Daten verwenden, werden wir dich durch eine deutliche Mitteilung in der App informieren. Die aktuellste Version dieser Erklärung ist in der Fußzeile der Webseite oder in deiner App zu finden.
+
+---
+
+*Version: Mai 2026*
\ No newline at end of file
diff --git a/project/frontend/app/homepage/MarkdownFiles/Impressum.md b/project/frontend/app/homepage/MarkdownFiles/Impressum.md
new file mode 100644
index 0000000..8ae9707
--- /dev/null
+++ b/project/frontend/app/homepage/MarkdownFiles/Impressum.md
@@ -0,0 +1,25 @@
+# Impressum
+
+## Angaben gemäß § 5 TMG
+
+**Lazycook GmbH**
+Erzbergerstraße 121
+76133 Karlsruhe
+Deutschland
+
+## Kontakt
+
+- **Telefon:** +49 000 00000000
+- **E-Mail:** lazycook.noreply@gmail.com
+- **Website:** https://www.lazycook.de
+
+## Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
+
+**Lazycook GmbH**
+ Erzbergerstraße 121
+ 76133 Karlsruhe
+ Deutschland
+
+## Rechtliche Hinweise
+
+Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich.
diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx
index c00423a..cb21ae5 100644
--- a/project/frontend/app/homepage/homepage.tsx
+++ b/project/frontend/app/homepage/homepage.tsx
@@ -2,6 +2,7 @@
import { ChefHat, Search, BookOpen, Star, Clock, Smile } from 'lucide-react';
+import Link from 'next/link';
import { Button } from '../components/ui/button';
import {useCallback, useState} from "react";
import Modal from "@/app/components/modal";
@@ -27,9 +28,9 @@ export default function Homepage() {
- // TODO Datenschutz & Impressum
- Datenschutz
- Impressum
+ {/* Datenschutz & Impressum */}
+ Datenschutz
+ Impressum
diff --git a/project/frontend/app/impressum/page.tsx b/project/frontend/app/impressum/page.tsx
new file mode 100644
index 0000000..fc1accc
--- /dev/null
+++ b/project/frontend/app/impressum/page.tsx
@@ -0,0 +1,18 @@
+import MarkdownRenderer from "@/app/components/markdown-renderer";
+import { readMarkdownFile } from "@/app/lib/read-markdown";
+
+export default function ImpressumPage() {
+ const content = readMarkdownFile("Impressum.md");
+
+ return (
+
+
+
Impressum
+
+ Das vollständige Impressum wird hier aus der bestehenden Markdown-Datei angezeigt.
+
+
+
+
+ );
+}
diff --git a/project/frontend/app/lib/read-markdown.ts b/project/frontend/app/lib/read-markdown.ts
new file mode 100644
index 0000000..544a8ef
--- /dev/null
+++ b/project/frontend/app/lib/read-markdown.ts
@@ -0,0 +1,14 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { notFound } from "next/navigation";
+
+export function readMarkdownFile(fileName: string) {
+ try {
+ return readFileSync(
+ path.join(process.cwd(), "app", "homepage", "MarkdownFiles", fileName),
+ "utf8"
+ );
+ } catch {
+ notFound();
+ }
+}
diff --git a/project/frontend/package.json b/project/frontend/package.json
index 49a205c..b39d49a 100644
--- a/project/frontend/package.json
+++ b/project/frontend/package.json
@@ -15,12 +15,14 @@
"lucide-react": "^0.553.0",
"next": "16.2.3",
"next-auth": "^4.24.13",
+ "react-markdown": "^10.1.0",
"radix-ui": "^1.4.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "latest",
"react-resizable-panels": "latest",
"recharts": "^3.4.1",
+ "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"vaul": "latest"
},