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 ( +
+

Laden…

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