feat(admin): manage member account access

Allow administrators to freeze, unfreeze, and permanently delete member accounts with related content cleanup.
This commit is contained in:
2026-09-01 20:09:04 +03:00
parent 9c4c506168
commit d1d594674f
6 changed files with 163 additions and 5 deletions
+53 -2
View File
@@ -67,7 +67,8 @@ def init_db():
is_approved INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL DEFAULT 'member',
mfa_secret TEXT,
mfa_enabled INTEGER NOT NULL DEFAULT 0
mfa_enabled INTEGER NOT NULL DEFAULT 0,
is_frozen INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -123,6 +124,7 @@ def init_db():
"email": "ALTER TABLE users ADD COLUMN email TEXT",
"email_verified": "ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0",
"real_name": "ALTER TABLE users ADD COLUMN real_name TEXT",
"is_frozen": "ALTER TABLE users ADD COLUMN is_frozen INTEGER NOT NULL DEFAULT 0",
}
post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")}
for column, statement in migrations.items():
@@ -154,6 +156,11 @@ def login_required(view):
if "user_id" not in session or not session.get("mfa_verified"):
flash("Sign in to continue.", "notice")
return redirect(url_for("login", next=request.path))
user = get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if user is None or user["is_frozen"]:
session.clear()
flash("This account has been frozen by an administrator.", "error")
return redirect(url_for("login"))
return view(*args, **kwargs)
return wrapped_view
@@ -413,6 +420,8 @@ def login():
user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
if user is None or not check_password_hash(user["password_hash"], request.form.get("password", "")):
flash("Invalid handle or password.", "error")
elif user["is_frozen"]:
flash("This account has been frozen by an administrator.", "error")
elif not user["is_approved"]:
flash("Your account is awaiting administrator approval.", "notice")
elif user["email"] and not user["email_verified"]:
@@ -637,7 +646,7 @@ def mfa_verify():
@admin_required
def admin_users():
users = get_db().execute(
"SELECT id, username, real_name, email, email_verified, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
"SELECT id, username, real_name, email, email_verified, created_at, is_approved, mfa_enabled, is_frozen, role FROM users ORDER BY is_frozen DESC, is_approved, created_at DESC"
).fetchall()
return render_template("admin_users.html", users=users)
@@ -651,6 +660,48 @@ def approve_user(user_id):
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/freeze")
@admin_required
def freeze_user(user_id):
cursor = get_db().execute("UPDATE users SET is_frozen = 1 WHERE id = ? AND role = 'member' AND is_frozen = 0", (user_id,))
get_db().commit()
flash("Account frozen." if cursor.rowcount else "No active member account was changed.", "success")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/unfreeze")
@admin_required
def unfreeze_user(user_id):
cursor = get_db().execute("UPDATE users SET is_frozen = 0 WHERE id = ? AND role = 'member' AND is_frozen = 1", (user_id,))
get_db().commit()
flash("Account unfrozen." if cursor.rowcount else "No frozen member account was changed.", "success")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/delete")
@admin_required
def delete_user(user_id):
db = get_db()
user = db.execute("SELECT id, username FROM users WHERE id = ? AND role = 'member'", (user_id,)).fetchone()
if user is None:
abort(404)
image_rows = db.execute(
"""SELECT image_filename AS filename FROM posts WHERE author_id = ? AND image_filename IS NOT NULL
UNION SELECT post_images.filename FROM post_images JOIN posts ON posts.id = post_images.post_id WHERE posts.author_id = ?""",
(user_id, user_id),
).fetchall()
db.execute("DELETE FROM post_images WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)", (user_id,))
db.execute("DELETE FROM posts WHERE author_id = ?", (user_id,))
db.execute("DELETE FROM password_reset_tokens WHERE user_id = ?", (user_id,))
db.execute("DELETE FROM email_verification_tokens WHERE user_id = ?", (user_id,))
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
db.commit()
for image in image_rows:
(app.config["UPLOAD_FOLDER"] / image["filename"]).unlink(missing_ok=True)
flash(f"Account {user['username']} and its posts were permanently deleted.", "success")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/password-reset")
@admin_required
def admin_password_reset(user_id):