From 814691c2357a6c012f27b48bf93f36d793b3ffb4 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Tue, 2 Dec 2025 16:32:45 +1100 Subject: [PATCH] Add account settings page with options to update email, password and delete account --- db.py | 24 +++ migrations/004_add_email_to_users.sql | 3 + routes/auth.py | 11 +- routes/settings.py | 93 ++++++++- templates/dashboard/settings/account.html | 197 ++++++++++++++++++ templates/dashboard/settings/api_keys.html | 8 +- .../dashboard/settings/database_schema.html | 8 +- templates/dashboard/settings/export.html | 8 +- .../dashboard/settings/login_history.html | 8 +- 9 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 migrations/004_add_email_to_users.sql create mode 100644 templates/dashboard/settings/account.html diff --git a/db.py b/db.py index 1fffbbf..3ac7e38 100644 --- a/db.py +++ b/db.py @@ -574,3 +574,27 @@ ORDER BY invocation_time DESC""", [http_function_id]) LIMIT %s""", (user_id, limit) ) + + def update_user_password(self, user_id, new_password_hash): + """Update user's password""" + self.execute( + 'UPDATE users SET password_hash=%s WHERE id=%s', + [new_password_hash, user_id], + commit=True + ) + + def update_user_email(self, user_id, new_email): + """Update user's email address""" + self.execute( + 'UPDATE users SET email=%s WHERE id=%s', + [new_email, user_id], + commit=True + ) + + def delete_user(self, user_id): + """Delete a user account and all associated data""" + self.execute( + 'DELETE FROM users WHERE id=%s', + [user_id], + commit=True + ) diff --git a/migrations/004_add_email_to_users.sql b/migrations/004_add_email_to_users.sql new file mode 100644 index 0000000..aceaee1 --- /dev/null +++ b/migrations/004_add_email_to_users.sql @@ -0,0 +1,3 @@ +-- Add email column to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); diff --git a/routes/auth.py b/routes/auth.py index 6b607e5..16ec978 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -21,26 +21,27 @@ def get_client_ip(): return request.remote_addr class User(UserMixin): - def __init__(self, id, username, password_hash, created_at, theme_preference='light'): + def __init__(self, id, username, password_hash, created_at, theme_preference='light', email=None): self.id = id self.username = username self.password_hash = password_hash self.created_at = created_at self.theme_preference = theme_preference + self.email = email @staticmethod def get(user_id): user_data = db.get_user(int(user_id)) if user_data: - return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light')) + return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'), email=user_data.get('email')) return None @login_manager.user_loader def load_user(user_id): user_data = db.get_user(int(user_id)) if user_data: - return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light')) + return User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'), email=user_data.get('email')) return None @auth.route('/login', methods=['GET', 'POST']) @@ -64,7 +65,7 @@ def login(): db.record_login(user_data['id'], get_client_ip(), str(request.user_agent), False, "Invalid password") return render_template("login.html", error="Invalid username or password") - user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light')) + user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'), email=user_data.get('email')) # Record successful login with real IP db.record_login(user.id, get_client_ip(), str(request.user_agent), True) @@ -94,7 +95,7 @@ def signup(): hashed_password = generate_password_hash(password) user_data = db.create_new_user(username, hashed_password) - user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light')) + user = User(id=str(user_data['id']), username=user_data['username'], password_hash=user_data['password_hash'], created_at=user_data['created_at'], theme_preference=user_data.get('theme_preference', 'light'), email=user_data.get('email')) login_user(user) return redirect(url_for('home.index')) diff --git a/routes/settings.py b/routes/settings.py index 414aac0..7723c70 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -50,7 +50,8 @@ def export(): return render_block( environment, "dashboard/settings/export.html", - "page" + "page", + current_user=current_user ) return render_template("dashboard/settings/export.html") @@ -74,7 +75,8 @@ def api_keys(): "dashboard/settings/api_keys.html", "page", api_keys=api_keys, - functions=functions + functions=functions, + current_user=current_user ) return render_template("dashboard/settings/api_keys.html", api_keys=api_keys, functions=functions) @@ -129,7 +131,8 @@ def database_schema(): environment, "dashboard/settings/database_schema.html", "page", - schema_info=schema_info + schema_info=schema_info, + current_user=current_user ) return render_template("dashboard/settings/database_schema.html", schema_info=schema_info) @@ -145,10 +148,92 @@ def login_history(): environment, "dashboard/settings/login_history.html", "page", - history=history + history=history, + current_user=current_user ) return render_template("dashboard/settings/login_history.html", history=history) +@settings.route("/account", methods=["GET"]) +@login_required +def account(): + """Display account settings page""" + if htmx: + return render_block( + environment, + "dashboard/settings/account.html", + "page", + current_user=current_user + ) + return render_template("dashboard/settings/account.html") + +@settings.route("/account/password", methods=["POST"]) +@login_required +def update_password(): + """Update user password""" + from werkzeug.security import generate_password_hash, check_password_hash + + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + # Verify current password + user_data = db.get_user(current_user.id) + if not check_password_hash(user_data['password_hash'], current_password): + return render_template("dashboard/settings/account.html", error="Incorrect current password") + + # Validate new password + if len(new_password) < 10: + return render_template("dashboard/settings/account.html", error="New password must be at least 10 characters") + + if new_password != confirm_password: + return render_template("dashboard/settings/account.html", error="New passwords do not match") + + # Update password + new_hash = generate_password_hash(new_password) + db.update_user_password(current_user.id, new_hash) + + return render_template("dashboard/settings/account.html", success="Password updated successfully") + +@settings.route("/account/email", methods=["POST"]) +@login_required +def update_email(): + """Update user email""" + email = request.form.get('email') + + # Basic validation + if email and '@' not in email: + return render_template("dashboard/settings/account.html", error="Invalid email address") + + db.update_user_email(current_user.id, email) + + # Update current user object in session if needed, or just reload page + return render_template("dashboard/settings/account.html", success="Email updated successfully") + +@settings.route("/account/delete", methods=["POST"]) +@login_required +def delete_account(): + """Delete user account""" + from werkzeug.security import check_password_hash + from flask_login import logout_user + + password = request.form.get('password') + confirm_text = request.form.get('confirm_text') + + # Verify password + user_data = db.get_user(current_user.id) + if not check_password_hash(user_data['password_hash'], password): + return render_template("dashboard/settings/account.html", error="Incorrect password") + + # Verify confirmation text + if confirm_text != "DELETE": + return render_template("dashboard/settings/account.html", error="Please type DELETE to confirm") + + # Delete account + db.delete_user(current_user.id) + logout_user() + + return redirect(url_for('landing_page')) + def get_database_schema(): """Fetch database schema information for ERD generation""" diff --git a/templates/dashboard/settings/account.html b/templates/dashboard/settings/account.html new file mode 100644 index 0000000..85f9048 --- /dev/null +++ b/templates/dashboard/settings/account.html @@ -0,0 +1,197 @@ +{% extends 'dashboard.html' %} + +{% block page %} +
+ + + +
+

