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
+2
View File
@@ -29,6 +29,8 @@ Code is displayed as literal text and can be copied from the article view. Image
Posts are public by default. Authors can select **Members only** while writing or editing to hide a post from visitors who are not signed in; MFA-verified members can still read these posts.
Administrators can freeze member accounts from **Accounts**. Frozen members cannot sign in and are signed out on their next protected request; administrators can unfreeze them later. Administrators can also permanently delete a member account. Deleting an account also permanently removes its posts, uploaded images, and outstanding reset or verification tokens.
## Accounts and MFA
The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish.
+1
View File
@@ -16,3 +16,4 @@
- [x] Add user email verification for registration and email-address changes.
- [x] Add user real names and use them as the published-by name.
- [x] Add ability to hide posts from unregistered users.
- [x] Add administrator controls to freeze and delete member accounts.
+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):
+1
View File
@@ -18,4 +18,5 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa
.code-block { margin:28px 0; border:1px solid var(--ink); background:var(--ink); }.code-block-head { display:flex; align-items:center; justify-content:space-between; min-height:38px; padding:7px 10px 7px 14px; color:var(--paper); font:11px "DM Mono", monospace; letter-spacing:.7px; text-transform:uppercase; }.copy-code { border:1px solid var(--paper); background:transparent; color:var(--paper); padding:5px 8px; font:11px "DM Mono", monospace; cursor:pointer; }.copy-code:hover { border-color:var(--acid); background:var(--acid); color:var(--ink); }.code-block pre { margin:0; padding:18px; overflow:auto; background:#fbfbf6; color:var(--ink); font:14px/1.55 "DM Mono", monospace; white-space:pre; }.code-block code { font:inherit; }
.flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); }
.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; }
.account-actions { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }.account-actions form { margin:0; }.account-actions strong { color:var(--orange); font:10px "DM Mono", monospace; }.delete-account { border-color:var(--orange); background:#ffe3d9; color:var(--ink); }.delete-account:hover { background:var(--orange); }
@media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col, .step-image-row { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } }
+2 -2
View File
@@ -4,13 +4,13 @@
<section class="editor-wrap">
<div class="section-head"><span>ACCOUNT QUEUE</span><span>{{ users|length }} REGISTERED</span></div>
<div class="account-table" role="table">
<div class="account-row account-head" role="row"><span>HANDLE / EMAIL</span><span>REGISTERED</span><span>MFA</span><span>STATUS</span></div>
<div class="account-row account-head" role="row"><span>HANDLE / EMAIL</span><span>REGISTERED</span><span>MFA</span><span>STATUS / ACTIONS</span></div>
{% for user in users %}
<div class="account-row" role="row">
<strong>{{ user.real_name or user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">@{{ user.username }} / {{ user.email or 'NO EMAIL' }}{% if user.email %} / {{ 'VERIFIED' if user.email_verified else 'UNVERIFIED' }}{% endif %}</small></strong>
<span>{{ user.created_at[:10] }}</span>
<span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span>
<span>{% if user.is_approved and user.role == 'member' %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Reset password</button></form>{% elif user.is_approved %}APPROVED{% else %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}</span>
<span class="account-actions">{% if user.role == 'member' %}{% if not user.is_approved %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}{% if user.is_frozen %}<strong>FROZEN</strong><form method="post" action="{{ url_for('unfreeze_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Unfreeze</button></form>{% else %}<form method="post" action="{{ url_for('freeze_user', user_id=user.id) }}" onsubmit="return confirm('Freeze this account? The member will be signed out on their next request.');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Freeze</button></form>{% endif %}{% if user.is_approved %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Reset password</button></form>{% endif %}<form method="post" action="{{ url_for('delete_user', user_id=user.id) }}" onsubmit="return confirm('Permanently delete this account, its posts, and all uploaded images? This cannot be undone.');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact delete-account" type="submit">Delete</button></form>{% else %}APPROVED{% endif %}</span>
</div>
{% endfor %}
</div>
+103
View File
@@ -0,0 +1,103 @@
import tempfile
import unittest
from pathlib import Path
import app as application
from werkzeug.security import generate_password_hash
class AccountLifecycleTestCase(unittest.TestCase):
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.database_path = Path(self.temp_directory.name) / "test.db"
self.upload_directory = Path(self.temp_directory.name) / "uploads"
self.original_config = application.app.config.copy()
application.app.config.update(
DATABASE=self.database_path,
UPLOAD_FOLDER=self.upload_directory,
TESTING=True,
WTF_CSRF_ENABLED=False,
)
with application.app.app_context():
application.init_db()
db = application.get_db()
self.admin_id = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'admin', 1)""",
("administrator", "admin@example.com", generate_password_hash("admin-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
self.member_id = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""",
("member", "member@example.com", generate_password_hash("member-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
db.commit()
self.client = application.app.test_client()
def tearDown(self):
application.app.config.update(self.original_config)
self.temp_directory.cleanup()
def sign_in_as_admin(self):
with self.client.session_transaction() as session:
session["user_id"] = self.admin_id
session["mfa_verified"] = True
def test_freeze_blocks_login_and_active_session(self):
self.sign_in_as_admin()
response = self.client.post(f"/admin/users/{self.member_id}/freeze")
self.assertEqual(response.status_code, 302)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (self.member_id,)).fetchone()[0], 1)
response = self.client.post("/login", data={"username": "member", "password": "member-password"})
self.assertIn(b"This account has been frozen by an administrator.", response.data)
with self.client.session_transaction() as session:
session["user_id"] = self.member_id
session["mfa_verified"] = True
response = self.client.get("/write")
self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.location)
with self.client.session_transaction() as session:
self.assertNotIn("user_id", session)
def test_delete_member_removes_related_data_and_images(self):
self.upload_directory.mkdir()
primary_image = self.upload_directory / "primary.webp"
inline_image = self.upload_directory / "inline.webp"
primary_image.write_bytes(b"primary")
inline_image.write_bytes(b"inline")
with application.app.app_context():
db = application.get_db()
post_id = db.execute(
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
("Member post", "member-post", "Excerpt", "Body", "Code", primary_image.name, self.member_id, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"),
).lastrowid
db.execute("INSERT INTO post_images (post_id, filename, caption, position, created_at) VALUES (?, ?, ?, ?, ?)", (post_id, inline_image.name, "", 1, "2026-09-01T00:00:00+00:00"))
db.execute("INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)", (self.member_id, "reset-token", "2026-10-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"))
db.execute("INSERT INTO email_verification_tokens (user_id, email_address, token_hash, token_type, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", (self.member_id, "member@example.com", "verification-token", "registration", "2026-10-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"))
db.commit()
self.sign_in_as_admin()
self.assertEqual(self.client.post(f"/admin/users/{self.member_id}/delete").status_code, 302)
with application.app.app_context():
db = application.get_db()
self.assertIsNone(db.execute("SELECT id FROM users WHERE id = ?", (self.member_id,)).fetchone())
self.assertIsNone(db.execute("SELECT id FROM posts WHERE id = ?", (post_id,)).fetchone())
self.assertEqual(db.execute("SELECT COUNT(*) FROM post_images WHERE post_id = ?", (post_id,)).fetchone()[0], 0)
self.assertEqual(db.execute("SELECT COUNT(*) FROM password_reset_tokens WHERE user_id = ?", (self.member_id,)).fetchone()[0], 0)
self.assertEqual(db.execute("SELECT COUNT(*) FROM email_verification_tokens WHERE user_id = ?", (self.member_id,)).fetchone()[0], 0)
self.assertFalse(primary_image.exists())
self.assertFalse(inline_image.exists())
def test_administrator_accounts_cannot_be_frozen_or_deleted(self):
self.sign_in_as_admin()
self.assertEqual(self.client.post(f"/admin/users/{self.admin_id}/freeze").status_code, 302)
self.assertEqual(self.client.post(f"/admin/users/{self.admin_id}/delete").status_code, 404)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (self.admin_id,)).fetchone()[0], 0)
if __name__ == "__main__":
unittest.main()