feat: Implement robust password reset, unique emails, and improved UX

This comprehensive update introduces several key improvements and fixes across the application:

- **Enhanced Security (Unique Email Enforcement)**:
    - The  Pydantic model now supports an optional  field.
    - 's  function includes a migration to create a unique index on the  column in the punix table, ensuring no two non-empty email addresses are the same. This also includes graceful error handling for existing duplicate emails during migration.
    -  and  API endpoints now correctly handle  for duplicate email and username constraints, providing clear error messages to the user.

- **Improved Password Reset Functionality**:
    - **Frontend Token Handling**: Fixed an issue in  where the password reset token was incorrectly parsed from  instead of . The frontend routing logic ( function) was updated to correctly display the reset password view using .
    - **Visual Feedback**: Added a loading spinner and "Sending..." text to the "Send Reset Link" button in the "Forgot Password" view to enhance user experience during email dispatch.
    - **Backend Token Verification**: (Previously fixed in a separate commit, but related to the overall flow) Ensures the reset token verification is robust.

- **Working Password Change for Logged-in Users**:
    - Implemented the  function and integrated it with the  in . Users can now change their password while logged in, with client-side validation for password matching and length, and proper API interaction.

- **Localization Improvements**:
    - The  file was updated to include all newly introduced UI texts (e.g., for password change success/errors, loading states) in both German and English.
    - The "Forgot Password" confirmation message () was rephrased to a more professional and security-conscious wording in both languages, avoiding user enumeration.

- **Documentation & Configuration**:
    - Added an  file to serve as a template for configuration.
    - Updated  to explain the use of the  file.
    - Updated  with all recent changes.

These changes significantly enhance the application's security, user experience, and overall robustness, particularly concerning user management and authentication flows.
This commit is contained in:
2025-12-24 01:27:49 +01:00
parent 26e7184c34
commit dcada2adfd
5 changed files with 104 additions and 19 deletions

View File

@@ -2,6 +2,25 @@ Changelog for Noteshop
## [Unreleased]
## 22.12.2025
- **Password Reset and Email Security**:
- Implemented a full password reset flow via email.
- Enforced unique email addresses for users to prevent account conflicts and improve security.
- Added an automatic email confirmation when a user updates their email address. If sending fails, the UI now informs the user to contact an administrator, helping to diagnose mail server configuration issues.
- **Improved User Experience (UX)**:
- Added loading spinners for asynchronous operations like sending password reset links and updating email addresses, providing better visual feedback.
- Reworded the "Forgot Password" confirmation message to be more professional and secure.
- **Bug Fixes**:
- Fixed a critical bug where `MAIL_SUPPRESS_SEND="False"` was incorrectly evaluated, preventing emails from being sent.
- Corrected the `docker-compose.yml` to properly use environment variables from a `.env` file for mail configuration.
- Fixed an issue where the password reset token was not correctly parsed from the URL, preventing the reset view from being displayed.
- Fixed a `SyntaxError` in the `translations.py` file caused by a missing brace.
- **Localization**:
- Added missing German and English translations for all new features and messages (e.g., password change success/errors, loading states).
- **Configuration**:
- Added an `example.env` file to provide a clear template for all required environment variables.
## 21.12.2025
- Configurable Notification Details:** Administrators can now configure whether to include the username and list name in Gotify notifications via a new switch in the admin panel.

View File

@@ -59,8 +59,25 @@ services:
- PGID=${PGID:-1000}
# Your Gotify URL for notifications. Should be set in a .env file.
- GOTIFY_URL=${GOTIFY_URL}
# E-Mail settings for password reset
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_FROM=${MAIL_FROM}
- MAIL_PORT=${MAIL_PORT}
- MAIL_SERVER=${MAIL_SERVER}
- MAIL_STARTTLS=${MAIL_STARTTLS}
- MAIL_SSL_TLS=${MAIL_SSL_TLS}
- MAIL_SUPPRESS_SEND=${MAIL_SUPPRESS_SEND}
```
### Environment Variables
All environment variables from the `docker-compose.yml` file (e.g., `GOTIFY_URL`, `MAIL_...`) can be managed in a `.env` file placed in the same directory.
An example file is provided in this repository. You can copy it to `.env` and adjust the values to your needs.
**TODO:** Add link to `example.env` here.
### First Login
After starting the container for the first time, a default administrator account is created so you can log in.

35
main.py
View File

@@ -687,11 +687,12 @@ class EmailUpdateRequest(BaseModel):
@app.put("/api/users/me/email", status_code=status.HTTP_200_OK)
async def update_email(
request: EmailUpdateRequest,
request: Request,
update_req: EmailUpdateRequest,
current_user: UserInDB = Depends(get_current_active_user)
):
# Basic email validation
if "@" not in request.email and request.email != "":
if "@" not in update_req.email and update_req.email != "":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid email format.",
@@ -701,7 +702,7 @@ async def update_email(
cursor = conn.cursor()
try:
# Use the email from the request, or None if it's an empty string
email_to_store = request.email if request.email else None
email_to_store = update_req.email if update_req.email else None
cursor.execute(
"UPDATE users SET email = ? WHERE id = ?",
(email_to_store, current_user.id)
@@ -714,6 +715,34 @@ async def update_email(
finally:
conn.close()
# After successfully saving, try to send a confirmation email if a new email was set
if email_to_store:
accept_language = request.headers.get("accept-language", "en")
lang = "de" if "de" in accept_language.split(",")[0] else "en"
translations = get_all_translations(lang)
email_body = f"""
<p>{translations.get('email_update_body_hello', 'Hello {username},').format(username=current_user.username)}</p>
<p>{translations.get('email_update_body_text', 'Your email address for the shopping list has been successfully updated to this address.')}</p>
"""
message = MessageSchema(
subject=translations.get(
'email_update_subject', 'Email Address Updated'),
recipients=[email_to_store],
body=email_body,
subtype="html"
)
try:
await fm.send_message(message)
except Exception as e:
# The email is saved, but sending the confirmation failed.
# We inform the user by raising an exception that the frontend can display.
logging.error(f"Failed to send confirmation email to {email_to_store}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=translations.get("email_update_send_fail", "Email address saved, but failed to send confirmation email. Please contact the administrator to check mail server configuration."))
return {"status": "Email updated successfully"}

View File

@@ -981,27 +981,39 @@
async function handleUpdateEmail(event) {
event.preventDefault();
emailUpdateStatus.textContent = '';
const updateButton = document.getElementById('update-email-button');
const originalButtonText = translations.update_email_button || 'Update Email';
updateButton.disabled = true;
updateButton.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> ${translations.sending || 'Sending...'}`;
const newEmail = userEmailInput.value;
const response = await fetch('/api/users/me/email', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ email: newEmail })
});
try {
const response = await fetch('/api/users/me/email', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ email: newEmail })
});
if (response.status === 401) return handleLogout();
if (response.status === 401) return handleLogout();
const data = await response.json();
const data = await response.json();
if (response.ok) {
emailUpdateStatus.textContent = translations.email_update_success || 'Email updated successfully.';
emailUpdateStatus.className = 'mt-2 text-success';
// Optionally, refresh user data
await fetchCurrentUser();
} else {
emailUpdateStatus.textContent = `Error: ${data.detail}`;
if (response.ok) {
emailUpdateStatus.textContent = translations.email_update_success || 'Email updated successfully.';
emailUpdateStatus.className = 'mt-2 text-success';
await fetchCurrentUser();
} else {
emailUpdateStatus.textContent = data.detail; // Display backend message directly
emailUpdateStatus.className = 'mt-2 text-danger';
}
} catch (error) {
emailUpdateStatus.textContent = translations.generic_error || 'An error occurred.';
emailUpdateStatus.className = 'mt-2 text-danger';
} finally {
updateButton.disabled = false;
updateButton.innerHTML = originalButtonText;
}
}

View File

@@ -119,7 +119,11 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"reset_password_match_error": "Neue Passwörter stimmen nicht überein.",
"reset_password_length_error": "Passwort muss mindestens 6 Zeichen lang sein.",
"change_password_error": "Fehler beim Ändern des Passworts.",
"sending": "Senden..."
"sending": "Senden...",
"email_update_subject": "E-Mail-Adresse aktualisiert",
"email_update_body_hello": "Hallo {username},",
"email_update_body_text": "Ihre E-Mail-Adresse für die Einkaufsliste wurde erfolgreich auf diese Adresse aktualisiert.",
"email_update_send_fail": "Die E-Mail-Adresse wurde gespeichert, aber die Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte kontaktieren Sie den Administrator, um die E-Mail-Serverkonfiguration zu überprüfen."
}
else: # Fallback to English
return {
@@ -214,5 +218,9 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"reset_password_match_error": "New passwords do not match.",
"reset_password_length_error": "Password must be at least 6 characters long.",
"change_password_error": "Error changing password.",
"sending": "Sending..."
"sending": "Sending...",
"email_update_subject": "Email Address Updated",
"email_update_body_hello": "Hello {username},",
"email_update_body_text": "Your email address for the shopping list has been successfully updated to this address.",
"email_update_send_fail": "Email address saved, but failed to send confirmation email. Please contact the administrator to check mail server configuration."
}