mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 14:53:41 +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 secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
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.services.audit_service import log_action
|
||||
from app.models.user import User
|
||||
from app.models.user import PasswordResetToken, User
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
MFADisableRequest,
|
||||
MFASetupResponse,
|
||||
MFAVerifyRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.user import ProfileUpdate, UserResponse
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.auth_service import (
|
||||
activate_mfa,
|
||||
authenticate_user,
|
||||
|
|
@ -33,6 +40,10 @@ from app.services.auth_service import (
|
|||
setup_mfa,
|
||||
update_profile,
|
||||
)
|
||||
from app.services.notification_service import send_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class Settings(BaseSettings):
|
|||
|
||||
# App
|
||||
APP_NAME: str = "DAK Zweitmeinungs-Portal"
|
||||
FRONTEND_BASE_URL: str = "https://dak.complexcaresolutions.de"
|
||||
CORS_ORIGINS: str = "http://localhost:5173,https://dak.complexcaresolutions.de"
|
||||
MAX_UPLOAD_SIZE: int = 20971520 # 20MB
|
||||
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):
|
||||
__tablename__ = "allowed_domains"
|
||||
|
||||
|
|
|
|||
|
|
@ -68,3 +68,16 @@ class MFADisableRequest(BaseModel):
|
|||
"""Password confirmation required to disable MFA."""
|
||||
|
||||
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").
|
||||
- [ ] **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 { WochenuebersichtPage } from '@/pages/WochenuebersichtPage'
|
||||
import { AnleitungPage } from '@/pages/AnleitungPage'
|
||||
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -41,6 +42,7 @@ function App() {
|
|||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ 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, Loader2, Moon, Sun } from 'lucide-react'
|
||||
import { AlertCircle, CheckCircle, Loader2, Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import api from '@/services/api'
|
||||
|
||||
const loginSchema = z.object({
|
||||
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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||
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 {
|
||||
register,
|
||||
|
|
@ -151,6 +171,60 @@ export function LoginPage() {
|
|||
)}
|
||||
</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>
|
||||
</CardContent>
|
||||
</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