mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 18:23:42 +00:00
feat: add self-service password reset via email
Adds "Passwort vergessen?" to login page with email-based password reset flow. Backend generates secure token (SHA-256 hashed, 1h expiry), sends reset link via SMTP, and validates on submission. Includes rate limiting (3 requests/hour/email), audit logging, and account unlock on successful reset. New ResetPasswordPage with password confirmation. New DB table: password_reset_tokens (migration 008). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
002021d7c7
commit
d5db84d93f
10 changed files with 490 additions and 5 deletions
43
backend/alembic/versions/008_password_reset_tokens.py
Normal file
43
backend/alembic/versions/008_password_reset_tokens.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Add password_reset_tokens table.
|
||||||
|
|
||||||
|
Revision ID: 008_password_reset_tokens
|
||||||
|
Revises: 007_add_report_type
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "008_password_reset_tokens"
|
||||||
|
down_revision = "007_add_report_type"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"password_reset_tokens",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("token_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime, nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_prt_token", "password_reset_tokens", ["token_hash"])
|
||||||
|
op.create_index("idx_prt_user", "password_reset_tokens", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_prt_user", table_name="password_reset_tokens")
|
||||||
|
op.drop_index("idx_prt_token", table_name="password_reset_tokens")
|
||||||
|
op.drop_table("password_reset_tokens")
|
||||||
|
|
@ -1,26 +1,33 @@
|
||||||
"""Auth API router: login, register, refresh, logout, MFA, password change."""
|
"""Auth API router: login, register, refresh, logout, MFA, password change, reset."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.core.dependencies import get_current_user
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.core.security import hash_password, hash_token
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.audit_service import log_action
|
from app.models.user import PasswordResetToken, User
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
|
ForgotPasswordRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MFADisableRequest,
|
MFADisableRequest,
|
||||||
MFASetupResponse,
|
MFASetupResponse,
|
||||||
MFAVerifyRequest,
|
MFAVerifyRequest,
|
||||||
RefreshRequest,
|
RefreshRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.user import ProfileUpdate, UserResponse
|
from app.schemas.user import ProfileUpdate, UserResponse
|
||||||
|
from app.services.audit_service import log_action
|
||||||
from app.services.auth_service import (
|
from app.services.auth_service import (
|
||||||
activate_mfa,
|
activate_mfa,
|
||||||
authenticate_user,
|
authenticate_user,
|
||||||
|
|
@ -33,6 +40,10 @@ from app.services.auth_service import (
|
||||||
setup_mfa,
|
setup_mfa,
|
||||||
update_profile,
|
update_profile,
|
||||||
)
|
)
|
||||||
|
from app.services.notification_service import send_email
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -87,6 +98,126 @@ def refresh(data: RefreshRequest, db: Session = Depends(get_db)):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Password reset (public, no auth required)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RESET_TOKEN_EXPIRE_HOURS = 1
|
||||||
|
RESET_MAX_REQUESTS_PER_HOUR = 3
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
def forgot_password(data: ForgotPasswordRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Request a password reset link via email.
|
||||||
|
|
||||||
|
Always returns 200 to prevent user enumeration.
|
||||||
|
"""
|
||||||
|
user = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.email == data.email, User.is_active == True) # noqa: E712
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Rate limiting: max N requests per email per hour
|
||||||
|
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||||
|
recent_count = (
|
||||||
|
db.query(PasswordResetToken)
|
||||||
|
.filter(
|
||||||
|
PasswordResetToken.user_id == user.id,
|
||||||
|
PasswordResetToken.created_at >= one_hour_ago,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
if recent_count < RESET_MAX_REQUESTS_PER_HOUR:
|
||||||
|
# Generate token
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_h = hash_token(raw_token)
|
||||||
|
|
||||||
|
prt = PasswordResetToken(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=token_h,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=RESET_TOKEN_EXPIRE_HOURS),
|
||||||
|
)
|
||||||
|
db.add(prt)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
reset_url = f"{settings.FRONTEND_BASE_URL}/reset-password?token={raw_token}"
|
||||||
|
send_email(
|
||||||
|
to=user.email,
|
||||||
|
subject="[DAK Portal] Passwort zurücksetzen",
|
||||||
|
body=(
|
||||||
|
f"Hallo {user.first_name or user.username},\n\n"
|
||||||
|
f"Sie haben eine Passwort-Zurücksetzung angefordert.\n\n"
|
||||||
|
f"Klicken Sie auf folgenden Link, um ein neues Passwort zu vergeben:\n"
|
||||||
|
f"{reset_url}\n\n"
|
||||||
|
f"Der Link ist {RESET_TOKEN_EXPIRE_HOURS} Stunde gültig.\n\n"
|
||||||
|
f"Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
|
||||||
|
f"Mit freundlichen Grüßen\n"
|
||||||
|
f"DAK Zweitmeinungs-Portal"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="password_reset_requested",
|
||||||
|
entity_type="user", entity_id=user.id,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"detail": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
def reset_password(data: ResetPasswordRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Reset password using a valid reset token."""
|
||||||
|
if len(data.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=422, detail="Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||||
|
|
||||||
|
token_h = hash_token(data.token)
|
||||||
|
prt = (
|
||||||
|
db.query(PasswordResetToken)
|
||||||
|
.filter(
|
||||||
|
PasswordResetToken.token_hash == token_h,
|
||||||
|
PasswordResetToken.used_at == None, # noqa: E711
|
||||||
|
PasswordResetToken.expires_at >= datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not prt:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Reset-Link.")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == prt.user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Benutzer nicht gefunden.")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.password_hash = hash_password(data.new_password)
|
||||||
|
user.must_change_password = False
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
|
||||||
|
# Invalidate all reset tokens for this user
|
||||||
|
db.query(PasswordResetToken).filter(
|
||||||
|
PasswordResetToken.user_id == user.id,
|
||||||
|
PasswordResetToken.used_at == None, # noqa: E711
|
||||||
|
).update({"used_at": datetime.now(timezone.utc)})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="password_reset_completed",
|
||||||
|
entity_type="user", entity_id=user.id,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"detail": "Passwort erfolgreich zurückgesetzt. Sie können sich jetzt anmelden."}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Authenticated endpoints
|
# Authenticated endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_NAME: str = "DAK Zweitmeinungs-Portal"
|
APP_NAME: str = "DAK Zweitmeinungs-Portal"
|
||||||
|
FRONTEND_BASE_URL: str = "https://dak.complexcaresolutions.de"
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,https://dak.complexcaresolutions.de"
|
CORS_ORIGINS: str = "http://localhost:5173,https://dak.complexcaresolutions.de"
|
||||||
MAX_UPLOAD_SIZE: int = 20971520 # 20MB
|
MAX_UPLOAD_SIZE: int = 20971520 # 20MB
|
||||||
VERSICHERUNG_FILTER: str = "DAK"
|
VERSICHERUNG_FILTER: str = "DAK"
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,30 @@ class InvitationLink(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetToken(Base):
|
||||||
|
__tablename__ = "password_reset_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_prt_token", "token_hash"),
|
||||||
|
Index("idx_prt_user", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AllowedDomain(Base):
|
class AllowedDomain(Base):
|
||||||
__tablename__ = "allowed_domains"
|
__tablename__ = "allowed_domains"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,16 @@ class MFADisableRequest(BaseModel):
|
||||||
"""Password confirmation required to disable MFA."""
|
"""Password confirmation required to disable MFA."""
|
||||||
|
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
"""Request body for POST /forgot-password."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
"""Request body for POST /reset-password."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
|
|
||||||
53
docs/plans/2026-02-28-password-reset-design.md
Normal file
53
docs/plans/2026-02-28-password-reset-design.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Passwort-Reset per E-Mail — Design
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. Nutzer klickt "Passwort vergessen?" auf der Login-Seite
|
||||||
|
2. Gibt E-Mail-Adresse ein → `POST /api/auth/forgot-password`
|
||||||
|
3. Backend generiert Token, speichert Hash in DB, sendet E-Mail mit Link
|
||||||
|
4. E-Mail enthält: `{FRONTEND_BASE_URL}/reset-password?token=XXX` (1h gültig)
|
||||||
|
5. Nutzer klickt Link → Frontend zeigt "Neues Passwort"-Formular
|
||||||
|
6. Nutzer gibt neues Passwort ein → `POST /api/auth/reset-password`
|
||||||
|
7. Backend validiert Token, setzt Passwort, invalidiert Token
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Neues Modell: `PasswordResetToken`
|
||||||
|
|
||||||
|
Felder: `id`, `user_id` (FK), `token_hash` (SHA-256), `expires_at` (1h), `used_at`, `created_at`.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
- `POST /api/auth/forgot-password` — Immer 200 (verhindert User-Enumeration). Bei gültiger E-Mail: Token generieren, E-Mail senden.
|
||||||
|
- `POST /api/auth/reset-password` — Token + neues Passwort. Validiert Token, setzt Passwort, invalidiert alle Reset-Tokens des Users.
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`FRONTEND_BASE_URL` in Settings für Reset-Link-Generierung.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Token: `secrets.token_urlsafe(32)`, gespeichert als SHA-256 Hash
|
||||||
|
- 1h gültig, einmalig verwendbar
|
||||||
|
- Rate-Limiting: Max 3 Requests pro E-Mail pro Stunde
|
||||||
|
- Funktioniert auch bei gesperrten Accounts
|
||||||
|
- Audit-Log: `password_reset_requested`, `password_reset_completed`
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- "Passwort vergessen?"-Link auf LoginPage
|
||||||
|
- Inline E-Mail-Eingabe unter dem Login-Formular
|
||||||
|
- Neue Route `/reset-password` → `ResetPasswordPage.tsx`
|
||||||
|
- Formular: Neues Passwort + Bestätigung (min. 8 Zeichen)
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
| Datei | Änderung |
|
||||||
|
|-------|----------|
|
||||||
|
| `backend/app/models/user.py` | `PasswordResetToken` Modell |
|
||||||
|
| `backend/alembic/versions/007_password_reset_tokens.py` | Migration |
|
||||||
|
| `backend/app/config.py` | `FRONTEND_BASE_URL` Setting |
|
||||||
|
| `backend/app/api/auth.py` | Zwei neue Endpoints |
|
||||||
|
| `frontend/src/pages/LoginPage.tsx` | "Passwort vergessen?" UI |
|
||||||
|
| `frontend/src/pages/ResetPasswordPage.tsx` | Neue Seite |
|
||||||
|
| `frontend/src/App.tsx` | Route `/reset-password` |
|
||||||
|
|
@ -17,4 +17,4 @@
|
||||||
|
|
||||||
- [ ] **Erweiterte Suche mit Filterspeicherung** — Häufig genutzte Filter als Presets speichern (z.B. "Onko ohne ICD 2026").
|
- [ ] **Erweiterte Suche mit Filterspeicherung** — Häufig genutzte Filter als Presets speichern (z.B. "Onko ohne ICD 2026").
|
||||||
- [ ] **Dashboard: Durchlaufzeiten** — Durchschnittliche Dauer von Fallerfassung bis Gutachten visualisieren.
|
- [ ] **Dashboard: Durchlaufzeiten** — Durchschnittliche Dauer von Fallerfassung bis Gutachten visualisieren.
|
||||||
- [ ] **Passwort-Reset per E-Mail** — Self-Service "Passwort vergessen" auf der Login-Seite. Aktuell nur Admin-seitig möglich.
|
- [x] **Passwort-Reset per E-Mail** — ✅ Implementiert: "Passwort vergessen?" auf Login-Seite, Reset-Link per E-Mail (1h gültig), ResetPasswordPage mit Passwort-Formular. Rate-Limiting (3/h), Audit-Log, Token als SHA-256 Hash gespeichert.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
|
||||||
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
|
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
|
||||||
import { WochenuebersichtPage } from '@/pages/WochenuebersichtPage'
|
import { WochenuebersichtPage } from '@/pages/WochenuebersichtPage'
|
||||||
import { AnleitungPage } from '@/pages/AnleitungPage'
|
import { AnleitungPage } from '@/pages/AnleitungPage'
|
||||||
|
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -41,6 +42,7 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { AlertCircle, Loader2, Moon, Sun } from 'lucide-react'
|
import { AlertCircle, CheckCircle, Loader2, Moon, Sun } from 'lucide-react'
|
||||||
import { useTheme } from '@/hooks/useTheme'
|
import { useTheme } from '@/hooks/useTheme'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
|
email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
|
||||||
|
|
@ -31,6 +32,25 @@ export function LoginPage() {
|
||||||
const [showMfa, setShowMfa] = useState(false)
|
const [showMfa, setShowMfa] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [legalDialog, setLegalDialog] = useState<{ title: string; url: string } | null>(null)
|
const [legalDialog, setLegalDialog] = useState<{ title: string; url: string } | null>(null)
|
||||||
|
const [showForgot, setShowForgot] = useState(false)
|
||||||
|
const [forgotEmail, setForgotEmail] = useState('')
|
||||||
|
const [forgotLoading, setForgotLoading] = useState(false)
|
||||||
|
const [forgotSent, setForgotSent] = useState(false)
|
||||||
|
const [forgotError, setForgotError] = useState('')
|
||||||
|
|
||||||
|
const handleForgotPassword = async () => {
|
||||||
|
if (!forgotEmail.trim()) return
|
||||||
|
setForgotLoading(true)
|
||||||
|
setForgotError('')
|
||||||
|
try {
|
||||||
|
await api.post('/auth/forgot-password', { email: forgotEmail.trim() })
|
||||||
|
setForgotSent(true)
|
||||||
|
} catch {
|
||||||
|
setForgotError('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.')
|
||||||
|
} finally {
|
||||||
|
setForgotLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -151,6 +171,60 @@ export function LoginPage() {
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowForgot(!showForgot); setForgotSent(false); setForgotError('') }}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForgot && (
|
||||||
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
|
{forgotSent ? (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet. Bitte prüfen Sie Ihr Postfach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zurücksetzen Ihres Passworts.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="name@dak.de"
|
||||||
|
value={forgotEmail}
|
||||||
|
onChange={(e) => setForgotEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleForgotPassword() } }}
|
||||||
|
/>
|
||||||
|
{forgotError && <p className="text-sm text-destructive">{forgotError}</p>}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleForgotPassword}
|
||||||
|
disabled={forgotLoading || !forgotEmail.trim()}
|
||||||
|
>
|
||||||
|
{forgotLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Wird gesendet...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Reset-Link senden'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
144
frontend/src/pages/ResetPasswordPage.tsx
Normal file
144
frontend/src/pages/ResetPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSearchParams, Link } from 'react-router-dom'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
|
export function ResetPasswordPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const token = searchParams.get('token') || ''
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const passwordValid = password.length >= 8
|
||||||
|
const passwordsMatch = password === confirm
|
||||||
|
const canSubmit = passwordValid && passwordsMatch && !loading
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.post('/auth/reset-password', { token, new_password: password })
|
||||||
|
setSuccess(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err?.response?.data?.detail
|
||||||
|
setError(typeof detail === 'string' ? detail : 'Ein Fehler ist aufgetreten.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Ungültiger Link</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Der Reset-Link ist ungültig. Bitte fordern Sie einen neuen Link an.
|
||||||
|
</p>
|
||||||
|
<Link to="/login">
|
||||||
|
<Button variant="outline">Zurück zum Login</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Neues Passwort vergeben</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Geben Sie ein neues Passwort für Ihr Konto ein.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{success ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/login">
|
||||||
|
<Button className="w-full">Zum Login</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Neues Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
/>
|
||||||
|
{password && !passwordValid && (
|
||||||
|
<p className="text-sm text-destructive">Das Passwort muss mindestens 8 Zeichen lang sein.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm">Passwort bestätigen</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
/>
|
||||||
|
{confirm && !passwordsMatch && (
|
||||||
|
<p className="text-sm text-destructive">Die Passwörter stimmen nicht überein.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!canSubmit}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Wird gespeichert...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Passwort zurücksetzen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link to="/login" className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors">
|
||||||
|
Zurück zum Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue