diff --git a/backend/alembic/versions/0023_user_suspension.py b/backend/alembic/versions/0023_user_suspension.py
new file mode 100644
index 0000000..51f20fa
--- /dev/null
+++ b/backend/alembic/versions/0023_user_suspension.py
@@ -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")
diff --git a/backend/app/email.py b/backend/app/email.py
index d1af768..8e1e653 100644
--- a/backend/app/email.py
+++ b/backend/app/email.py
@@ -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"
diff --git a/backend/app/models.py b/backend/app/models.py
index 4f12e9f..388ba36 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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")
diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py
index 176c1b9..1f7d3d8 100644
--- a/backend/app/routes/admin.py
+++ b/backend/app/routes/admin.py
@@ -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
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index d3172b1..20165b6 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -1,21 +1,31 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
+import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
+import Tabs, { usePersistedTab } from "./Tabs";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
-// demo whitelist + reset. Moved out of Settings → Account into its own admin page; the Invite
-// and demo data are unchanged (same tables) — only the UI relocated.
+// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
+// carries a badge with the count of pending requests so it's visible without switching to it.
export default function AdminUsers({ me }: { me: Me }) {
+ const { t } = useTranslation();
+ const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "roles");
+ const tabs = [
+ { id: "roles", label: t("users.tabs.roles") },
+ { id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
+ { id: "demo", label: t("users.tabs.demo") },
+ ];
+ const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return (