feat: password change + split account/household settings UI
CI / test-and-package (push) Successful in 43s
CI / build-android (push) Successful in 1m10s

- New /change-password route: validates current password,
  enforces min 4 chars, requires confirmation match
- update_password() helper in models.py
- Dashboard UI split into two collapsible sections:
  👤 Account Settings (password, ntfy, delete account)
  🏠 Household Settings (admin only: public link, timezone,
     delete household)
- 14 new i18n keys with fr/de translations
- Restored accidentally dropped 'Delete My Account' key
This commit is contained in:
agentbox
2026-08-01 12:23:30 +00:00
parent 4abbbecce6
commit bda54eda3a
4 changed files with 114 additions and 21 deletions
+54 -8
View File
@@ -166,14 +166,6 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"fr": "⚙ Paramètres du compte", "fr": "⚙ Paramètres du compte",
"de": "⚙ Kontoeinstellungen", "de": "⚙ Kontoeinstellungen",
}, },
"Delete My Account": {
"fr": "Supprimer mon compte",
"de": "Mein Konto löschen",
},
"Enter your password to confirm": {
"fr": "Entrez votre mot de passe pour confirmer",
"de": "Geben Sie Ihr Passwort zur Bestätigung ein",
},
"Delete Household": { "Delete Household": {
"fr": "Supprimer le foyer", "fr": "Supprimer le foyer",
"de": "Haushalt löschen", "de": "Haushalt löschen",
@@ -466,6 +458,60 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"fr": "Copié !", "fr": "Copié !",
"de": "Kopiert!", "de": "Kopiert!",
}, },
# ── Password change ──
"👤 Account Settings": {
"fr": "👤 Paramètres du compte",
"de": "👤 Kontoeinstellungen",
},
"🏠 Household Settings": {
"fr": "🏠 Paramètres du foyer",
"de": "🏠 Haushaltseinstellungen",
},
"Change Password": {
"fr": "Changer le mot de passe",
"de": "Passwort ändern",
},
"Current password": {
"fr": "Mot de passe actuel",
"de": "Aktuelles Passwort",
},
"New password": {
"fr": "Nouveau mot de passe",
"de": "Neues Passwort",
},
"Confirm new password": {
"fr": "Confirmer le nouveau mot de passe",
"de": "Neues Passwort bestätigen",
},
"Password changed successfully.": {
"fr": "Mot de passe changé avec succès.",
"de": "Passwort erfolgreich geändert.",
},
"Current password is incorrect.": {
"fr": "Le mot de passe actuel est incorrect.",
"de": "Das aktuelle Passwort ist falsch.",
},
"New password must be at least 4 characters.": {
"fr": "Le nouveau mot de passe doit comporter au moins 4 caractères.",
"de": "Das neue Passwort muss mindestens 4 Zeichen lang sein.",
},
"New passwords do not match.": {
"fr": "Les nouveaux mots de passe ne correspondent pas.",
"de": "Die neuen Passwörter stimmen nicht überein.",
},
"This will permanently delete your account and all your responses.": {
"fr": "Cela supprimera définitivement votre compte et toutes vos réponses.",
"de": "Dies löscht Ihr Konto und alle Ihre Antworten dauerhaft.",
},
"Delete My Account": {
"fr": "Supprimer mon compte",
"de": "Mein Konto löschen",
},
"Enter your password to confirm": {
"fr": "Entrez votre mot de passe pour confirmer",
"de": "Geben Sie Ihr Passwort zur Bestätigung ein",
},
} }
+29 -1
View File
@@ -12,7 +12,7 @@ from flask import (
flash, flash,
) )
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from models import ( from models import (
get_dashboard_data, get_dashboard_data,
@@ -33,6 +33,7 @@ from models import (
get_household_by_public_token, get_household_by_public_token,
generate_public_token, generate_public_token,
set_public_enabled, set_public_enabled,
update_password,
) )
from i18n import t from i18n import t
from ntfy import send_test_notification, send_meal_reminder from ntfy import send_test_notification, send_meal_reminder
@@ -191,6 +192,33 @@ def delete_account():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@meals_bp.route("/change-password", methods=["POST"])
@login_required
def change_password():
"""Change the logged-in user's password."""
current = request.form.get("current_password", "")
new = request.form.get("new_password", "")
confirm = request.form.get("confirm_password", "")
user = get_user_by_id(session["user_id"])
if not check_password_hash(user["password_hash"], current):
flash(t("Current password is incorrect."), "error")
return redirect(url_for("meals.dashboard"))
if len(new) < 4:
flash(t("New password must be at least 4 characters."), "error")
return redirect(url_for("meals.dashboard"))
if new != confirm:
flash(t("New passwords do not match."), "error")
return redirect(url_for("meals.dashboard"))
update_password(session["user_id"], generate_password_hash(new))
flash(t("Password changed successfully."), "success")
return redirect(url_for("meals.dashboard"))
@meals_bp.route("/admin/remove-user/<int:user_id>", methods=["POST"]) @meals_bp.route("/admin/remove-user/<int:user_id>", methods=["POST"])
@login_required @login_required
def admin_remove_user(user_id): def admin_remove_user(user_id):
+7
View File
@@ -195,6 +195,13 @@ def set_public_enabled(household_id: int, enabled: bool) -> None:
# ── Users ───────────────────────────────────────────────────────────────────── # ── Users ─────────────────────────────────────────────────────────────────────
def update_password(user_id: int, new_password_hash: str) -> None:
"""Update a user's password hash."""
with get_db() as db:
db.execute("UPDATE users SET password_hash = ? WHERE id = ?",
(new_password_hash, user_id))
def get_user_by_id(user_id: int) -> dict | None: def get_user_by_id(user_id: int) -> dict | None:
"""Fetch a single user by ID.""" """Fetch a single user by ID."""
with get_db() as db: with get_db() as db:
+23 -11
View File
@@ -85,15 +85,14 @@
<div class="manage-section"> <div class="manage-section">
<details class="manage-details"> <details class="manage-details">
<summary>{{ t(' Account Settings') }}</summary> <summary>{{ t('👤 Account Settings') }}</summary>
<div class="manage-forms"> <div class="manage-forms">
<form method="POST" action="{{ url_for('meals.delete_account') }}" <form method="POST" action="{{ url_for('meals.change_password') }}" class="manage-form">
onsubmit="return confirm(this.dataset.confirmMsg);" <h4>🔑 {{ t('Change Password') }}</h4>
data-confirm-msg="{{ t('Delete YOUR account? This cannot be undone.') }}" <input type="password" name="current_password" placeholder="{{ t('Current password') }}" required>
class="manage-form"> <input type="password" name="new_password" placeholder="{{ t('New password') }}" required style="margin-top: 8px;">
<h4>{{ t('Delete My Account') }}</h4> <input type="password" name="confirm_password" placeholder="{{ t('Confirm new password') }}" required style="margin-top: 8px;">
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required> <button type="submit" class="btn btn-primary btn-sm" style="margin-top: 8px;">{{ t('Change Password') }}</button>
<button class="btn btn-danger">{{ t('Delete My Account') }}</button>
</form> </form>
<form method="POST" action="{{ url_for('meals.ntfy_settings') }}" class="manage-form"> <form method="POST" action="{{ url_for('meals.ntfy_settings') }}" class="manage-form">
@@ -113,7 +112,22 @@
</div> </div>
</form> </form>
<form method="POST" action="{{ url_for('meals.delete_account') }}"
onsubmit="return confirm(this.dataset.confirmMsg);"
data-confirm-msg="{{ t('Delete YOUR account? This cannot be undone.') }}"
class="manage-form">
<h4>{{ t('Delete My Account') }}</h4>
<p class="hint">{{ t('This will permanently delete your account and all your responses.') }}</p>
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required>
<button class="btn btn-danger">{{ t('Delete My Account') }}</button>
</form>
</div>
</details>
{% if is_admin %} {% if is_admin %}
<details class="manage-details" style="margin-top: 8px;">
<summary>{{ t('🏠 Household Settings') }}</summary>
<div class="manage-forms">
<form method="POST" action="{{ url_for('meals.admin_public_link') }}" class="manage-form"> <form method="POST" action="{{ url_for('meals.admin_public_link') }}" class="manage-form">
<h4>🔗 {{ t('Public Sharing Link') }}</h4> <h4>🔗 {{ t('Public Sharing Link') }}</h4>
<p class="hint">{{ t('Share a read-only view of your household\'s meal status. The link is hard to guess — only people with it can see the data.') }}</p> <p class="hint">{{ t('Share a read-only view of your household\'s meal status. The link is hard to guess — only people with it can see the data.') }}</p>
@@ -150,9 +164,7 @@
</select> </select>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top: 8px;">{{ t('Save') }}</button> <button type="submit" class="btn btn-primary btn-sm" style="margin-top: 8px;">{{ t('Save') }}</button>
</form> </form>
{% endif %}
{% if is_admin %}
<form method="POST" action="{{ url_for('meals.admin_delete_household') }}" <form method="POST" action="{{ url_for('meals.admin_delete_household') }}"
onsubmit="return confirm(this.dataset.confirmMsg);" onsubmit="return confirm(this.dataset.confirmMsg);"
data-confirm-msg="{{ t('Delete the ENTIRE household and ALL members? This cannot be undone.') }}" data-confirm-msg="{{ t('Delete the ENTIRE household and ALL members? This cannot be undone.') }}"
@@ -162,9 +174,9 @@
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required> <input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required>
<button class="btn btn-danger">{{ t('Delete Household') }}</button> <button class="btn btn-danger">{{ t('Delete Household') }}</button>
</form> </form>
{% endif %}
</div> </div>
</details> </details>
{% endif %}
</div> </div>
<script> <script>