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 (
-
onChange(e.target.value)}
- placeholder={placeholder}
- onKeyDown={onKeyDown}
- onBlur={onBlur}
- />
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ onKeyDown={onKeyDown}
+ onBlur={onBlur}
+ style={isPassword ? { paddingRight: 38 } : undefined}
+ />
+ {isPassword && (
+
+ )}
+
);
-}
\ No newline at end of file
+}
diff --git a/project/frontend/app/global.css b/project/frontend/app/globals.css
similarity index 99%
rename from project/frontend/app/global.css
rename to project/frontend/app/globals.css
index fddc17d..83073d3 100644
--- a/project/frontend/app/global.css
+++ b/project/frontend/app/globals.css
@@ -40,7 +40,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
- --text-base: 10 px;
+ --text-base: 10px;
--text-2xl: 30px;
--text-lg: 20px;
--text-xl:25px;
diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx
new file mode 100644
index 0000000..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.
+
+
+
+ );
+ }
+
+ 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"
+ }
+ />
+
+
+
+
+
+ );
+}
\ 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/layout.tsx b/project/frontend/app/layout.tsx
index 407b567..4f16d75 100644
--- a/project/frontend/app/layout.tsx
+++ b/project/frontend/app/layout.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
-import "./global.css";
+import "./globals.css";
import { AuthProvider } from "@/lib/auth";
const geistSans = Geist({
@@ -23,13 +23,13 @@ export default function RootLayout({children,}: Readonly<{
}>) {
return (
-
-
- {children}
-
-
+
+
+ {children}
+
+
);
}
\ No newline at end of file
diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx
new file mode 100644
index 0000000..1d1dd3b
--- /dev/null
+++ b/project/frontend/app/profile/changeEmailPopup.tsx
@@ -0,0 +1,57 @@
+import {Button} from "@/app/components/ui/button";
+import {fetchWithAuth} from "@/lib/auth";
+import {useState} from "react";
+import "../recipeFinder/style.css"
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
+export default function ChangeEmail (){
+
+
+ const [newEmail, setNewEmail] = useState("");
+ const [emailMsg, setEmailMsg] = useState("");
+
+ async function handleEmailChange() {
+ setEmailMsg("");
+ try {
+ const res = await fetchWithAuth(`${API_URL}/users/me`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: newEmail }),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ setEmailMsg(`${err.detail}`);
+ return;
+ }
+ setEmailMsg("E-Mail erfolgreich geändert.");
+ setNewEmail("");
+ } catch {
+ setEmailMsg("Unbekannter Fehler.");
+ }
+ }
+
+ return (
+
+
E-Mail ändern
+
+ setNewEmail(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleEmailChange()}
+ className="popup__input"
+ />
+
+ {emailMsg &&
{emailMsg}
}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx
new file mode 100644
index 0000000..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 }}
+ />
+
+
+ );
+}
+
+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}
}
+
+
+
+
+
+
+ );
+}
\ 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
- {/* E-Mail Modal */}
- {showEmailModal && (
- setShowEmailModal(false)}
- >
-
e.stopPropagation()}
- >
-
E-Mail ändern
-
setNewEmail(e.target.value)}
- className="border rounded-lg px-3 py-2 text-sm w-full"
- />
- {emailMsg &&
{emailMsg}
}
-
-
-
-
-
-
- )}
+ setShowConfirm(false)}>
+
+
+ Konto wirklich löschen?
+
+
+
+
+
- {/* Passwort Modal */}
- {showPasswordModal && (
-
setShowPasswordModal(false)}
- >
-
e.stopPropagation()}
- >
-
Passwort ändern
-
setCurrentPassword(e.target.value)}
- className="border rounded-lg px-3 py-2 text-sm w-full"
- />
-
setNewPassword(e.target.value)}
- className="border rounded-lg px-3 py-2 text-sm w-full"
- />
- {passwordMsg &&
{passwordMsg}
}
-
-
-
-
-
- )}
+
+
+
setShowEmailModal(false)}>
+
+
+
+
setShowPasswordModal(false)}>
+
+
);
}
diff --git a/project/frontend/app/recipeFinder/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"}
+ />
+
+
+ );
+}
+
+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