feat(admin): user management — roles, suspend, delete + lifecycle emails

- New Users page tabs (Users & roles / Access requests / Demo) and a tab-ified Configuration
  page, both via a reusable Tabs component (persisted active tab).
- Admin can suspend/unsuspend (migration 0023 adds users.is_suspended) and delete accounts
  from the Users & roles tab, with guards (demo / self / last admin).
- Email notifications to the affected user: suspended (reactive, on a blocked sign-in),
  reinstated, role changed, and account deleted; the approval email now carries a clickable
  app link. The scheduled/manual demo reset also seeds sample channel subscriptions.
- Hide the 'unverified email' chip for Google accounts (Google attests the email).
This commit is contained in:
npeter83 2026-06-19 19:52:12 +02:00
parent 2aa13a6433
commit 4571800991
10 changed files with 461 additions and 32 deletions

View file

@ -0,0 +1,30 @@
"""admin account suspension
Revision ID: 0023_user_suspension
Revises: 0022_password_auth
Create Date: 2026-06-19
Adds users.is_suspended an admin-controlled access block, distinct from is_active (the
approval lifecycle). A suspended account can't sign in (password or Google) and any live
session is rejected; the user is told why and given the operator's contact.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0023_user_suspension"
down_revision: Union[str, None] = "0022_password_auth"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_suspended", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("users", "is_suspended")

View file

@ -74,8 +74,8 @@ def send_access_approved(email: str) -> bool:
body = (
"Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
"Just open Siftlode and sign in with this Google account, and your YouTube\n"
"subscriptions feed will be ready.\n\n"
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
f"{settings.app_base}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode"
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
@ -83,6 +83,64 @@ def send_access_approved(email: str) -> bool:
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
line = (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
if role == "admin"
else "Your admin access on Siftlode has been removed — you're now a regular user."
)
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
def send_account_unsuspended(to: str, operator: str | None) -> bool:
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
"in again."
+ contact
+ "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
def send_account_deleted(to: str, operator: str | None) -> bool:
contact = (
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
if operator
else ""
)
body = (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode."
+ contact
+ "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
def send_account_suspended(to: str, operator: str | None) -> bool:
contact = (
f"If you think this is a mistake or have questions, contact the Siftlode operator "
f"at {operator}.\n\n"
if operator
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
)
body = (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n"
+ contact
+ "— Siftlode"
)
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
def send_admin_new_request(admins: list[str], requester: str) -> bool:
body = (
f"{requester} just requested access to Siftlode.\n\n"

View file

@ -37,6 +37,12 @@ class User(Base):
# Admin-approved & enabled. A pending password registration starts inactive; admin
# approval activates it. A deactivated account can't log in.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
# account can't sign in by any method and its live sessions are rejected; the user is told
# why and pointed at the operator's contact. The demo account is never suspendable.
is_suspended: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")

View file

@ -8,13 +8,14 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import email as email_mod
from app.auth import current_user, get_or_create_demo_user
from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user
from app.db import get_db
from app.models import (
DemoWhitelist,
Invite,
Playlist,
PlaylistItem,
Subscription,
User,
Video,
VideoState,
@ -132,6 +133,7 @@ def _serialize_user(u: User) -> dict:
"display_name": u.display_name,
"role": u.role,
"is_active": u.is_active,
"is_suspended": u.is_suspended,
"email_verified": u.email_verified,
"is_demo": u.is_demo,
"has_password": u.password_hash is not None,
@ -150,11 +152,12 @@ def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
def set_user_role(
user_id: int,
payload: dict,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo
account, and can't remove the last remaining admin."""
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = payload.get("role")
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
@ -169,11 +172,82 @@ def set_user_role(
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role
target.role = role
db.commit()
if changed:
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
return _serialize_user(target)
@router.patch("/users/{user_id}/suspend")
def set_user_suspended(
user_id: int,
payload: dict,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
live session is dropped on its next request. Guards: can't suspend the demo account, can't
suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in)."""
suspended = bool(payload.get("suspended"))
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
if suspended and target.role == "admin" and not target.is_suspended:
active_admins = db.scalar(
select(func.count())
.select_from(User)
.where(User.role == "admin", User.is_suspended.is_(False))
)
if (active_admins or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
background.add_task(
email_mod.send_account_unsuspended, target.email, operator_contact()
)
return _serialize_user(target)
@router.delete("/users/{user_id}")
def admin_delete_user(
user_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
not the demo account, not yourself (use Settings Account), and not the last admin."""
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
if target.id == admin.id:
raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account."
)
if target.role == "admin":
admin_count = db.scalar(
select(func.count()).select_from(User).where(User.role == "admin")
)
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
purge_user(db, target, background)
return {"deleted": user_id}
# --- Demo account: email whitelist + state reset -------------------------------------
def _serialize_demo(row: DemoWhitelist) -> dict:
@ -270,16 +344,35 @@ def _seed_demo_playlists(db: Session, demo: User) -> int:
return created
def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
"""Seed the demo with a handful of catalog channels (the ones with the most videos) so its
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
top = db.execute(
select(Video.channel_id)
.where(Video.channel_id.is_not(None))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(n)
).scalars().all()
for channel_id in top:
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
return len(top)
def reset_demo_state(db: Session, demo: User) -> int:
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
back to a clean baseline and re-seed a few sample playlists. Returns the seeded count.
Shared by the admin's manual reset button and the scheduled demo reset (see scheduler)."""
back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns
the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo
reset (see scheduler)."""
db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
demo.preferences = {"language": "en"}
db.commit()
seeded = _seed_demo_playlists(db, demo)
_seed_demo_subscriptions(db, demo)
db.commit()
return seeded