From 7b20faf1bf15bcddb1ab94fe18366bc4d963a05a Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 5 May 2026 16:25:22 +0200 Subject: [PATCH 01/14] 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. +

+ +
+ ); + } + + 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" + } + /> + +

+ +

+
+ ); +} \ 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"} /> +

+ +

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}

} +
+ +
+
+ ); +} \ 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}

} + +
+ +
+
+ + ); +} \ 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

- {/* 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)}> + + - {/* 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)}> + + ); } 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}

} + + + +
+ ); +} + +export default function ResetPasswordPage() { + return ( + +

Wird geladen…

+ + } + > + +
+ ); +} \ No newline at end of file From 68dcdefad1907f39f17b2853aae14c7d47f0b0ed Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Fri, 8 May 2026 11:27:51 +0200 Subject: [PATCH 02/14] Feature: Email and Password change popup width changed --- project/data/LazyCookDB.sqlite3 | Bin 65536 -> 0 bytes .../frontend/app/{global.css => globals.css} | 2 +- project/frontend/app/layout.tsx | 16 +++--- .../frontend/app/profile/changeEmailPopup.tsx | 21 ++++---- .../app/profile/changePasswordPopup.tsx | 51 +++++++++--------- project/frontend/app/profile/page.tsx | 43 +++++++++++++-- project/frontend/app/profile/style.css | 10 ---- project/frontend/app/recipeFinder/style.css | 4 ++ 8 files changed, 92 insertions(+), 55 deletions(-) delete mode 100644 project/data/LazyCookDB.sqlite3 rename project/frontend/app/{global.css => globals.css} (99%) delete mode 100644 project/frontend/app/profile/style.css 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;; 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/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 index 9b971ea..cd6045e 100644 --- a/project/frontend/app/profile/changeEmailPopup.tsx +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -1,6 +1,7 @@ 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 (){ @@ -30,15 +31,17 @@ export default function ChangeEmail (){ } return ( -
-

E-Mail ändern

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

E-Mail ändern

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

{emailMsg}

}
+ setShowConfirm(false)}> +
+

+ Konto wirklich löschen? +

+

+ + +

+ +
+
+ setShowEmailModal(false)}> diff --git a/project/frontend/app/profile/style.css b/project/frontend/app/profile/style.css deleted file mode 100644 index ef2f8da..0000000 --- a/project/frontend/app/profile/style.css +++ /dev/null @@ -1,10 +0,0 @@ -.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/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; From fb332478664ba1485c5c192df8bc48453d804b1b Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Sat, 9 May 2026 16:02:54 +0200 Subject: [PATCH 03/14] 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 (
- onChange(e.target.value)} - placeholder={placeholder} - onKeyDown={onKeyDown} - onBlur={onBlur} - /> +
+ onChange(e.target.value)} + placeholder={placeholder} + onKeyDown={onKeyDown} + onBlur={onBlur} + style={isPassword ? { paddingRight: 38 } : undefined} + /> + {isPassword && ( + + )} +
); } \ No newline at end of file diff --git a/project/frontend/app/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 }} + /> + +
+ ); +} + 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 04/14] 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"} /> @@ -240,6 +297,7 @@ export default function RecipeFinder() { onAdd={handleAdd} servings={servings} onServingsChange={setServings} + suggestions={suggestions} />
diff --git a/project/frontend/app/recipeFinder/popup.tsx b/project/frontend/app/recipeFinder/popup.tsx index ec2f81b..444545b 100644 --- a/project/frontend/app/recipeFinder/popup.tsx +++ b/project/frontend/app/recipeFinder/popup.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import "./style.css"; const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; @@ -9,18 +9,25 @@ export interface IngredientInput { unit: string; } +export interface Suggestion { + name: string; + unit: string | null; +} + interface Props { ingredients: IngredientInput[]; onAdd: (ingredient: IngredientInput) => void; servings: number; onServingsChange: (s: number) => void; + suggestions?: Suggestion[]; } -export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { +export default function AddIngredientsPopup({ ingredients, onAdd, suggestions = [] }: Props) { const [ingredientName, setIngredientName] = useState(""); const [ingredientAmount, setIngredientAmount] = useState(""); const [ingredientUnit, setIngredientUnit] = useState("Stück"); const [inputError, setInputError] = useState(""); + const amountInputRef = useRef(null); const handleAddIngredient = () => { const trimmedName = ingredientName.trim(); @@ -40,6 +47,21 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { setInputError(""); }; + const handleSuggestionClick = (s: Suggestion) => { + setIngredientName(s.name); + if (s.unit && EINHEITEN.includes(s.unit)) { + setIngredientUnit(s.unit); + } + setInputError(""); + // Fokus aufs Mengen-Feld – User muss nur noch die Menge tippen + setTimeout(() => amountInputRef.current?.focus(), 0); + }; + + // Vorschläge bleiben sichtbar – bereits hinzugefügte werden disabled markiert, + // damit das Popup seine Größe behält. + const isAlreadyAdded = (s: Suggestion) => + ingredients.some(z => z.name.toLowerCase() === s.name.toLowerCase()); + return (

Zutaten Hinzufügen

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

Häufig verwendet

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

{inputError}

} ); -} \ No newline at end of file +} diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css index 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: Sun, 10 May 2026 21:40:21 +0200 Subject: [PATCH 13/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- project/backend/Auth.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/project/backend/Auth.py b/project/backend/Auth.py index 3a116a4..696fce6 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -16,10 +16,6 @@ PASSWORD_RESET_EXPIRE_MINUTES = 30 -import hashlib - -PASSWORD_RESET_EXPIRE_MINUTES = 30 - from Models import Token, User from Database import getAccountByEmail, saveRefreshToken, getRefreshToken From 42adbbb94ae9b7cfb1992566eaa5171280a728d4 Mon Sep 17 00:00:00 2001 From: Eden Tabea Bernhard <105359952+EdenBernhard@users.noreply.github.com> Date: Sun, 10 May 2026 21:40:49 +0200 Subject: [PATCH 14/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- project/backend/Database.py | 1 + 1 file changed, 1 insertion(+) diff --git a/project/backend/Database.py b/project/backend/Database.py index 4a2cea5..a20ad40 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -9,6 +9,7 @@ def getConnection() -> sqlite3.Connection: """Erstellt eine neue SQLite-Connection mit Row-Factory.""" con = sqlite3.connect(str(DB_PATH), check_same_thread=False) con.row_factory = sqlite3.Row + con.execute("PRAGMA journal_mode = WAL") con.execute("PRAGMA foreign_keys = ON") return con