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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user