Account Settings

+

Manage your profile, security, and account preferences

+
+ + {% if error %} + + {% endif %} + + {% if success %} + + {% endif %} + + +
+

Profile Information

+ +
+
+ +
+ {{ current_user.username }} +
+
+
+ +
+ {{ current_user.created_at.strftime('%B %d, %Y') }} +
+
+
+ +
+
+ +
+ + +
+

Used for notifications and account recovery. +

+
+
+
+ + +
+

Change Password

+ +
+
+
+ + +
+ +
+
+ + +

Minimum 10 characters

+
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+

Danger Zone

+ +
+
+

Delete Account

+

Permanently delete your account and all associated + data. This action cannot be undone.

+
+ +
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/dashboard/settings/api_keys.html b/templates/dashboard/settings/api_keys.html index 1e18e04..6bd213c 100644 --- a/templates/dashboard/settings/api_keys.html +++ b/templates/dashboard/settings/api_keys.html @@ -22,8 +22,14 @@ hx-push-url="true" class="border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 py-4 px-1 text-sm font-medium cursor-pointer"> Login History + + + Account +
@@ -136,4 +142,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/dashboard/settings/database_schema.html b/templates/dashboard/settings/database_schema.html index e78ac16..01e6496 100644 --- a/templates/dashboard/settings/database_schema.html +++ b/templates/dashboard/settings/database_schema.html @@ -22,8 +22,14 @@ hx-push-url="true" class="border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 py-4 px-1 text-sm font-medium cursor-pointer"> Login History + + + Account +
@@ -232,4 +238,4 @@ erDiagram document.getElementById('mermaid-diagram').innerHTML = '

Unable to render ERD

View the tables overview below for schema information.

'; } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/dashboard/settings/export.html b/templates/dashboard/settings/export.html index 8d426b1..51e40ac 100644 --- a/templates/dashboard/settings/export.html +++ b/templates/dashboard/settings/export.html @@ -22,8 +22,14 @@ hx-push-url="true" class="border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 py-4 px-1 text-sm font-medium cursor-pointer"> Login History + + + Account +
@@ -271,4 +277,4 @@ detailsDiv.innerHTML = detailsHTML || '

No items to import.

'; } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/dashboard/settings/login_history.html b/templates/dashboard/settings/login_history.html index e000373..57ca9ba 100644 --- a/templates/dashboard/settings/login_history.html +++ b/templates/dashboard/settings/login_history.html @@ -22,8 +22,14 @@ hx-push-url="true" class="border-b-2 border-blue-500 text-blue-600 dark:text-blue-400 py-4 px-1 text-sm font-medium cursor-pointer"> Login History + + + Account +
@@ -112,4 +118,4 @@
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %}