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.

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 00:41:42 +01:00
parent 075662a8ad
commit 26e7184c34
4 changed files with 298 additions and 134 deletions

11
example.env Normal file
View File

@@ -0,0 +1,11 @@
GOTIFY_URL="example.gotify.com"
MAIL_USERNAME="your_email@example.com"
MAIL_PASSWORD="your_email_password"
MAIL_FROM="your_email@example.com"
MAIL_SERVER="smtp.example.com"
MAIL_PORT="587"
MAIL_STARTTLS="True"
MAIL_SSL_TLS="False"
MAIL_SUPPRESS_SEND="False"

330
main.py
View File

@@ -1,4 +1,5 @@
import logging # Added this line
import secrets
import logging # Added this line
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig
from collections import defaultdict
@@ -16,7 +17,9 @@ 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
logging.basicConfig(level=logging.INFO,
# Added this line
format='%(asctime)s - %(levelname)s - %(message)s')
app = FastAPI()
@@ -29,11 +32,13 @@ 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.
mail_starttls = os.getenv("MAIL_STARTTLS", "True").lower() in ('true', '1', 't')
mail_starttls = os.getenv(
"MAIL_STARTTLS", "True").lower() in ('true', '1', 't')
mail_ssl_tls = os.getenv("MAIL_SSL_TLS", "False").lower() in ('true', '1', 't')
if mail_starttls and mail_ssl_tls:
raise ValueError("MAIL_STARTTLS and MAIL_SSL_TLS cannot both be True. Please check your .env file.")
raise ValueError(
"MAIL_STARTTLS and MAIL_SSL_TLS cannot both be True. Please check your .env file.")
conf = ConnectionConfig(
MAIL_USERNAME=os.getenv("MAIL_USERNAME", "your_email@example.com"),
@@ -43,12 +48,15 @@ conf = ConnectionConfig(
MAIL_SERVER=os.getenv("MAIL_SERVER", "smtp.example.com"),
MAIL_STARTTLS=mail_starttls,
MAIL_SSL_TLS=mail_ssl_tls,
USE_CREDENTIALS=os.getenv("USE_CREDENTIALS", "True").lower() in ('true', '1', 't'),
VALIDATE_CERTS=os.getenv("VALIDATE_CERTS", "True").lower() in ('true', '1', 't'),
USE_CREDENTIALS=os.getenv(
"USE_CREDENTIALS", "True").lower() in ('true', '1', 't'),
VALIDATE_CERTS=os.getenv(
"VALIDATE_CERTS", "True").lower() in ('true', '1', 't'),
TEMPLATE_FOLDER='./templates/email',
)
conf.SUPPRESS_SEND = os.getenv("MAIL_SUPPRESS_SEND", "False").lower() in ('true', '1', 't')
conf.SUPPRESS_SEND = os.getenv(
"MAIL_SUPPRESS_SEND", "False").lower() in ('true', '1', 't')
fm = FastMail(conf)
@@ -113,41 +121,24 @@ class ShoppingListCreate(BaseModel):
class NewUser(BaseModel):
username: str
password: str
is_admin: bool = False
email: Optional[str] = None
class ForgotPasswordRequest(BaseModel):
email_or_username: str
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
# --- WebSocket Connection Manager ---
@@ -186,7 +177,8 @@ def get_user(username: str):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT *, email FROM users WHERE username = ?", (username,))
cursor.execute(
"SELECT *, email FROM users WHERE username = ?", (username,))
user_data = cursor.fetchone()
conn.close()
if user_data:
@@ -203,6 +195,7 @@ def get_user(username: str):
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
@@ -273,14 +266,32 @@ def init_db():
)
""")
if not column_exists(cursor, "users", "failed_login_attempts"):
cursor.execute("ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0")
cursor.execute(
"ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0")
if not column_exists(cursor, "users", "is_locked"):
cursor.execute("ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")
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")
# Add a unique index for the email column, but only for non-null values
try:
cursor.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (email) WHERE email IS NOT NULL AND email != ''")
except sqlite3.OperationalError as e:
if "duplicate" in str(e).lower():
print("\n" + "="*60)
print("FEHLER: Konnte keinen UNIQUE Index für E-Mails erstellen, da Duplikate in der Datenbank vorhanden sind.")
print(
"Bitte beheben Sie die doppelten E-Mail-Adressen manuell und starten Sie die Anwendung neu.")
print("Sie können ein Werkzeug wie 'DB Browser for SQLite' verwenden, um die Datei 'data/shoppinglist.db' zu bearbeiten.")
print("="*60 + "\n")
# We don't exit here, to allow the app to run so the user can potentially fix it via the UI if possible.
else:
raise e
# --- Password Reset Tokens Table ---
cursor.execute("""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
@@ -308,7 +319,8 @@ def init_db():
)
""")
if not column_exists(cursor, "shopping_lists", "use_trash"):
cursor.execute("ALTER TABLE shopping_lists ADD COLUMN use_trash BOOLEAN NOT NULL DEFAULT 1")
cursor.execute(
"ALTER TABLE shopping_lists ADD COLUMN use_trash BOOLEAN NOT NULL DEFAULT 1")
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
@@ -323,14 +335,17 @@ def init_db():
""")
# --- Data Migration from items_old ---
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='items_old'")
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='items_old'")
if cursor.fetchone():
cursor.execute("INSERT OR IGNORE INTO shopping_lists (name) VALUES ('Standard')")
default_list_id = cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Standard'").fetchone()[0]
cursor.execute(
"INSERT OR IGNORE INTO shopping_lists (name) VALUES ('Standard')")
default_list_id = cursor.execute(
"SELECT id FROM shopping_lists WHERE name = 'Standard'").fetchone()[0]
cursor.execute("PRAGMA table_info(items_old)")
old_cols = {info[1] for info in cursor.fetchall()}
cols_to_select = ['id', 'name', 'is_standard']
cols_to_insert = ['id', 'name', 'is_standard']
if 'created_by_user_id' in old_cols:
@@ -344,11 +359,11 @@ def init_db():
insert_str = ", ".join(cols_to_insert + ['list_id'])
try:
cursor.execute(f"INSERT INTO items ({insert_str}) SELECT {select_str}, ? FROM items_old", (default_list_id,))
cursor.execute(
f"INSERT INTO items ({insert_str}) SELECT {select_str}, ? FROM items_old", (default_list_id,))
except sqlite3.IntegrityError:
print("Warnung: Beim Migrieren der Daten sind Duplikate aufgetreten. Einige Einträge wurden möglicherweise nicht migriert.")
cursor.execute("DROP TABLE items_old")
conn.commit()
print("Migration erfolgreich abgeschlossen.")
@@ -375,16 +390,20 @@ def init_db():
cursor.execute("SELECT COUNT(*) FROM shopping_lists")
if cursor.fetchone()[0] == 0:
cursor.execute("INSERT INTO shopping_lists (name) VALUES ('Standard')")
cursor.execute("SELECT COUNT(*) FROM shopping_lists WHERE name = 'Papierkorb'")
cursor.execute(
"SELECT COUNT(*) FROM shopping_lists WHERE name = 'Papierkorb'")
if cursor.fetchone()[0] == 0:
cursor.execute("INSERT INTO shopping_lists (name) VALUES ('Papierkorb')")
cursor.execute(
"INSERT INTO shopping_lists (name) VALUES ('Papierkorb')")
# Enforce global trash setting on startup
cursor.execute("SELECT value FROM app_settings WHERE key = 'global_use_trash'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'global_use_trash'")
global_trash_setting = cursor.fetchone()
if global_trash_setting and global_trash_setting[0] == '1':
cursor.execute("UPDATE shopping_lists SET use_trash = 1 WHERE name != 'Papierkorb'")
cursor.execute(
"UPDATE shopping_lists SET use_trash = 1 WHERE name != 'Papierkorb'")
conn.commit()
conn.close()
@@ -411,7 +430,8 @@ async def get_lists(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, name, use_trash FROM shopping_lists ORDER BY name")
cursor.execute(
"SELECT id, name, use_trash FROM shopping_lists ORDER BY name")
lists = cursor.fetchall()
conn.close()
return [dict(row) for row in lists]
@@ -423,17 +443,19 @@ async def create_list(list_data: ShoppingListCreate, current_user: User = Depend
cursor = conn.cursor()
# Get global trash setting
cursor.execute("SELECT value FROM app_settings WHERE key = 'global_use_trash'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'global_use_trash'")
global_trash_result = cursor.fetchone()
use_trash_default = global_trash_result[0] == '1' if global_trash_result else True
name_to_insert = list_data.name.strip()
if not name_to_insert:
# Find the highest existing "Standard(n)" name
cursor.execute("SELECT name FROM shopping_lists WHERE name LIKE 'Standard%' ORDER BY name DESC")
cursor.execute(
"SELECT name FROM shopping_lists WHERE name LIKE 'Standard%' ORDER BY name DESC")
last_standard = cursor.fetchone()
if not last_standard:
name_to_insert = "Standard"
name_to_insert = "Standard"
else:
last_name = last_standard[0]
if last_name == "Standard":
@@ -447,13 +469,15 @@ async def create_list(list_data: ShoppingListCreate, current_user: User = Depend
name_to_insert = f"{last_name}(1)"
try:
cursor.execute("INSERT INTO shopping_lists (name, use_trash) VALUES (?, ?)", (name_to_insert, use_trash_default))
cursor.execute("INSERT INTO shopping_lists (name, use_trash) VALUES (?, ?)",
(name_to_insert, use_trash_default))
new_list_id = cursor.lastrowid
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="A list with this name already exists.")
raise HTTPException(
status_code=400, detail="A list with this name already exists.")
conn.close()
await manager.broadcast_update()
return {"id": new_list_id, "name": name_to_insert, "use_trash": use_trash_default}
@@ -464,21 +488,25 @@ async def rename_list(list_id: int, list_data: ShoppingListCreate, current_user:
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT name, use_trash FROM shopping_lists WHERE id = ?", (list_id,))
cursor.execute(
"SELECT name, use_trash FROM shopping_lists WHERE id = ?", (list_id,))
list_result = cursor.fetchone()
if list_result and list_result[0] == "Papierkorb":
conn.close()
raise HTTPException(status_code=400, detail="Cannot rename the trash bin.")
raise HTTPException(
status_code=400, detail="Cannot rename the trash bin.")
try:
cursor.execute("UPDATE shopping_lists SET name = ?, use_trash = ? WHERE id = ?", (list_data.name, list_data.use_trash, list_id))
cursor.execute("UPDATE shopping_lists SET name = ?, use_trash = ? WHERE id = ?",
(list_data.name, list_data.use_trash, list_id))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="A list with this name already exists.")
raise HTTPException(
status_code=400, detail="A list with this name already exists.")
finally:
conn.close()
await manager.broadcast_update()
return {"id": list_id, "name": list_data.name, "use_trash": list_data.use_trash}
@@ -492,17 +520,18 @@ async def delete_list(list_id: int, current_user: User = Depends(get_current_act
list_name_result = cursor.fetchone()
if list_name_result and list_name_result[0] == "Papierkorb":
conn.close()
raise HTTPException(status_code=400, detail="Cannot delete the trash bin.")
raise HTTPException(
status_code=400, detail="Cannot delete the trash bin.")
# Delete associated items first
cursor.execute("DELETE FROM items WHERE list_id = ?", (list_id,))
# Then delete the list
cursor.execute("DELETE FROM shopping_lists WHERE id = ?", (list_id,))
conn.commit()
conn.close()
await manager.broadcast_update()
return {"status": "ok"}
@@ -596,19 +625,29 @@ async def create_user(user: NewUser, current_user: User = Depends(get_current_ac
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (user.username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered")
hashed_password = pwd_context.hash(user.password)
cursor.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(user.username, hashed_password, user.is_admin)
)
conn.commit()
conn.close()
try:
cursor.execute(
"INSERT INTO users (username, password_hash, is_admin, email) VALUES (?, ?, ?, ?)",
(user.username, hashed_password, user.is_admin,
user.email if user.email else None)
)
conn.commit()
except sqlite3.IntegrityError as e:
conn.close()
if "UNIQUE constraint failed: users.username" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
elif "UNIQUE constraint failed: users.email" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
else:
# Generic message for other integrity errors
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="A database integrity error occurred.")
finally:
conn.close()
return {"status": f"User {user.username} created"}
@@ -645,6 +684,7 @@ async def change_password(
class EmailUpdateRequest(BaseModel):
email: str
@app.put("/api/users/me/email", status_code=status.HTTP_200_OK)
async def update_email(
request: EmailUpdateRequest,
@@ -656,15 +696,23 @@ async def update_email(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid email format.",
)
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET email = ? WHERE id = ?",
(request.email, current_user.id)
)
conn.commit()
conn.close()
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
cursor.execute(
"UPDATE users SET email = ? WHERE id = ?",
(email_to_store, current_user.id)
)
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already in use by another account.")
finally:
conn.close()
return {"status": "Email updated successfully"}
@@ -686,7 +734,8 @@ async def get_users(current_user: UserInDB = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, username, is_admin, is_locked, email FROM users")
cursor.execute(
"SELECT id, username, is_admin, is_locked, email FROM users")
users = cursor.fetchall()
conn.close()
return [dict(row) for row in users]
@@ -752,7 +801,6 @@ 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: Request, forgot_req: ForgotPasswordRequest):
@@ -766,12 +814,14 @@ async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
user_info = None
# Try to find user by email
cursor.execute("SELECT id, username, email FROM users WHERE email = ?", (forgot_req.email_or_username,))
cursor.execute("SELECT id, username, email FROM users WHERE email = ?",
(forgot_req.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 = ?", (forgot_req.email_or_username,))
cursor.execute("SELECT id, username, email FROM users WHERE username = ?",
(forgot_req.email_or_username,))
user_info = cursor.fetchone()
if not user_info or not user_info[2]: # user_info[2] is the email
@@ -790,7 +840,7 @@ async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
conn.close()
reset_link = f"{request.url.scheme}://{request.url.hostname}:{request.url.port}/#reset-password?token={token}"
email_body = f"""
<p>{translations.get('forgot_password_email_body_hello', 'Hello {username},').format(username=username)}</p>
<p>{translations.get('forgot_password_email_body_request_text', 'You have requested a password reset for your account.')}</p>
@@ -801,7 +851,8 @@ async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
"""
message = MessageSchema(
subject=translations.get('forgot_password_email_subject', 'Password Reset Request'),
subject=translations.get(
'forgot_password_email_subject', 'Password Reset Request'),
recipients=[email],
body=email_body,
subtype="html"
@@ -816,12 +867,14 @@ async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
return {"message": translations.get("forgot_password_email_sent", "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")
cursor.execute(
"SELECT user_id, expires_at, token FROM password_reset_tokens")
all_tokens = cursor.fetchall()
reset_data = None
@@ -835,24 +888,29 @@ async def reset_password(request: ResetPasswordRequest):
if not reset_data:
conn.close()
raise HTTPException(status_code=400, detail="Invalid or expired reset token.")
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:
# It's important to also delete the expired token from the database
cursor.execute("DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
cursor.execute(
"DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
conn.commit()
conn.close()
raise HTTPException(status_code=400, detail="Invalid or expired reset token.")
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))
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,))
cursor.execute(
"DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
conn.commit()
conn.close()
@@ -878,7 +936,8 @@ async def add_item(item: Item, current_user: UserInDB = Depends(get_current_acti
added_count = 0
for name in item.names:
cursor.execute("SELECT id FROM items WHERE name = ? AND list_id = ?", (name, item.list_id))
cursor.execute(
"SELECT id FROM items WHERE name = ? AND list_id = ?", (name, item.list_id))
if cursor.fetchone():
continue
@@ -909,7 +968,8 @@ async def update_item(item_id: int, item: ItemUpdate, current_user: User = Depen
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
try:
cursor.execute("UPDATE items SET name = ? WHERE id = ?", (item.name, item_id))
cursor.execute("UPDATE items SET name = ? WHERE id = ?",
(item.name, item_id))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
@@ -926,7 +986,8 @@ async def update_item(item_id: int, item: ItemUpdate, current_user: User = Depen
async def mark_item(item_id: int, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("UPDATE items SET marked = NOT marked WHERE id = ?", (item_id,))
cursor.execute(
"UPDATE items SET marked = NOT marked WHERE id = ?", (item_id,))
conn.commit()
await manager.broadcast_update()
conn.close()
@@ -948,29 +1009,34 @@ async def delete_marked_items(request: DeletionRequest, current_user: User = Dep
conn.close()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
# Get Papierkorb list ID
cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Papierkorb'")
trash_list_id_result = cursor.fetchone()
if not trash_list_id_result:
# This should not happen if init_db is correct
conn.close()
raise HTTPException(status_code=500, detail="Trash bin list not found.")
raise HTTPException(
status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
if request.list_id == trash_list_id:
# Permanent deletion from trash
cursor.execute("DELETE FROM items WHERE marked = 1 AND list_id = ?", (trash_list_id,))
cursor.execute(
"DELETE FROM items WHERE marked = 1 AND list_id = ?", (trash_list_id,))
else:
# Check if the list uses the trash
cursor.execute("SELECT use_trash FROM shopping_lists WHERE id = ?", (request.list_id,))
cursor.execute(
"SELECT use_trash FROM shopping_lists WHERE id = ?", (request.list_id,))
use_trash_result = cursor.fetchone()
if use_trash_result and use_trash_result[0]:
# Move to trash and unmark
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE marked = 1 AND list_id = ?", (trash_list_id, request.list_id))
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE marked = 1 AND list_id = ?",
(trash_list_id, request.list_id))
else:
# Delete directly
cursor.execute("DELETE FROM items WHERE marked = 1 AND list_id = ?", (request.list_id,))
cursor.execute(
"DELETE FROM items WHERE marked = 1 AND list_id = ?", (request.list_id,))
conn.commit()
conn.close()
@@ -999,12 +1065,13 @@ async def set_deletion_password(request: DeletionRequest, current_user: User = D
cursor = conn.cursor()
if not request.password:
cursor.execute("DELETE FROM app_settings WHERE key = 'deletion_password'")
cursor.execute(
"DELETE FROM app_settings WHERE key = 'deletion_password'")
else:
hashed_password = pwd_context.hash(request.password)
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('deletion_password', hashed_password))
conn.commit()
conn.close()
return {"status": "ok"}
@@ -1018,7 +1085,8 @@ class NotificationSendListOnlySetting(BaseModel):
async def get_notification_send_list_only_setting(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
result = cursor.fetchone()
conn.close()
# Default to disabled (send both user and list) if not set
@@ -1029,12 +1097,14 @@ async def get_notification_send_list_only_setting(current_user: User = Depends(g
@app.post("/api/settings/notification-send-list-only")
async def set_notification_send_list_only_setting(request: NotificationSendListOnlySetting, current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can change this setting")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change this setting")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
value_to_store = '1' if request.enabled else '0'
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", ('notification_send_list_only', value_to_store))
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('notification_send_list_only', value_to_store))
conn.commit()
conn.close()
return {"status": "ok"}
@@ -1046,7 +1116,8 @@ async def restore_item(item_id: int, request: ItemRestore, current_user: User =
cursor = conn.cursor()
# Check if target list exists
cursor.execute("SELECT id FROM shopping_lists WHERE id = ?", (request.target_list_id,))
cursor.execute("SELECT id FROM shopping_lists WHERE id = ?",
(request.target_list_id,))
if not cursor.fetchone():
conn.close()
raise HTTPException(status_code=404, detail="Target list not found.")
@@ -1056,19 +1127,22 @@ async def restore_item(item_id: int, request: ItemRestore, current_user: User =
trash_list_id_result = cursor.fetchone()
if not trash_list_id_result:
conn.close()
raise HTTPException(status_code=500, detail="Trash bin list not found.")
raise HTTPException(
status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
# Check if item is in trash
cursor.execute("SELECT list_id FROM items WHERE id = ?", (item_id,))
item_list_id_result = cursor.fetchone()
if not item_list_id_result or item_list_id_result[0] != trash_list_id:
conn.close()
raise HTTPException(status_code=400, detail="Item is not in the trash bin.")
raise HTTPException(
status_code=400, detail="Item is not in the trash bin.")
# Restore to target list and unmark
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE id = ?", (request.target_list_id, item_id))
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE id = ?",
(request.target_list_id, item_id))
conn.commit()
conn.close()
await manager.broadcast_update()
@@ -1087,25 +1161,28 @@ async def delete_item(item_id: int, current_user: User = Depends(get_current_act
conn.close()
raise HTTPException(status_code=404, detail="Item not found.")
item_list_id = item_list_id_result[0]
# Get Papierkorb list ID
cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Papierkorb'")
trash_list_id_result = cursor.fetchone()
if not trash_list_id_result:
conn.close()
raise HTTPException(status_code=500, detail="Trash bin list not found.")
raise HTTPException(
status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
# Check if the list uses the trash
cursor.execute("SELECT use_trash FROM shopping_lists WHERE id = ?", (item_list_id,))
cursor.execute(
"SELECT use_trash FROM shopping_lists WHERE id = ?", (item_list_id,))
use_trash_result = cursor.fetchone()
if item_list_id == trash_list_id or (use_trash_result and not use_trash_result[0]):
# Permanent deletion from trash or if trash is disabled
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
else:
# Move to trash and unmark
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE id = ?", (trash_list_id, item_id))
cursor.execute(
"UPDATE items SET list_id = ?, marked = 0 WHERE id = ?", (trash_list_id, item_id))
conn.commit()
conn.close()
@@ -1120,12 +1197,14 @@ async def trigger_notification(list_id: int, list_name: str, lang: str = 'en', c
cursor = conn.cursor()
# Get the notification detail setting
cursor.execute("SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
setting_result = cursor.fetchone()
# Default to False (send both) if not set in the database
send_list_only = setting_result[0] == '1' if setting_result else False
cursor.execute("SELECT name FROM items WHERE list_id = ? ORDER BY name", (list_id,))
cursor.execute(
"SELECT name FROM items WHERE list_id = ? ORDER BY name", (list_id,))
items = cursor.fetchall()
conn.close()
@@ -1138,14 +1217,14 @@ async def trigger_notification(list_id: int, list_name: str, lang: str = 'en', c
else:
message = ", ".join(item_names)
notification_title = translations.get("notification_title", "Shopping List Updated")
notification_title = translations.get(
"notification_title", "Shopping List Updated")
if send_list_only:
full_title = f"{list_name}: {notification_title}"
else:
full_title = f"{current_user.username} @ {list_name}: {notification_title}"
status = send_gotify_notification(
full_title,
message
@@ -1192,32 +1271,39 @@ async def websocket_endpoint(websocket: WebSocket, token: str):
class UseTrashSetting(BaseModel):
enabled: bool
@app.get("/api/settings/global-use-trash", response_model=UseTrashSetting)
async def get_global_use_trash(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT value FROM app_settings WHERE key = 'global_use_trash'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'global_use_trash'")
result = cursor.fetchone()
conn.close()
is_enabled = result[0] == '1' if result else True # Default to True if not set
# Default to True if not set
is_enabled = result[0] == '1' if result else True
return {"enabled": is_enabled}
@app.post("/api/settings/global-use-trash")
async def set_global_use_trash(request: UseTrashSetting, current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can change this setting")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change this setting")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
value_to_store_setting = '1' if request.enabled else '0'
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", ('global_use_trash', value_to_store_setting))
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('global_use_trash', value_to_store_setting))
# When the admin toggles the global setting, all lists should default to using the trash.
# If the global setting is enabled, this is enforced.
# If it's disabled, lists are reset to the default 'on' state, giving control back to the user.
cursor.execute("UPDATE shopping_lists SET use_trash = 1 WHERE name != 'Papierkorb'")
cursor.execute(
"UPDATE shopping_lists SET use_trash = 1 WHERE name != 'Papierkorb'")
conn.commit()
conn.close()
await manager.broadcast_update()

View File

@@ -703,6 +703,8 @@
const userEmailInput = document.getElementById('user-email-input');
const emailUpdateStatus = document.getElementById('email-update-status');
const changePasswordForm = document.getElementById('change-password-form');
// App elements
const pageTitle = document.getElementById('page-title');
const itemList = document.getElementById('item-list');
@@ -890,11 +892,17 @@
async function handleForgotPassword(event) {
event.preventDefault();
forgotPasswordStatus.textContent = '';
const sendButton = document.getElementById('send-reset-link-button');
const originalButtonText = translations.send_reset_link_button || 'Send Reset Link';
sendButton.disabled = true;
sendButton.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> ${translations.sending || 'Sending...'}`;
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';
sendButton.disabled = false;
sendButton.innerHTML = originalButtonText;
return;
}
@@ -907,20 +915,22 @@
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';
// Always show a 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';
if (!response.ok) {
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);
} finally {
setTimeout(() => {
sendButton.disabled = false;
sendButton.innerHTML = originalButtonText;
}, 2000); // Keep disabled for a bit to prevent spamming
}
}
@@ -995,6 +1005,52 @@
}
}
async function handleChangePassword(event) {
event.preventDefault();
const statusEl = document.getElementById('change-password-status');
statusEl.textContent = '';
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password-change').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword !== confirmPassword) {
statusEl.textContent = translations.reset_password_match_error || 'New passwords do not match.';
statusEl.className = 'mt-2 text-danger';
return;
}
if (newPassword.length < 6) {
statusEl.textContent = translations.reset_password_length_error || 'Password must be at least 6 characters long.';
statusEl.className = 'mt-2 text-danger';
return;
}
try {
const response = await fetch('/api/users/me/password', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
if (response.ok) {
statusEl.textContent = translations.change_password_success || 'Password changed successfully.';
statusEl.className = 'mt-2 text-success';
document.getElementById('change-password-form').reset();
} else {
const data = await response.json();
statusEl.textContent = data.detail || translations.change_password_error || 'Error changing password.';
statusEl.className = 'mt-2 text-danger';
}
} catch (error) {
statusEl.textContent = translations.change_password_error || 'Error changing password.';
statusEl.className = 'mt-2 text-danger';
}
}
// --- View Management ---
function hideAllViews() {
@@ -2072,6 +2128,7 @@
logoutBtn.addEventListener('click', handleLogout);
createUserForm.addEventListener('submit', handleCreateUser);
emailUpdateForm.addEventListener('submit', handleUpdateEmail);
changePasswordForm.addEventListener('submit', handleChangePassword);
addItemForm.addEventListener('submit', async (event) => {
event.preventDefault();
await addItemsFromInput(itemNameInput.value, true);

View File

@@ -108,13 +108,18 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"back_to_login_link": "Zurück zum Login",
"reset_password_title": "Passwort zurücksetzen",
"set_new_password_button": "Neues Passwort festlegen",
"forgot_password_email_sent": "Wenn ein passender Benutzer mit einer E-Mail-Adresse gefunden wurde, wurde eine E-Mail zum Zurücksetzen des Passworts gesendet.",
"forgot_password_email_sent": "Falls ein Konto mit den angegebenen Daten und einer hinterlegten E-Mail-Adresse existiert, wurde ein Link zum Zurücksetzen des Passworts versendet.",
"unlock_user_button": "Entsperren",
"confirm_unlock_user": "Sind Sie sicher, dass Sie den Benutzer '{username}' entsperren möchten?",
"delete_user_button": "Löschen",
"confirm_delete_user": "Sind Sie sicher, dass Sie den Benutzer '{username}' löschen möchten?",
"generic_error": "Ein Fehler ist aufgetreten.",
"email_label": "E-Mail"
"email_label": "E-Mail",
"change_password_success": "Passwort erfolgreich geändert.",
"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..."
}
else: # Fallback to English
return {
@@ -199,10 +204,15 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"back_to_login_link": "Back to Login",
"reset_password_title": "Reset Password",
"set_new_password_button": "Set New Password",
"forgot_password_email_sent": "If a matching user with an email address was found, a password reset email has been sent.",
"forgot_password_email_sent": "If an account with the provided data and a stored email address exists, a password reset link has been sent.",
"unlock_user_button": "Unlock",
"confirm_unlock_user": "Are you sure you want to unlock {username}?",
"delete_user_button": "Delete", "confirm_delete_user": "Are you sure you want to delete user {username}?",
"generic_error": "An error occurred.",
"email_label": "Email"
"email_label": "Email",
"change_password_success": "Password changed successfully.",
"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..."
}