fix: Resolve application startup and frontend issues

This commit addresses several issues encountered during application startup and frontend rendering:

- **Backend (main.py):
  - Corrected ConnectionConfig parameters for fastapi_mail: Replaced deprecated MAIL_TLS/MAIL_SSL with MAIL_STARTTLS/MAIL_SSL_TLS and removed MAIL_CONSOLE_BACKEND (now handled by SUPPRESS_SEND).
  - Ensured 'templates/email' directory exists to resolve TEMPLATE_FOLDER validation error.
  - Replaced print() statements with logging.info()/logging.error() for email sending to improve log visibility in Docker.

- **Frontend (static/index.html):**
  - Implemented missing  and  JavaScript functions to fix a  that prevented proper frontend rendering.
  - Added corresponding event listeners for password reset forms and links.
  - Added missing  function for the admin user management.

These changes ensure the application starts without Pydantic validation errors, the frontend renders correctly, and email-related actions are properly logged in development.
This commit is contained in:
2025-12-22 11:25:06 +01:00
parent d0689900f8
commit dd6202e14d
3 changed files with 487 additions and 16 deletions

168
main.py
View File

@@ -1,3 +1,6 @@
import logging # Added this line
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig
from collections import defaultdict
from passlib.context import CryptContext
import os
@@ -13,6 +16,8 @@ from translations import get_standard_items, get_all_translations
from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # Added this line
app = FastAPI()
# --- Configuration ---
@@ -21,6 +26,26 @@ ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 43200 # 30 days
DB_FILE = "data/shoppinglist.db"
# --- Email Configuration ---
# For development, we'll use a console backend to print emails to stdout.
# In production, replace with actual SMTP settings from environment variables.
conf = ConnectionConfig(
MAIL_USERNAME=os.getenv("MAIL_USERNAME", "your_email@example.com"),
MAIL_PASSWORD=os.getenv("MAIL_PASSWORD", "your_email_password"),
MAIL_FROM=os.getenv("MAIL_FROM", "your_email@example.com"),
MAIL_PORT=int(os.getenv("MAIL_PORT", 587)),
MAIL_SERVER=os.getenv("MAIL_SERVER", "smtp.example.com"),
MAIL_STARTTLS=bool(os.getenv("MAIL_STARTTLS", True)), # Use MAIL_STARTTLS
MAIL_SSL_TLS=bool(os.getenv("MAIL_SSL_TLS", False)), # Use MAIL_SSL_TLS
USE_CREDENTIALS=bool(os.getenv("USE_CREDENTIALS", True)),
VALIDATE_CERTS=bool(os.getenv("VALIDATE_CERTS", True)),
TEMPLATE_FOLDER='./templates/email',
)
conf.SUPPRESS_SEND = bool(os.getenv("MAIL_SUPPRESS_SEND", True))
fm = FastMail(conf)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -53,6 +78,7 @@ class UserInDB(User):
failed_login_attempts: int
is_locked: bool
locked_at: Optional[datetime]
email: Optional[str] = None
class Item(BaseModel):
@@ -80,12 +106,44 @@ class ShoppingListCreate(BaseModel):
class NewUser(BaseModel):
username: str
password: str
is_admin: bool = False
class ForgotPasswordRequest(BaseModel):
email_or_username: str
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
# --- WebSocket Connection Manager ---
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
@@ -121,7 +179,7 @@ def get_user(username: str):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
cursor.execute("SELECT *, email FROM users WHERE username = ?", (username,))
user_data = cursor.fetchone()
conn.close()
if user_data:
@@ -203,7 +261,8 @@ def init_db():
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT 0
is_admin BOOLEAN NOT NULL DEFAULT 0,
email TEXT
)
""")
if not column_exists(cursor, "users", "failed_login_attempts"):
@@ -212,6 +271,18 @@ def init_db():
cursor.execute("ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")
if not column_exists(cursor, "users", "locked_at"):
cursor.execute("ALTER TABLE users ADD COLUMN locked_at TIMESTAMP")
if not column_exists(cursor, "users", "email"):
cursor.execute("ALTER TABLE users ADD COLUMN email TEXT")
# --- Password Reset Tokens Table ---
cursor.execute("""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL
)
""")
# --- Schema Migration for 'items' table ---
cursor.execute("PRAGMA table_info(items)")
@@ -569,6 +640,7 @@ class UserListUser(BaseModel):
username: str
is_admin: bool
is_locked: bool
email: Optional[str] = None
@app.get("/api/users", response_model=List[UserListUser])
@@ -646,6 +718,98 @@ async def unlock_user(user_id: int, current_user: UserInDB = Depends(get_current
return {"status": "User unlocked successfully"}
import secrets
@app.post("/api/users/forgot-password")
async def forgot_password(request: ForgotPasswordRequest):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
user_info = None
# Try to find user by email
cursor.execute("SELECT id, username, email FROM users WHERE email = ?", (request.email_or_username,))
user_info = cursor.fetchone()
# If not found by email, try by username
if not user_info:
cursor.execute("SELECT id, username, email FROM users WHERE username = ?", (request.email_or_username,))
user_info = cursor.fetchone()
if not user_info or not user_info[2]: # user_info[2] is the email
conn.close()
# To prevent user enumeration, always return a success message
return {"message": "If a matching user with an email address was found, a password reset email has been sent."}
user_id, username, email = user_info
# Generate a secure token
token = secrets.token_urlsafe(32)
hashed_token = pwd_context.hash(token)
expires_at = datetime.utcnow() + timedelta(hours=1)
cursor.execute("INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, hashed_token, expires_at))
conn.commit()
conn.close()
# Send email
reset_link = f"http://localhost:8000/reset-password?token={token}" # TODO: Replace with actual domain
message = MessageSchema(
subject="Password Reset Request",
recipients=[email],
body=f"""
<p>Hello {username},</p>
<p>You have requested a password reset for your account.</p>
<p>Please click on the following link to reset your password:</p>
<p><a href="{reset_link}">{reset_link}</a></p>
<p>This link is valid for 1 hour.</p>
<p>If you did not request a password reset, please ignore this email.</p>
""",
subtype="html"
)
try:
await fm.send_message(message)
logging.info(f"Password reset email sent to {email}") # Changed this line
except Exception as e:
logging.error(f"Failed to send email: {e}") # Changed this line
# Log error, but still return success to frontend to avoid leaking information
return {"message": "If a matching user with an email address was found, a password reset email has been sent."}
@app.post("/api/users/reset-password")
async def reset_password(request: ResetPasswordRequest):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT user_id, expires_at, token FROM password_reset_tokens WHERE token = ?", (request.token,))
reset_data = cursor.fetchone()
if not reset_data:
conn.close()
raise HTTPException(status_code=400, detail="Invalid or expired reset token.")
user_id, expires_at_str, hashed_stored_token = reset_data
expires_at = datetime.fromisoformat(expires_at_str)
if datetime.utcnow() > expires_at or not pwd_context.verify(request.token, hashed_stored_token):
conn.close()
# Invalidate token to prevent reuse after expiration or failed hash verification
cursor.execute("DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
conn.commit()
raise HTTPException(status_code=400, detail="Invalid or expired reset token.")
# Update user password
new_hashed_password = pwd_context.hash(request.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hashed_password, user_id))
# Delete used token
cursor.execute("DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
conn.commit()
conn.close()
return {"message": "Password has been reset successfully."}
@app.get("/api/items")
async def get_items(list_id: int, current_user: User = Depends(get_current_active_user)):

View File

@@ -4,4 +4,5 @@ requests
passlib[bcrypt]==1.7.4
bcrypt==3.2.0
python-jose[cryptography]
python-multipart
python-multipart
fastapi-mail

View File

@@ -260,6 +260,66 @@
<button id="login-button" type="submit" class="btn btn-primary"></button>
</div>
<div id="login-error" class="text-danger mt-2"></div>
<div class="text-center mt-3">
<a href="#forgot-password" id="forgot-password-link"></a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Forgot Password View -->
<div id="forgot-password-view" class="container mt-5" style="display: none;">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<h3 id="forgot-password-title" class="card-title text-center"></h3>
<form id="forgot-password-form">
<div class="mb-3">
<label id="email-username-label" for="email-username" class="form-label"></label>
<input type="text" class="form-control" id="email-username" required>
</div>
<div class="d-grid">
<button id="send-reset-link-button" type="submit" class="btn btn-primary"></button>
</div>
<div id="forgot-password-status" class="mt-2"></div>
<div class="text-center mt-3">
<a href="#login" id="back-to-login-link"></a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Reset Password View -->
<div id="reset-password-view" class="container mt-5" style="display: none;">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<h3 id="reset-password-title" class="card-title text-center"></h3>
<form id="reset-password-form">
<input type="hidden" id="reset-token">
<div class="mb-3">
<label id="new-password-reset-label" for="new-password-reset" class="form-label"></label>
<input type="password" class="form-control" id="new-password-reset" required>
</div>
<div class="mb-3">
<label id="confirm-new-password-reset-label" for="confirm-new-password-reset" class="form-label"></label>
<input type="password" class="form-control" id="confirm-new-password-reset" required>
</div>
<div class="d-grid">
<button id="set-new-password-button" type="submit" class="btn btn-primary"></button>
</div>
<div id="reset-password-status" class="mt-2"></div>
<div class="text-center mt-3">
<a href="#login" id="back-to-login-from-reset-link"></a>
</div>
</form>
</div>
</div>
@@ -362,15 +422,19 @@
<h5 id="create-user-title" class="card-title"></h5>
<form id="create-user-form">
<div class="row">
<div class="col-md-4 mb-3">
<div class="col-md-3 mb-3">
<label id="username-label" for="new-username" class="form-label"></label>
<input type="text" id="new-username" class="form-control" required>
</div>
<div class="col-md-4 mb-3">
<div class="col-md-3 mb-3">
<label id="password-label" for="new-password" class="form-label"></label>
<input type="password" id="new-password" class="form-control" required>
</div>
<div class="col-md-4 mb-3 d-flex align-items-end">
<div class="col-md-3 mb-3">
<label id="email-label" for="new-user-email" class="form-label"></label>
<input type="email" id="new-user-email" class="form-control">
</div>
<div class="col-md-3 mb-3 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="new-user-is-admin">
<label id="is-admin-label" class="form-check-label" for="new-user-is-admin"></label>
@@ -562,6 +626,9 @@
const loginView = document.getElementById('login-view');
const appView = document.getElementById('app-view');
const userViewContent = document.getElementById('user-view-content');
const forgotPasswordView = document.getElementById('forgot-password-view');
const resetPasswordView = document.getElementById('reset-password-view');
// Login elements
const loginForm = document.getElementById('login-form');
@@ -569,11 +636,28 @@
const passwordInput = document.getElementById('password');
const loginError = document.getElementById('login-error');
const logoutBtn = document.getElementById('logout-btn');
const forgotPasswordLink = document.getElementById('forgot-password-link');
const backToLoginLink = document.getElementById('back-to-login-link');
const backToLoginFromResetLink = document.getElementById('back-to-login-from-reset-link');
// Forgot Password elements
const forgotPasswordForm = document.getElementById('forgot-password-form');
const emailUsernameInput = document.getElementById('email-username');
const forgotPasswordStatus = document.getElementById('forgot-password-status');
// Reset Password elements
const resetPasswordForm = document.getElementById('reset-password-form');
const resetTokenInput = document.getElementById('reset-token');
const newPasswordResetInput = document.getElementById('new-password-reset');
const confirmNewPasswordResetInput = document.getElementById('confirm-new-password-reset');
const resetPasswordStatus = document.getElementById('reset-password-status');
const adminSection = document.getElementById('admin-section');
const createUserForm = document.getElementById('create-user-form');
const newUsernameInput = document.getElementById('new-username');
const newPasswordInput = document.getElementById('new-password');
const newUserEmailInput = document.getElementById('new-user-email');
const newUserIsAdminCheckbox = document.getElementById('new-user-is-admin');
const createUserStatus = document.getElementById('create-user-status');
@@ -735,7 +819,8 @@
const newUser = {
username: newUsernameInput.value,
password: newPasswordInput.value,
is_admin: newUserIsAdminCheckbox.checked
is_admin: newUserIsAdminCheckbox.checked,
email: newUserEmailInput.value || null
};
const response = await fetch('/api/users', {
@@ -760,24 +845,127 @@
}
}
async function handleForgotPassword(event) {
event.preventDefault();
forgotPasswordStatus.textContent = '';
const emailOrUsername = emailUsernameInput.value.trim();
if (!emailOrUsername) {
forgotPasswordStatus.textContent = translations.forgot_password_empty_input || 'Please enter your email or username.';
forgotPasswordStatus.className = 'mt-2 text-danger';
return;
}
try {
const response = await fetch('/api/users/forgot-password', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ email_or_username: emailOrUsername })
});
const data = await response.json();
if (response.ok) {
forgotPasswordStatus.textContent = translations.forgot_password_email_sent || 'If a matching user with an email address was found, a password reset email has been sent.';
forgotPasswordStatus.className = 'mt-2 text-success';
} else {
// Even if there's a backend error (e.g., mail not configured),
// we still show the success message to prevent user enumeration.
forgotPasswordStatus.textContent = translations.forgot_password_email_sent || 'If a matching user with an email address was found, a password reset email has been sent.';
forgotPasswordStatus.className = 'mt-2 text-success';
console.error("Backend error during forgot password:", data.detail);
}
} catch (error) {
forgotPasswordStatus.textContent = translations.forgot_password_generic_error || 'An error occurred. Please try again.';
forgotPasswordStatus.className = 'mt-2 text-danger';
console.error("Network or other error during forgot password:", error);
}
}
async function handleResetPassword(event) {
event.preventDefault();
resetPasswordStatus.textContent = '';
const token = resetTokenInput.value;
const newPassword = newPasswordResetInput.value;
const confirmNewPassword = confirmNewPasswordResetInput.value;
if (newPassword !== confirmNewPassword) {
resetPasswordStatus.textContent = translations.reset_password_match_error || 'New passwords do not match.';
resetPasswordStatus.className = 'mt-2 text-danger';
return;
}
if (newPassword.length < 6) { // Example: enforce minimum password length
resetPasswordStatus.textContent = translations.reset_password_length_error || 'Password must be at least 6 characters long.';
resetPasswordStatus.className = 'mt-2 text-danger';
return;
}
try {
const response = await fetch('/api/users/reset-password', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ token: token, new_password: newPassword })
});
const data = await response.json();
if (response.ok) {
resetPasswordStatus.textContent = translations.reset_password_success || 'Password has been reset successfully. You can now log in.';
resetPasswordStatus.className = 'mt-2 text-success';
setTimeout(() => {
window.location.hash = '#login'; // Redirect to login
}, 3000);
} else {
resetPasswordStatus.textContent = data.detail || translations.reset_password_generic_error || 'Error resetting password.';
resetPasswordStatus.className = 'mt-2 text-danger';
}
} catch (error) {
resetPasswordStatus.textContent = translations.reset_password_generic_error || 'An error occurred. Please try again.';
resetPasswordStatus.className = 'mt-2 text-danger';
console.error("Network or other error during reset password:", error);
}
}
// --- View Management ---
function showLoginView() {
function hideAllViews() {
loginView.style.display = 'none';
appView.style.display = 'none';
loginView.style.display = 'block';
adminSection.style.display = 'none';
if (userViewContent) userViewContent.style.display = 'none';
userViewContent.style.display = 'none';
forgotPasswordView.style.display = 'none';
resetPasswordView.style.display = 'none';
}
function showLoginView() {
hideAllViews();
loginView.style.display = 'block';
}
async function showAppView() {
loginView.style.display = 'none';
hideAllViews();
appView.style.display = 'block';
adminSection.style.display = 'none';
if (userViewContent) userViewContent.style.display = 'none';
connectWebSocket();
await initApp();
}
function showForgotPasswordView() {
hideAllViews();
forgotPasswordView.style.display = 'block';
forgotPasswordStatus.textContent = '';
emailUsernameInput.value = '';
}
function showResetPasswordView(token) {
hideAllViews();
resetPasswordView.style.display = 'block';
resetTokenInput.value = token;
newPasswordResetInput.value = '';
confirmNewPasswordResetInput.value = '';
resetPasswordStatus.textContent = '';
}
// --- App Functions ---
async function fetchCurrentUser() {
const response = await fetch(`/api/users/me?_=${new Date().getTime()}`, { headers: getAuthHeaders() });
@@ -798,6 +986,44 @@
updateTrashSwitchVisibility(); // New call
}
async function fetchAndRenderUsers() {
const userListElement = document.getElementById('user-list');
userListElement.innerHTML = ''; // Clear current list
const response = await fetch('/api/users', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
const users = await response.json();
users.forEach(user => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.textContent = user.username + (user.is_admin ? ' (Admin)' : '');
if (user.id !== currentUser.id) { // Don't allow admin to delete themselves
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-sm';
deleteButton.textContent = translations.delete_user_button || 'Delete';
deleteButton.onclick = async () => {
if (confirm(translations.confirm_delete_user || `Are you sure you want to delete user ${user.username}?`)) {
const deleteResponse = await fetch(`/api/users/${user.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (deleteResponse.status === 401) return handleLogout();
if (deleteResponse.ok) {
await fetchAndRenderUsers(); // Refresh list after deletion
} else {
const errorData = await deleteResponse.json();
alert(errorData.detail || translations.generic_error);
}
}
};
li.appendChild(deleteButton);
}
userListElement.appendChild(li);
});
}
async function fetchTranslations() {
const lang = document.documentElement.lang;
const response = await fetch(`/api/translations/${lang}?_=${new Date().getTime()}`);
@@ -815,6 +1041,22 @@
document.getElementById('login-username-label').textContent = translations.login_username_label;
document.getElementById('login-password-label').textContent = translations.login_password_label;
document.getElementById('login-button').textContent = translations.login_button;
document.getElementById('forgot-password-link').textContent = translations.forgot_password_link || 'Forgot password?';
}
// Forgot Password View
if(document.getElementById('forgot-password-title')) {
document.getElementById('forgot-password-title').textContent = translations.forgot_password_title || 'Forgot Password';
document.getElementById('email-username-label').textContent = translations.email_username_label || 'Email or Username';
document.getElementById('send-reset-link-button').textContent = translations.send_reset_link_button || 'Send Reset Link';
document.getElementById('back-to-login-link').textContent = translations.back_to_login_link || 'Back to Login';
}
// Reset Password View
if(document.getElementById('reset-password-title')) {
document.getElementById('reset-password-title').textContent = translations.reset_password_title || 'Reset Password';
document.getElementById('new-password-reset-label').textContent = translations.new_password_label || 'New Password';
document.getElementById('confirm-new-password-reset-label').textContent = translations.confirm_password_label || 'Confirm New Password';
document.getElementById('set-new-password-button').textContent = translations.set_new_password_button || 'Set New Password';
document.getElementById('back-to-login-from-reset-link').textContent = translations.back_to_login_link || 'Back to Login';
}
// User View
@@ -837,6 +1079,7 @@
document.getElementById('create-user-title').textContent = translations.create_user_title;
document.getElementById('username-label').textContent = translations.username_label;
document.getElementById('password-label').textContent = translations.password_label;
document.getElementById('email-label').textContent = translations.email_label || 'Email';
document.getElementById('is-admin-label').textContent = translations.is_admin_label;
document.getElementById('create-user-button').textContent = translations.create_user_button;
document.getElementById('change-password-title').textContent = translations.change_password_title;
@@ -1740,6 +1983,24 @@
document.getElementById('notification-include-details-switch').addEventListener('change', setSendListOnlySetting);
}
forgotPasswordLink.addEventListener('click', (e) => {
e.preventDefault();
showForgotPasswordView();
});
backToLoginLink.addEventListener('click', (e) => {
e.preventDefault();
showLoginView();
});
backToLoginFromResetLink.addEventListener('click', (e) => {
e.preventDefault();
showLoginView();
});
forgotPasswordForm.addEventListener('submit', handleForgotPassword);
resetPasswordForm.addEventListener('submit', handleResetPassword);
renameListForm.addEventListener('submit', (e) => {
e.preventDefault();
handleConfirmRename();
@@ -1973,6 +2234,13 @@
userInfo.appendChild(lockedBadge);
}
if (user.email) {
const emailSpan = document.createElement('span');
emailSpan.className = 'text-muted ms-2';
emailSpan.textContent = `(${user.email})`;
userInfo.appendChild(emailSpan);
}
li.appendChild(userInfo);
const buttonContainer = document.createElement('div');
@@ -2005,14 +2273,52 @@
// Always fetch translations first, so all UI is translated before showing.
await fetchTranslations();
// Now decide which view to show
// Handle routing based on URL hash
const hash = window.location.hash;
const urlParams = new URLSearchParams(window.location.search);
const resetToken = urlParams.get('token');
if (localStorage.getItem('access_token')) {
showAppView();
// If logged in, check if there's a reset token in URL
if (hash === '#reset-password' && resetToken) {
showResetPasswordView(resetToken);
} else {
showAppView();
}
} else {
showLoginView();
// Not logged in
if (hash === '#forgot-password') {
showForgotPasswordView();
} else if (hash === '#reset-password' && resetToken) {
showResetPasswordView(resetToken);
}
else {
showLoginView();
}
}
}
// --- Event Listeners for new views ---
forgotPasswordLink.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = '#forgot-password';
});
backToLoginLink.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = '#login';
});
backToLoginFromResetLink.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = '#login';
});
forgotPasswordForm.addEventListener('submit', handleForgotPassword);
resetPasswordForm.addEventListener('submit', handleResetPassword);
// Call main function to initialize the app
main(); // Run the app
const themeSwitcher = document.getElementById('theme-switcher');