feat: implement password reset for shop customers

Add complete password reset functionality:

Database:
- Add password_reset_tokens migration with token hash, expiry, used_at
- Create PasswordResetToken model with secure token hashing (SHA256)
- One active token per customer (old tokens invalidated on new request)
- 1-hour token expiry for security

API:
- Implement forgot_password endpoint with email lookup
- Implement reset_password endpoint with token validation
- No email enumeration (same response for all requests)
- Password minimum 8 characters validation

Frontend:
- Add reset-password.html template with Alpine.js
- Support for invalid/expired token states
- Success state with login redirect
- Dark mode support

Email:
- Add password_reset email templates (en, fr, de, lb)
- Uses existing EmailService with template rendering

Testing:
- Add comprehensive pytest tests (19 tests)
- Test token creation, validation, expiry, reuse prevention
- Test endpoint success and error cases

Removes critical launch blocker for password reset functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 17:16:27 +01:00
parent 2c7ac5b6b2
commit 2e1a2fc9fc
8 changed files with 1282 additions and 16 deletions

View File

@@ -653,6 +653,249 @@ Liwweradress:
Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt.
Merci fir Ären Akaf!
""",
},
# -------------------------------------------------------------------------
# PASSWORD RESET
# -------------------------------------------------------------------------
{
"code": "password_reset",
"language": "en",
"name": "Password Reset",
"description": "Sent to customers when they request a password reset",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Reset Your Password",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Reset Your Password</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reset Password
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
This link will expire in {{ expiry_hours }} hour(s). If you didn't request this password reset, you can safely ignore this email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Best regards,<br><strong>The Team</strong></p>
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</body>
</html>""",
"body_text": """Reset Your Password
Hi {{ customer_name }},
We received a request to reset your password. Click the link below to create a new password:
{{ reset_link }}
This link will expire in {{ expiry_hours }} hour(s). If you didn't request this password reset, you can safely ignore this email.
Best regards,
The Team
""",
},
{
"code": "password_reset",
"language": "fr",
"name": "Reinitialisation du mot de passe",
"description": "Envoye aux clients lorsqu'ils demandent une reinitialisation de mot de passe",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Reinitialiser votre mot de passe",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Reinitialiser votre mot de passe</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Nous avons recu une demande de reinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour creer un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reinitialiser le mot de passe
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Ce lien expirera dans {{ expiry_hours }} heure(s). Si vous n'avez pas demande cette reinitialisation, vous pouvez ignorer cet email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Si le bouton ne fonctionne pas, copiez et collez ce lien dans votre navigateur :<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Cordialement,<br><strong>L'equipe</strong></p>
</div>
</body>
</html>""",
"body_text": """Reinitialiser votre mot de passe
Bonjour {{ customer_name }},
Nous avons recu une demande de reinitialisation de votre mot de passe. Cliquez sur le lien ci-dessous pour creer un nouveau mot de passe :
{{ reset_link }}
Ce lien expirera dans {{ expiry_hours }} heure(s). Si vous n'avez pas demande cette reinitialisation, vous pouvez ignorer cet email.
Cordialement,
L'equipe
""",
},
{
"code": "password_reset",
"language": "de",
"name": "Passwort zurucksetzen",
"description": "An Kunden gesendet, wenn sie eine Passwortzurucksetzung anfordern",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Passwort zurucksetzen",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Passwort zurucksetzen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Wir haben eine Anfrage zur Zurucksetzung Ihres Passworts erhalten. Klicken Sie auf die Schaltflache unten, um ein neues Passwort zu erstellen:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Passwort zurucksetzen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dieser Link lauft in {{ expiry_hours }} Stunde(n) ab. Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, konnen Sie diese E-Mail ignorieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wenn die Schaltflache nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Mit freundlichen Grussen,<br><strong>Das Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Passwort zurucksetzen
Hallo {{ customer_name }},
Wir haben eine Anfrage zur Zurucksetzung Ihres Passworts erhalten. Klicken Sie auf den Link unten, um ein neues Passwort zu erstellen:
{{ reset_link }}
Dieser Link lauft in {{ expiry_hours }} Stunde(n) ab. Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Das Team
""",
},
{
"code": "password_reset",
"language": "lb",
"name": "Passwuert zrecksetzen",
"description": "Un Clienten gescheckt wann si eng Passwuertzrecksetzung ufroen",
"category": EmailCategory.AUTH.value,
"variables": json.dumps([
"customer_name", "reset_link", "expiry_hours"
]),
"subject": "Passwuert zrecksetzen",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Passwuert zrecksetzen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Mir hunn eng Ufro kritt fir Aert Passwuert zreckzesetzen. Klickt op de Knäppchen hei drënner fir en neit Passwuert ze kreéieren:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Passwuert zrecksetzen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dëse Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir dës Passwuertzrecksetzung net ugefrot hutt, kënnt Dir dës E-Mail ignoréieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wann de Knäppchen net fonctionnéiert, kopéiert dëse Link an Äre Browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Mat beschte Gréiss,<br><strong>D'Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Passwuert zrecksetzen
Moien {{ customer_name }},
Mir hunn eng Ufro kritt fir Aert Passwuert zreckzesetzen. Klickt op de Link hei drënner fir en neit Passwuert ze kreéieren:
{{ reset_link }}
Dëse Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir dës Passwuertzrecksetzung net ugefrot hutt, kënnt Dir dës E-Mail ignoréieren.
Mat beschte Gréiss,
D'Team
""",
},
]