"""User settings — profile, password, email, security, API keys."""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from app.models import (
change_user_email,
change_user_password,
delete_user,
export_user_data,
generate_api_key,
is_magic_login_enabled,
list_api_keys,
revoke_api_key,
update_user_magic_login_pref,
update_user_profile,
)
from app.routes.auth import current_user, login_required
user_settings_bp = Blueprint("user_settings", __name__, url_prefix="/settings")
# ─── Main Settings Page ─────────────────────────────────────────────────────
@user_settings_bp.route("/")
@login_required
def settings_page():
"""Main settings page — profile, password, email, security."""
user = current_user()
magic_enabled = is_magic_login_enabled(user["id"])
return render_template(
"settings.html",
user=user,
magic_login_enabled=magic_enabled,
)
# ─── Profile ────────────────────────────────────────────────────────────────
@user_settings_bp.route("/profile", methods=["POST"])
@login_required
def update_profile():
"""Update user profile (name, display name)."""
user = current_user()
name = request.form.get("name", "").strip()
display_name = request.form.get("display_name", "").strip()
result = update_user_profile(user["id"], name=name or None, display_name=display_name or None)
if result["success"]:
flash("Profile updated successfully.", "success")
else:
flash(result.get("error", "Failed to update profile."), "error")
return redirect(url_for("user_settings.settings_page"))
# ─── Password ───────────────────────────────────────────────────────────────
@user_settings_bp.route("/password", methods=["POST"])
@login_required
def change_password():
"""Change user password."""
user = current_user()
current_password = request.form.get("current_password", "").strip()
new_password = request.form.get("new_password", "").strip()
confirm_password = request.form.get("confirm_password", "").strip()
if not current_password or not new_password:
flash("Current password and new password are required.", "error")
return redirect(url_for("user_settings.settings_page"))
if new_password != confirm_password:
flash("New password and confirmation do not match.", "error")
return redirect(url_for("user_settings.settings_page"))
result = change_user_password(user["id"], current_password, new_password)
if result["success"]:
flash("Password changed successfully.", "success")
else:
flash(result.get("error", "Failed to change password."), "error")
return redirect(url_for("user_settings.settings_page"))
# ─── Email ──────────────────────────────────────────────────────────────────
@user_settings_bp.route("/email", methods=["POST"])
@login_required
def change_email():
"""Change user email address."""
user = current_user()
current_password = request.form.get("current_password", "").strip()
new_email = request.form.get("new_email", "").strip()
if not current_password or not new_email:
flash("Current password and new email are required.", "error")
return redirect(url_for("user_settings.settings_page"))
if "@" not in new_email or "." not in new_email:
flash("Please enter a valid email address.", "error")
return redirect(url_for("user_settings.settings_page"))
result = change_user_email(user["id"], current_password, new_email)
if result["success"]:
flash(
f"Email changed to {new_email}. A verification link has been sent to the new address.",
"success",
)
# TODO: Send verification email via NotificationService
else:
flash(result.get("error", "Failed to change email."), "error")
return redirect(url_for("user_settings.settings_page"))
# ─── Security ───────────────────────────────────────────────────────────────
@user_settings_bp.route("/security/magic-login", methods=["POST"])
@login_required
def toggle_magic_login():
"""Enable or disable magic login (passwordless). Handles both AJAX and form POST."""
user = current_user()
# Support both form POST and JSON body
if request.is_json:
enabled = request.json.get("enabled", False)
else:
# Form POST: checkbox only sends 'on' when checked, nothing when unchecked
enabled = request.form.get("magic_login_enabled") == "on"
update_user_magic_login_pref(user["id"], enabled)
# Return JSON for AJAX, redirect for form POST
if request.is_json:
return jsonify({"status": "ok", "enabled": enabled})
return redirect(url_for("user_settings.settings_page"))
# ─── API Keys ───────────────────────────────────────────────────────────────
@user_settings_bp.route("/api-keys")
@login_required
def api_keys_page():
"""API key management page."""
user = current_user()
keys = list_api_keys(user["id"])
return render_template(
"user_settings_api_keys.html",
user=user,
keys=keys,
)
@user_settings_bp.route("/api-keys", methods=["POST"])
@login_required
def create_api_key():
"""Create a new API key."""
user = current_user()
name = request.form.get("name", "").strip()
if not name:
flash("API key name is required", "error")
return redirect(url_for("user_settings.api_keys_page"))
# Parse permission checkboxes
permissions = {
"read_forms": request.form.get("perm_read_forms") == "on",
"write_forms": request.form.get("perm_write_forms") == "on",
"read_submissions": request.form.get("perm_read_submissions") == "on",
"delete_forms": request.form.get("perm_delete_forms") == "on",
}
result = generate_api_key(name, user["id"], permissions)
flash(f"API key created: {result['full_key']}. Save it now — you won't see it again.", "success")
# Store the full key in a flash-like mechanism for the template
from flask import session
session["_new_api_key"] = result["full_key"]
return redirect(url_for("user_settings.api_keys_page"))
@user_settings_bp.route("/api-keys/clear-new-key", methods=["POST"])
@login_required
def clear_new_api_key():
"""Clear the temporarily-stored new API key from session."""
from flask import session
session.pop("_new_api_key", None)
return jsonify({"status": "ok"})
@user_settings_bp.route("/api-keys/<int:key_id>/revoke", methods=["POST"])
@login_required
def revoke_api_key_route(key_id):
"""Revoke an API key."""
user = current_user()
success = revoke_api_key(key_id, user["id"])
if success:
flash("API key revoked.", "info")
else:
flash("API key not found or already revoked.", "error")
return redirect(url_for("user_settings.api_keys_page"))
# ─── GDPR Data Export ───────────────────────────────────────────────────────
@user_settings_bp.route("/data-export", methods=["POST"])
@login_required
def data_export():
"""Export all user data as JSON (GDPR right to data portability)."""
user = current_user()
import io
from flask import make_response
data = export_user_data(user["id"])
if data is None:
flash("Failed to export data — user not found.", "error")
return redirect(url_for("user_settings.settings_page"))
json_bytes = json.dumps(data, indent=2, default=str).encode("utf-8")
response = make_response(json_bytes)
response.headers["Content-Type"] = "application/json"
response.headers["Content-Disposition"] = 'attachment; filename="agentforms-data-export.json"'
return response
# ─── Account Deletion ───────────────────────────────────────────────────────
@user_settings_bp.route("/account/delete", methods=["POST"])
@login_required
def delete_account():
"""Delete user account and all associated data."""
user = current_user()
current_password = request.form.get("current_password", "").strip()
# Verify password before deletion (bcrypt — consistent with auth.py)
import bcrypt
from app.db import get_db
if not current_password:
flash("Current password is required to confirm deletion.", "error")
return redirect(url_for("user_settings.settings_page"))
conn = get_db()
user_row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone()
conn.close()
if not user_row:
flash("User not found. Account not deleted.", "error")
return redirect(url_for("user_settings.settings_page"))
if not bcrypt.checkpw(
current_password.encode("utf-8"),
user_row["password_hash"].encode("utf-8"),
):
flash("Incorrect password. Account not deleted.", "error")
return redirect(url_for("user_settings.settings_page"))
# Delete the account
result = delete_user(user["id"])
if result["success"]:
from flask import session as flask_session
flask_session.clear()
flash("Your account and all associated data have been permanently deleted.", "success")
return redirect(url_for("auth.login"))
else:
flash(result.get("error", "Failed to delete account."), "error")
return redirect(url_for("user_settings.settings_page"))