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:
19
Changelog
19
Changelog
@@ -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.
|
||||
|
||||
17
README.md
17
README.md
@@ -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
35
main.py
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user