feat!: remove the shared demo account

The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent cca3315836
commit 6d8b7be6d8
109 changed files with 487 additions and 1400 deletions
@@ -0,0 +1,55 @@
"""demo: drop the shared demo account (the owner's decision, 2026-10-04)
The demo account — one shared user that whitelisted emails entered from the login page, reset on a
schedule — goes with its code. Prod held the demo user and 10 whitelist rows.
The upgrade deletes the demo user first (its playlists, states and subscriptions cascade; audit and
quota rows keep their history with the actor nulled, as for any deleted account), then the traces
that only the demo code could render: its audit rows (whitelist edits, manual resets, the scheduled
reset's job runs), the reset job's interval override and its completion notices. Nothing is
migrated anywhere; the nightly backup holds the rows.
The downgrade restores the schema as it stood (0014), empty: the demo user is created lazily on its
first sign-in, so an empty `demo_whitelist` is the whole of its state.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0098_drop_demo_account"
down_revision: str | None = "0097_drop_messages"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.execute("DELETE FROM users WHERE is_demo")
op.execute(
"DELETE FROM audit_log WHERE action IN ('demo_add', 'demo_remove', 'demo_reset')"
" OR (action = 'job_run' AND target_id = 'demo_reset')"
)
op.execute("DELETE FROM scheduler_settings WHERE job_id = 'demo_reset'")
op.execute("DELETE FROM notifications WHERE data->>'job_id' = 'demo_reset'")
op.drop_index("ix_demo_whitelist_email", table_name="demo_whitelist")
op.drop_table("demo_whitelist")
op.drop_index("ix_users_is_demo", table_name="users")
op.drop_column("users", "is_demo")
def downgrade() -> None:
op.add_column(
"users",
sa.Column("is_demo", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.create_index("ix_users_is_demo", "users", ["is_demo"])
op.create_table(
"demo_whitelist",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("added_by", sa.String(length=320), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True)
+1 -1
View File
@@ -1,7 +1,7 @@
# Backend conventions # Backend conventions
The rules below are already followed across `backend/app` — they lived only in a few module The rules below are already followed across `backend/app` — they lived only in a few module
docstrings (`demo.py`, `userscope.py`, `routes/_common.py`) until this file collected them (E3 S3, docstrings (`userscope.py`, `routes/_common.py`) until this file collected them (E3 S3,
B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do
I copy". I copy".
-3
View File
@@ -39,9 +39,6 @@ class AuditAction:
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is # E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
# silent, so this is the act that actually reaches a person — and the one worth a record. # silent, so this is the act that actually reaches a person — and the one worth a record.
INVITE_SEND = "invite_send" INVITE_SEND = "invite_send"
DEMO_ADD = "demo_add"
DEMO_REMOVE = "demo_remove"
DEMO_RESET = "demo_reset"
DISCOVERY_PURGE = "discovery_purge" DISCOVERY_PURGE = "discovery_purge"
# Config # Config
CONFIG_SET = "config_set" CONFIG_SET = "config_set"
+3 -39
View File
@@ -24,8 +24,6 @@ def operator_contact() -> str | None:
return next(iter(sorted(settings.admin_email_set)), None) return next(iter(sorted(settings.admin_email_set)), None)
DEMO_GOOGLE_SUB = "__demo__"
DEMO_EMAIL = "demo@siftlode.local"
MAX_SESSION_ACCOUNTS = 6 MAX_SESSION_ACCOUNTS = 6
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check # OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
@@ -129,7 +127,7 @@ def can_activate(db: Session, user: User) -> bool:
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
button that appears and a number that counts must never disagree about who is stuck. button that appears and a number that counts must never disagree about who is stuck.
""" """
if user.is_demo or user.is_suspended or user.is_active: if user.is_suspended or user.is_active:
return False return False
return is_allowed(db, user.email) return is_allowed(db, user.email)
@@ -147,7 +145,6 @@ def count_pending_activations(db: Session) -> int:
rows = ( rows = (
db.execute( db.execute(
select(User).where( select(User).where(
User.is_demo.is_(False),
User.is_suspended.is_(False), User.is_suspended.is_(False),
User.is_active.is_(False), User.is_active.is_(False),
) )
@@ -188,24 +185,6 @@ def find_user_by_email(db: Session, email: str) -> User | None:
return candidates[0] if len(candidates) == 1 else None return candidates[0] if len(candidates) == 1 else None
def get_or_create_demo_user(db: Session) -> User:
"""The one shared demo user, created on first use. No OAuth token / YouTube scope, so
has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it."""
user = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if user is None:
user = User(
google_sub=DEMO_GOOGLE_SUB,
email=DEMO_EMAIL,
display_name="Demo",
role="user",
is_demo=True,
preferences={"language": "en"},
)
db.add(user)
db.commit()
return user
def revoke_google_token(token: str | None) -> None: def revoke_google_token(token: str | None) -> None:
"""Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh """Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh
token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes
@@ -402,25 +381,10 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
return None return None
def require_human(user: User = Depends(current_user)) -> User: def require_write_scope(user: User = Depends(current_user)) -> User:
"""Reject the shared demo account from actions that need a real Google/YouTube identity
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
where there's no scope check to lean on."""
if user.is_demo:
# Structured, because `req` leaves 403 to the caller and `notifyActionError` only shows a
# STRUCTURED detail — a plain string here reached the demo user as their caller's generic
# "couldn't do that", with no hint that the demo account is the reason (round 6). Affordances
# this gate sits behind are not all UI-gated on `is_demo` (the comfort shelf is not).
raise HTTPException(status_code=403, detail={"code": "demo"})
return user
def require_write_scope(user: User = Depends(require_human)) -> User:
"""Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit). """Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit).
Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)` Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)`
checks that had drifted into three different 403 messages across channels/playlists. Layers on checks that had drifted into three different 403 messages across channels/playlists."""
`require_human` because writing to YouTube needs a real, non-demo identity."""
if not has_write_scope(user): if not has_write_scope(user):
raise refused(403, "youtube_editing_off") raise refused(403, "youtube_editing_off")
return user return user
+1 -1
View File
@@ -61,7 +61,7 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it, # Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
# and the owner check resolves first). The upload route did NOT until P1: it declared # and the owner check resolves first). The upload route did NOT until P1: it declared
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole # `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
# per-file allowance to temp before `require_human` refused them — this middleware bounded that # per-file allowance to temp before the auth dependency refused them — this middleware bounded that
# number but could not stop the writing. It now reads its own body from a dependency declared after # number but could not stop the writing. It now reads its own body from a dependency declared after
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here. # the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
# #
-2
View File
@@ -146,8 +146,6 @@ class Settings(BaseSettings):
autotag_interval_minutes: int = 30 autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360 subscriptions_resync_minutes: int = 360
playlist_sync_minutes: int = 360 playlist_sync_minutes: int = 360
# How often the shared demo account is auto-reset to a clean baseline (communal sandbox).
demo_reset_minutes: int = 720
# --- Maintenance / validation job --- # --- Maintenance / validation job ---
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods # Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
# are in days because that's the granularity that matters; the rolling re-validation # are in days because that's the granularity that matters; the rolling re-validation
-82
View File
@@ -1,82 +0,0 @@
"""Demo-account service: reset the shared demo sandbox to a clean, seeded baseline.
Shared by the admin's manual reset endpoint (routes/admin.py) and the scheduled demo-reset job
(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
"""
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.models import Playlist, PlaylistItem, Subscription, User, Video, VideoState
def _seed_demo_playlists(db: Session, demo: User) -> int:
"""Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't
empty — gives visitors something to play with. Best-effort: skips silently if the
catalog is too small."""
recent = (
db.execute(
select(Video.id)
.where(Video.is_short.is_(False), Video.live_status == "none")
.order_by(Video.published_at.desc().nullslast())
.limit(60)
)
.scalars()
.all()
)
if not recent:
return 0
samples = [
("Fresh picks", recent[0:10]),
("Quick watch", recent[10:18]),
("Saved for later", recent[18:26]),
]
created = 0
for pos, (name, vids) in enumerate(samples):
if not vids:
continue
pl = Playlist(user_id=demo.id, name=name, position=pos)
db.add(pl)
db.flush()
for ipos, vid in enumerate(vids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos))
created += 1
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 + 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
-1
View File
@@ -10,7 +10,6 @@ Three levels, named in the UI Alap / Részletes / Teljes:
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time, A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
never taken from the client, and an expired level reads as INFO without anything having to clear it. never taken from the client, and an expired level reads as INFO without anything having to clear it.
The shared demo account cannot raise its level — its log would mix every visitor's session.
Core layer: imports `models` and `sysconfig`. Core layer: imports `models` and `sysconfig`.
""" """
+3 -22
View File
@@ -106,7 +106,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google # half of the truth lives, for the UI to show when the two differ. NULL until the next Google
# sign-in for accounts linked before this column existed. # sign-in for accounts linked before this column existed.
google_email: Mapped[str | None] = mapped_column(String(320)) google_email: Mapped[str | None] = mapped_column(String(320))
# argon2id hash for email+password login; NULL for Google-only / demo accounts. # argon2id hash for email+password login; NULL for Google-only accounts.
password_hash: Mapped[str | None] = mapped_column(String(255)) password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of # When SEC-10 dropped a preset password on Google adoption (see the single caller of
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without # auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without
@@ -123,7 +123,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended # 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 # 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. # why and pointed at the operator's contact.
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password # Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under; # change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
@@ -132,7 +132,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# When this account last signed in (Google or password), stamped by the one sign-in path, # When this account last signed in (Google or password), stamped by the one sign-in path,
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is # routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
# possible). The shared demo account is never stamped — its "last login" would mean nobody. # possible).
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The diagnostics level this user consented to (E22 S5) and when it lapses back to the base # The diagnostics level this user consented to (E22 S5) and when it lapses back to the base
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry # level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry
@@ -146,12 +146,6 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and # The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
# cached. Used to exclude their own channel from playlist discovery. NULL = unresolved. # cached. Used to exclude their own channel from playlist discovery. NULL = unresolved.
yt_channel_id: Mapped[str | None] = mapped_column(String(32)) yt_channel_id: Mapped[str | None] = mapped_column(String(32))
# The single shared "demo" account: signed into without Google OAuth (via a whitelisted
# email on the login page), has no OAuth token / YouTube scope, and its per-user state is
# shared by everyone who enters this way. Exactly one such row is expected.
is_demo: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
# Free-form UI preferences (theme, color scheme, font scale, default filters…). # Free-form UI preferences (theme, color scheme, font scale, default filters…).
preferences: Mapped[dict | None] = mapped_column(JSON) preferences: Mapped[dict | None] = mapped_column(JSON)
@@ -224,19 +218,6 @@ class Invite(Base, CanonicalEmailMixin):
lang: Mapped[str | None] = mapped_column(String(8)) lang: Mapped[str | None] = mapped_column(String(8))
class DemoWhitelist(Base, TimestampMixin):
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
__tablename__ = "demo_whitelist"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
note: Mapped[str | None] = mapped_column(Text)
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
class Channel(Base, TimestampMixin): class Channel(Base, TimestampMixin):
"""A YouTube channel. Shared across all users (one channel's videos are the same """A YouTube channel. Shared across all users (one channel's videos are the same
for everyone), so its expensive metadata is fetched and stored only once.""" for everyone), so its expensive metadata is fetched and stored only once."""
+9 -119
View File
@@ -1,5 +1,4 @@
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add. """Admin-only onboarding: review access requests (Invites), approve/deny, manual add."""
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -19,15 +18,12 @@ from app.auth import (
count_admins, count_admins,
env_allowed_canonicals, env_allowed_canonicals,
find_user_by_email, find_user_by_email,
get_or_create_demo_user,
is_allowed, is_allowed,
operator_contact, operator_contact,
purge_user, purge_user,
) )
from app.db import get_db from app.db import get_db
from app.demo import reset_demo_state
from app.models import ( from app.models import (
DemoWhitelist,
Invite, Invite,
User, User,
) )
@@ -131,7 +127,7 @@ def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]
""" """
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600) hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
user = find_user_by_email(db, email) user = find_user_by_email(db, email)
if user is None or user.is_demo: if user is None:
return None, False, hours return None, False, hours
if user.email_verified or not email_mod.email_enabled(db): if user.email_verified or not email_mod.email_enabled(db):
return None, True, hours return None, True, hours
@@ -400,7 +396,6 @@ def _serialize_user(u: User) -> dict:
"is_active": u.is_active, "is_active": u.is_active,
"is_suspended": u.is_suspended, "is_suspended": u.is_suspended,
"email_verified": u.email_verified, "email_verified": u.email_verified,
"is_demo": u.is_demo,
"has_password": u.password_hash is not None, "has_password": u.password_hash is not None,
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" — # Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before # the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
@@ -412,7 +407,7 @@ def _serialize_user(u: User) -> dict:
# confusing sign-in. # confusing sign-in.
"google_email": u.google_email if u.google_email != u.email else None, "google_email": u.google_email if u.google_email != u.email else None,
"created_at": iso(u.created_at), "created_at": iso(u.created_at),
# NULL = no sign-in since the column existed (0085), or the shared demo account. # NULL = no sign-in since the column existed (0085).
"last_login_at": iso(u.last_login_at), "last_login_at": iso(u.last_login_at),
} }
@@ -435,14 +430,12 @@ def set_user_role(
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo """Promote/demote a user. Guards: can't change your own role and can't remove the
account, and can't remove the last remaining admin. Emails the user when it actually changes.""" last remaining admin. Emails the user when it actually changes."""
role = body.role role = body.role
if role not in ("user", "admin"): if role not in ("user", "admin"):
raise refused(400, "invalid_role") raise refused(400, "invalid_role")
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo:
raise refused(400, "demo_role")
if target.id == admin.id: if target.id == admin.id:
raise refused(400, "own_role") raise refused(400, "own_role")
if ( if (
@@ -490,14 +483,11 @@ def set_user_suspended(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any """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 live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
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 un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in).""" sign-in)."""
suspended = body.suspended suspended = body.suspended
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo:
raise refused(400, "demo_suspend")
if target.id == admin.id: if target.id == admin.id:
raise refused(400, "own_suspend") raise refused(400, "own_suspend")
if ( if (
@@ -538,10 +528,8 @@ def admin_delete_user(
) -> dict: ) -> dict:
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves """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: (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.""" not yourself (use Settings → Account), and not the last admin."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo:
raise refused(400, "demo_delete")
if target.id == admin.id: if target.id == admin.id:
raise refused(400, "own_delete") raise refused(400, "own_delete")
if ( if (
@@ -563,9 +551,6 @@ def admin_delete_user(
return {"deleted": user_id} return {"deleted": user_id}
# --- Demo account: email whitelist + state reset -------------------------------------
# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) --------------------------------- # --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) ---------------------------------
@@ -587,7 +572,6 @@ def _pending_access_rows(db: Session) -> list[User]:
db.execute( db.execute(
select(User) select(User)
.where( .where(
User.is_demo.is_(False),
User.is_suspended.is_(False), User.is_suspended.is_(False),
or_(User.is_active.is_(False), User.email_verified.is_(False)), or_(User.is_active.is_(False), User.email_verified.is_(False)),
) )
@@ -653,13 +637,8 @@ def create_set_password_link(
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
revoked password, for a Google-only account that wants one, and for a plain forgotten password on revoked password, for a Google-only account that wants one, and for a plain forgotten password on
an instance with no mail — narrowing it to one of those would only make the admin's real case the an instance with no mail — narrowing it to one of those would only make the admin's real case the
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to one it refuses."""
change its password is handing them everyone else's session."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo:
raise HTTPException(
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
)
url, expires_at = issue_set_password_link(db, target) url, expires_at = issue_set_password_link(db, target)
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live # The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom, # credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
@@ -692,8 +671,6 @@ def activate_user(
already do exactly this by re-entering the address in the whitelist box (`add_invite` → already do exactly this by re-entering the address in the whitelist box (`add_invite` →
`_activate_user_by_email`). What was missing was any way to know they had to.""" `_activate_user_by_email`). What was missing was any way to know they had to."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo:
raise refused(400, "demo_activate")
if target.is_active: if target.is_active:
return _serialize_user(target) # idempotent: a double click is not an error return _serialize_user(target) # idempotent: a double click is not an error
if not is_allowed(db, target.email): if not is_allowed(db, target.email):
@@ -731,7 +708,7 @@ def resend_user_verification(
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
of the public endpoint is exactly what this exists to escape.""" of the public endpoint is exactly what this exists to escape."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo or target.email_verified: if target.email_verified:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail={"code": "resend", "reason": "not_applicable", "email": target.email}, detail={"code": "resend", "reason": "not_applicable", "email": target.email},
@@ -757,93 +734,6 @@ def resend_user_verification(
return {"ok": True, "email": target.email} return {"ok": True, "email": target.email}
def _serialize_demo(row: DemoWhitelist) -> dict:
return {
"id": row.id,
"email": row.email,
"note": row.note,
"added_by": row.added_by,
"created_at": iso(row.created_at),
}
@router.get("/demo/whitelist")
def list_demo_whitelist(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
rows = (
db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc())).scalars().all()
)
return [_serialize_demo(r) for r in rows]
class AddDemoWhitelistIn(BaseModel):
email: str = ""
note: str | None = None
@router.post("/demo/whitelist")
def add_demo_whitelist(
body: AddDemoWhitelistIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Add an email that may enter the shared demo account from the login page (no Google
sign-in). Idempotent — re-adding an existing email just returns it."""
email = body.email.strip().lower()
if not valid_email(email):
raise refused(400, "invalid_email")
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
if row is None:
row = DemoWhitelist(
email=email,
note=(body.note or "").strip() or None,
added_by=admin.email,
)
db.add(row)
audit.record(
db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email
)
db.commit()
return _serialize_demo(row)
@router.delete("/demo/whitelist/{entry_id}")
def remove_demo_whitelist(
entry_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
row = db.get(DemoWhitelist, entry_id)
if row is not None:
audit.record(
db,
admin.id,
AuditAction.DEMO_REMOVE,
target_type="demo",
target_id=row.email,
summary=row.email,
)
db.delete(row)
db.commit()
return {"deleted": entry_id}
@router.post("/demo/reset")
def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
seeded = reset_demo_state(db, demo)
audit.record(
db,
admin.id,
AuditAction.DEMO_RESET,
summary=f"{seeded} sample playlists re-seeded",
after={"playlists_seeded": seeded}, # the count as data: named in the reader's language
)
db.commit()
return {"ok": True, "reset": True, "playlists_seeded": seeded}
@router.post("/purge-discovery") @router.post("/purge-discovery")
def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result """Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
+11 -60
View File
@@ -31,7 +31,6 @@ from app.auth import (
env_admin_canonicals, env_admin_canonicals,
env_allowed_canonicals, env_allowed_canonicals,
find_user_by_email, find_user_by_email,
get_or_create_demo_user,
google_enabled, google_enabled,
is_allowed, is_allowed,
log, log,
@@ -40,7 +39,7 @@ from app.auth import (
) )
from app.config import settings from app.config import settings
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text from app.models import AuthToken, Invite, OAuthToken, User, display_text
from app.ratelimit import RateLimiter, client_key from app.ratelimit import RateLimiter, client_key
from app.security import encrypt, hash_password, hash_token, verify_password from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import canonical_email, valid_email from app.utils import canonical_email, valid_email
@@ -92,8 +91,6 @@ def _notify_suspended(background: BackgroundTasks, user: User) -> None:
) )
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
_oauth = OAuth() _oauth = OAuth()
@@ -161,18 +158,6 @@ def notify_admins_of_request(
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester) background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
def is_demo_allowed(db: Session, email: str) -> bool:
"""Whether this email may enter the shared demo account (no Google sign-in)."""
if not email:
return False
return (
db.execute(
select(DemoWhitelist).where(DemoWhitelist.email == email.lower())
).scalar_one_or_none()
is not None
)
class InviteRequest(NamedTuple): class InviteRequest(NamedTuple):
"""What an access request settled on. """What an access request settled on.
@@ -588,7 +573,7 @@ def _complete_link(
if link_uid != request.session.get("user_id") and link_uid not in accounts: if link_uid != request.session.get("user_id") and link_uid not in accounts:
return RedirectResponse(url="/?link=error") return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid) user = db.get(User, link_uid)
if user is None or user.is_demo: if user is None:
return RedirectResponse(url="/?link=error") return RedirectResponse(url="/?link=error")
other = db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none() other = db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
if other is not None: if other is not None:
@@ -634,36 +619,12 @@ async def logout(request: Request):
return JSONResponse({"ok": True, "switched": False}) return JSONResponse({"ok": True, "switched": False})
@router.post("/demo")
def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -> dict:
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
exactly like a non-match (authenticated=false) so the field can't be hammered as an
enumeration oracle. On a match it sets the session and the client reloads into the app."""
if not _demo_limiter.allow(client_key(request)):
return {"ok": True, "authenticated": False}
email = (body.email or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email):
demo = get_or_create_demo_user(db)
# Isolate the demo: drop any real-account wallet on this tab first, so the demo session
# can't switch to / act as a previously signed-in account via the multi-account header.
request.session.clear()
request.session["user_id"] = demo.id
_remember_epoch(request, demo)
# The demo key IS an address, so it is not in the line — see the denied branch above.
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
return {"ok": True, "authenticated": True}
return {"ok": True, "authenticated": False}
def _establish_session(request: Request, user: User) -> None: def _establish_session(request: Request, user: User) -> None:
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
and password login, so both join the same account switcher and both stamp `last_login_at` (the and password login, so both join the same account switcher and both stamp `last_login_at` (the
caller commits). Multi-session: every account that authenticated in this browser is remembered so caller commits). Multi-session: every account that authenticated in this browser is remembered so
the user can switch between them; only accounts that actually proved themselves HERE land in that the user can switch between them; only accounts that actually proved themselves HERE land in that
wallet (it is the proof they may be switched to). The demo entry deliberately does not come wallet (it is the proof they may be switched to)."""
through here: it clears the wallet instead, and its shared account has no meaningful last login."""
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id] accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id) accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
@@ -847,7 +808,7 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash # primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
# and bumps the session epoch. The residual is logged as SEC-16. # and bumps the session epoch. The residual is logged as SEC-16.
user = existing user = existing
if not user.email_verified and not user.is_demo and email_mod.email_enabled(): if not user.email_verified and email_mod.email_enabled():
send_verification_link(db, user) send_verification_link(db, user)
return return
# Without working SMTP we can't deliver a verification link, so email ownership can't gate # Without working SMTP we can't deliver a verification link, so email ownership can't gate
@@ -981,7 +942,7 @@ def _resend_verify_if_eligible(email: str) -> None:
return return
with SessionLocal() as db: with SessionLocal() as db:
user = find_user_by_email(db, email) user = find_user_by_email(db, email)
if user is not None and not user.email_verified and not user.is_demo: if user is not None and not user.email_verified:
send_verification_link(db, user) send_verification_link(db, user)
@@ -1027,11 +988,11 @@ def password_login(
email = (body.email or "").strip().lower() email = (body.email or "").strip().lower()
password = body.password or "" password = body.password or ""
user = find_user_by_email(db, email) user = find_user_by_email(db, email)
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the # Always run the KDF (against a decoy hash for an unknown / passwordless account) so the
# response time is the same whether or not the email exists — no timing-based enumeration. # response time is the same whether or not the email exists — no timing-based enumeration.
candidate_hash = user.password_hash if (user and not user.is_demo) else None candidate_hash = user.password_hash if user else None
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH) password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
if user is None or user.is_demo or not password_ok: if user is None or not password_ok:
raise _login_refused(401, "invalid") raise _login_refused(401, "invalid")
if user.is_suspended: if user.is_suspended:
_notify_suspended(background, user) _notify_suspended(background, user)
@@ -1070,7 +1031,7 @@ def password_reset_request(
def _send_reset_if_eligible(email: str) -> None: def _send_reset_if_eligible(email: str) -> None:
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY """Background worker for password_reset_request: issue a reset token + email the link, but ONLY
for a real (non-demo) password account. Runs after the response with its own DB session, so the for a password account. Runs after the response with its own DB session, so the
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
can't leak into proxy/access logs or a Referer (SB3). can't leak into proxy/access logs or a Referer (SB3).
@@ -1081,7 +1042,7 @@ def _send_reset_if_eligible(email: str) -> None:
proves identity through the session rather than by mail.""" proves identity through the session rather than by mail."""
with SessionLocal() as db: with SessionLocal() as db:
user = find_user_by_email(db, email) user = find_user_by_email(db, email)
if user is None or user.is_demo: if user is None:
return return
if user.password_hash or user.password_revoked_at: if user.password_hash or user.password_revoked_at:
raw = _issue_token(db, user, "reset", RESET_TTL) raw = _issue_token(db, user, "reset", RESET_TTL)
@@ -1208,9 +1169,7 @@ def logout_others(
) -> dict: ) -> dict:
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies """SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
in somewhere" / a suspected session compromise. The demo account (shared) is excluded.""" in somewhere" / a suspected session compromise."""
if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo"})
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current # Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
# session at a newer epoch than the DB (see set_password). # session at a newer epoch than the DB (see set_password).
bump_session_epoch(user) bump_session_epoch(user)
@@ -1229,8 +1188,6 @@ async def link_google(
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only """Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account.""" sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db) client = google_oauth(db)
if client is None: if client is None:
return RedirectResponse(url="/?link=error") return RedirectResponse(url="/?link=error")
@@ -1256,8 +1213,6 @@ def set_password(
"""Set or change the signed-in account's password. Setting a first password (Google-only """Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password — the session already proves identity. Changing an account) needs no current password — the session already proves identity. Changing an
existing one requires the current password, so a hijacked session can't silently rotate it.""" existing one requires the current password, so a hijacked session can't silently rotate it."""
if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo"})
new_password = body.password or "" new_password = body.password or ""
if len(new_password) < PASSWORD_MIN_LEN: if len(new_password) < PASSWORD_MIN_LEN:
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
@@ -1288,10 +1243,6 @@ async def upgrade(
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` / (unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
`can_write` flip on once granted. Anything other than an explicit `write` defaults to `can_write` flip on once granted. Anything other than an explicit `write` defaults to
the least-privileged read scope.""" the least-privileged read scope."""
# This is the browser-facing redirect entry point, so the shared demo account is bounced
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db) client = google_oauth(db)
if client is None: if client is None:
return RedirectResponse(url="/") return RedirectResponse(url="/")
+10 -14
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.collation import collated, sorted_words from app.collation import collated, sorted_words
from app.mailtext import lang_for_user from app.mailtext import lang_for_user
from app import logs, quota from app import logs, quota
from app.auth import admin_user, current_user, require_human, require_write_scope from app.auth import admin_user, current_user, require_write_scope
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING, LIVE_OR_UPCOMING,
@@ -163,8 +163,8 @@ def discover_channels(
can judge a channel BEFORE subscribing — but we do NOT pull its videos. Most-present can judge a channel BEFORE subscribing — but we do NOT pull its videos. Most-present
channels come first; subscribing is a separate action.""" channels come first; subscribing is a separate action."""
# Resolve & cache the user's own channel id so it can be excluded — you don't subscribe # Resolve & cache the user's own channel id so it can be excluded — you don't subscribe
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it. # to yourself. Lazy + cached; needs OAuth, so token-less users just skip it.
if user.yt_channel_id is None and user.token is not None and not user.is_demo: if user.yt_channel_id is None and user.token is not None:
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
@@ -192,8 +192,7 @@ def discover_channels(
# (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can # (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can
# change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB. # change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB.
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
# Skip the quota-spending enrich for the demo account (it just sees the stub metadata). if need:
if need and not user.is_demo:
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
@@ -311,12 +310,12 @@ def channel_detail(
Lazily fills the About metadata (view count, join date, banner, links) the first time it's Lazily fills the About metadata (view count, join date, banner, links) the first time it's
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit, subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
cached after), so it's skipped for the quota-less demo account.""" cached after)."""
channel = get_or_404(db, Channel, channel_id, what="channel") channel = get_or_404(db, Channel, channel_id, what="channel")
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on # No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway. # every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
stale = details_stale(channel, datetime.now(timezone.utc)) stale = details_stale(channel, datetime.now(timezone.utc))
if stale and not user.is_demo: if stale:
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
@@ -389,7 +388,7 @@ def channel_detail(
def explore_channel( def explore_channel(
channel_id: str, channel_id: str,
page_token: str | None = None, page_token: str | None = None,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user, """Open an un-subscribed channel for browsing. Records the exploration (a per-user,
@@ -397,7 +396,7 @@ def explore_channel(
drives the later cleanup), flags the channel ephemeral unless someone organically follows drives the later cleanup), flags the channel ephemeral unless someone organically follows
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched. it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
Returns the next page token so the page can "load more". Guarded against the shared Returns the next page token so the page can "load more". Guarded against the shared
backfill quota reserve; blocked for the demo account (it spends shared quota).""" backfill quota reserve."""
channel = get_or_404(db, Channel, channel_id, what="channel") channel = get_or_404(db, Channel, channel_id, what="channel")
if _is_blocked(db, user, channel_id): if _is_blocked(db, user, channel_id):
raise refused(403, "channel_blocked") raise refused(403, "channel_blocked")
@@ -493,10 +492,7 @@ def update_channel(
sub.priority = body.priority sub.priority = body.priority
if body.hidden is not None: if body.hidden is not None:
sub.hidden = body.hidden sub.hidden = body.hidden
# The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto if body.deep_requested is not None:
# the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on
# False so the immediate backfill below can never fire for it.
if body.deep_requested is not None and not user.is_demo:
# Opt this channel into (or out of) full-history backfill. The deep scheduler # Opt this channel into (or out of) full-history backfill. The deep scheduler
# picks the flag up on its next run; turning it off won't undo already-fetched # picks the flag up on its next run; turning it off won't undo already-fetched
# videos, it just stops further deep paging if nobody else still wants it. # videos, it just stops further deep paging if nobody else still wants it.
@@ -507,7 +503,7 @@ def update_channel(
# Opting a channel into full history should give an immediate feed: if we haven't even # Opting a channel into full history should give an immediate feed: if we haven't even
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
# the scheduler. (deep_turned_on is only ever True for a non-demo user — see the guard above.) # the scheduler.
if deep_turned_on and channel is not None and channel.recent_synced_at is None: if deep_turned_on and channel is not None and channel.recent_synced_at is None:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
+15 -18
View File
@@ -2,8 +2,7 @@
The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes
are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including
for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account for an admin, who reads everyone's lines in the diagnostics console instead.
cannot record: its log would interleave every visitor under one account.
Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart
form — one image per request, capped while it arrives, and kept only if its bytes really are one of form — one image per request, capped while it arrives, and kept only if its bytes really are one of
@@ -21,7 +20,7 @@ from sqlalchemy.orm import Session
from app import audit, debugsession, logs, logsink from app import audit, debugsession, logs, logsink
from app.audit import AuditAction from app.audit import AuditAction
from app.auth import require_human from app.auth import current_user
from app.db import get_db, release_read_connection from app.db import get_db, release_read_connection
from app.issues import bundle, gitea from app.issues import bundle, gitea
from app.ratelimit import RateLimiter from app.ratelimit import RateLimiter
@@ -51,7 +50,7 @@ _new_sessions = RateLimiter(max_events=20, window_seconds=3600)
@router.post("") @router.post("")
def start_session( def start_session(
body: StartIn, body: StartIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Start recording, or join the recording already running (another tab started it).""" """Start recording, or join the recording already running (another tab started it)."""
@@ -79,7 +78,7 @@ def start_session(
@router.post("/{sid}/stop") @router.post("/{sid}/stop")
def stop_session( def stop_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session") session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if session.ended_at is None: if session.ended_at is None:
@@ -93,7 +92,7 @@ def stop_session(
@router.get("") @router.get("")
def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def list_sessions(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
sessions = db.scalars( sessions = db.scalars(
select(DebugSession) select(DebugSession)
.where(DebugSession.user_id == user.id) .where(DebugSession.user_id == user.id)
@@ -105,14 +104,14 @@ def list_sessions(user: User = Depends(require_human), db: Session = Depends(get
@router.get("/{sid}") @router.get("/{sid}")
def get_session( def get_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session")) return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session"))
@router.get("/{sid}/events") @router.get("/{sid}/events")
def session_events( def session_events(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""The rows an issue from this session would carry — browser and server, in time order.""" """The rows an issue from this session would carry — browser and server, in time order."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session") session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
@@ -141,7 +140,7 @@ class DraftIn(BaseModel):
def save_draft( def save_draft(
sid: str, sid: str,
body: DraftIn, body: DraftIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session") session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
@@ -162,7 +161,7 @@ def save_draft(
@router.get("/{sid}/export") @router.get("/{sid}/export")
def export_session( def export_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> Response: ) -> Response:
"""The issue as a zip — the text, the two logs and the screenshots — for an instance without an """The issue as a zip — the text, the two logs and the screenshots — for an instance without an
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
@@ -208,9 +207,7 @@ def _issues_rate_limited() -> HTTPException:
@router.post("/{sid}/issue") @router.post("/{sid}/issue")
def send_issue( def send_issue(sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""Send the session's issue to the tracker — from the SAVED draft, as the export is built — or """Send the session's issue to the tracker — from the SAVED draft, as the export is built — or
finish a send that stopped half-way (the issue exists; only what is missing is sent).""" finish a send that stopped half-way (the issue exists; only what is missing is sent)."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session") session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
@@ -302,7 +299,7 @@ class PreviewIn(BaseModel):
def preview_issue( def preview_issue(
sid: str, sid: str,
body: PreviewIn, body: PreviewIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The body as the tracker will render it. Gitea sanitises its own output; the composer still """The body as the tracker will render it. Gitea sanitises its own output; the composer still
@@ -323,14 +320,14 @@ def _shot_view(shot: DebugScreenshot) -> dict:
@router.get("/{sid}/screenshots") @router.get("/{sid}/screenshots")
def list_screenshots( def list_screenshots(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session") session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]} return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]}
def _upload_target( def _upload_target(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> DebugSession: ) -> DebugSession:
return owned_or_404(db, DebugSession, sid, user, what="debug_session") return owned_or_404(db, DebugSession, sid, user, what="debug_session")
@@ -393,7 +390,7 @@ def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScr
@router.get("/{sid}/screenshots/{shot_id}") @router.get("/{sid}/screenshots/{shot_id}")
def get_screenshot( def get_screenshot(
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, shot_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> Response: ) -> Response:
shot = _session_screenshot(db, sid, shot_id, user) shot = _session_screenshot(db, sid, shot_id, user)
# `nosniff`: the type was checked against the bytes at upload, and the browser must not guess. # `nosniff`: the type was checked against the bytes at upload, and the browser must not guess.
@@ -406,7 +403,7 @@ def get_screenshot(
@router.delete("/{sid}/screenshots/{shot_id}") @router.delete("/{sid}/screenshots/{shot_id}")
def delete_screenshot( def delete_screenshot(
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) sid: str, shot_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session") session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
shot = _screenshot_of(db, session, shot_id) shot = _screenshot_of(db, session, shot_id)
+7 -8
View File
@@ -17,7 +17,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import current_user, require_human from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.discover import hero as hero_mod from app.discover import hero as hero_mod
from app.discover import pins, rails from app.discover import pins, rails
@@ -127,15 +127,14 @@ def list_pins(
@router.post("/pins") @router.post("/pins")
def add_pin( def add_pin(
body: PinIn, body: PinIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Pin something to the comfort shelf. """Pin something to the comfort shelf.
`require_human`, like every other personal-state write: the shared demo account must not be able Re-pinning something already on the shelf is a no-op that returns the existing pin rather than
to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the a 409 — the gesture is "make sure this is on my shelf", and the unique constraint is the
existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique mechanism, not the message."""
constraint is the mechanism, not the message."""
resolved = pins.resolve_ref(db, user, body.kind, body.ref) resolved = pins.resolve_ref(db, user, body.kind, body.ref)
if resolved is None: if resolved is None:
raise not_found("item") raise not_found("item")
@@ -178,7 +177,7 @@ def add_pin(
@router.delete("/pins/{pin_id}") @router.delete("/pins/{pin_id}")
def remove_pin( def remove_pin(
pin_id: int, pin_id: int,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pin = owned_or_404(db, ComfortPin, pin_id, user, what="pin") pin = owned_or_404(db, ComfortPin, pin_id, user, what="pin")
@@ -190,7 +189,7 @@ def remove_pin(
@router.post("/pins/reorder") @router.post("/pins/reorder")
def reorder_pins( def reorder_pins(
body: ReorderIn, body: ReorderIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Rewrite the shelf order from the list of pin ids the client shows. """Rewrite the shelf order from the list of pin ids the client shows.
+40 -45
View File
@@ -1,9 +1,8 @@
"""Download center HTTP API — the per-user surface over the worker/cache/quota layers. """Download center HTTP API — the per-user surface over the worker/cache/quota layers.
All routes are behind `require_human` (the shared demo account can't spend server disk / needs a All routes are behind `current_user`; admin routes add `admin_user`. Live job state is polled by the
real identity). Admin routes add `admin_user`. Live job state is polled by the frontend (the frontend (the worker is a separate process); this module never downloads — it enqueues, manages job
worker is a separate process); this module never downloads — it enqueues, manages job state, state, serves finished files (range-aware, with the user's custom display name), and shares them.
serves finished files (range-aware, with the user's custom display name), and shares them.
""" """
import os import os
@@ -24,7 +23,7 @@ from sqlalchemy.orm import Session
from app.mailtext import lang_for_user from app.mailtext import lang_for_user
from app.collation import collated from app.collation import collated
from app import audit, sysconfig from app import audit, sysconfig
from app.auth import admin_user, require_human from app.auth import admin_user, current_user
from app.config import settings from app.config import settings
from app.db import get_db, release_read_connection from app.db import get_db, release_read_connection
from app.downloads import credentials as dlcreds from app.downloads import credentials as dlcreds
@@ -212,7 +211,7 @@ def _serialize_profile(p: DownloadProfile) -> dict:
@router.get("/profiles") @router.get("/profiles")
def list_profiles(user: User = Depends(require_human), db: Session = Depends(get_db)) -> list[dict]: def list_profiles(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = ( rows = (
db.execute( db.execute(
select(DownloadProfile) select(DownloadProfile)
@@ -234,7 +233,7 @@ class CreateProfileIn(BaseModel):
@router.post("/profiles") @router.post("/profiles")
def create_profile( def create_profile(
body: CreateProfileIn, user: User = Depends(require_human), db: Session = Depends(get_db) body: CreateProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
name = body.name.strip() name = body.name.strip()
spec = body.spec spec = body.spec
@@ -262,7 +261,7 @@ class UpdateProfileIn(BaseModel):
def update_profile( def update_profile(
profile_id: int, profile_id: int,
body: UpdateProfileIn, body: UpdateProfileIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
@@ -284,7 +283,7 @@ def update_profile(
@router.delete("/profiles/{profile_id}") @router.delete("/profiles/{profile_id}")
def delete_profile( def delete_profile(
profile_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) profile_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or p.is_builtin or p.user_id != user.id: if p is None or p.is_builtin or p.user_id != user.id:
@@ -300,7 +299,7 @@ def delete_profile(
@router.get("/credentials") @router.get("/credentials")
def list_credentials(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def list_credentials(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return {"hosts": dlcreds.status_list(db, user.id)} return {"hosts": dlcreds.status_list(db, user.id)}
@@ -312,7 +311,7 @@ class SetCredentialIn(BaseModel):
def set_credential( def set_credential(
host_key: str, host_key: str,
body: SetCredentialIn, body: SetCredentialIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is """Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
@@ -337,7 +336,7 @@ def set_credential(
@router.delete("/credentials/{host_key}") @router.delete("/credentials/{host_key}")
def delete_credential( def delete_credential(
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db) host_key: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
if not dlcreds.is_supported_host(host_key): if not dlcreds.is_supported_host(host_key):
raise not_found("host") raise not_found("host")
@@ -388,7 +387,7 @@ class EnqueueDownloadIn(BaseModel):
@router.post("") @router.post("")
def enqueue_download( def enqueue_download(
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db) body: EnqueueDownloadIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
source_kind, source_ref = resolve_source(body.source) source_kind, source_ref = resolve_source(body.source)
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the # SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
@@ -413,7 +412,7 @@ def enqueue_download(
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a # The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by # style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
# FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous # FastAPI *before the first dependency runs*, so the auth dependency could not gate it and an anonymous
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20 # caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency # bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
# declared AFTER the auth one, is the only shape that closes it — the same one # declared AFTER the auth one, is the only shape that closes it — the same one
@@ -454,13 +453,13 @@ def _header_text(request: Request, name: str) -> str:
async def spool_upload( async def spool_upload(
request: Request, request: Request,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> AsyncIterator[SpooledUpload]: ) -> AsyncIterator[SpooledUpload]:
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives. """Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so `user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
naming `require_human` here makes the auth check a precondition of reading the body at all — a naming `current_user` here makes the auth check a precondition of reading the body at all — a
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit, MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
empty, quota, probe or move error, a client that vanished mid-upload — it is removed.""" empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
@@ -532,7 +531,7 @@ class EnqueueEditIn(BaseModel):
@router.post("/edit") @router.post("/edit")
def enqueue_edit( def enqueue_edit(
body: EnqueueEditIn, user: User = Depends(require_human), db: Session = Depends(get_db) body: EnqueueEditIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""Queue a phase-2 editor derivative (trim/crop) of a download this user can access. """Queue a phase-2 editor derivative (trim/crop) of a download this user can access.
@@ -560,9 +559,7 @@ def _quota_detail(e: quota.QuotaExceeded) -> dict:
@router.get("") @router.get("")
def list_downloads( def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
jobs = ( jobs = (
db.execute( db.execute(
select(DownloadJob) select(DownloadJob)
@@ -578,7 +575,7 @@ def list_downloads(
@router.get("/usage") @router.get("/usage")
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The caller's download quota, plus the per-file upload cap. """The caller's download quota, plus the per-file upload cap.
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
@@ -600,7 +597,7 @@ _INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
@router.get("/index") @router.get("/index")
def my_download_index(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def my_download_index(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""source_ref -> best active status, for the feed's per-card "downloaded / queued" badge. """source_ref -> best active status, for the feed's per-card "downloaded / queued" badge.
Small + cheap so the feed can poll it without loading the full job list.""" Small + cheap so the feed can poll it without loading the full job list."""
rows = db.execute( rows = db.execute(
@@ -617,9 +614,7 @@ def my_download_index(user: User = Depends(require_human), db: Session = Depends
@router.get("/shared") @router.get("/shared")
def shared_with_me( def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
jobs = ( jobs = (
db.execute( db.execute(
select(DownloadJob) select(DownloadJob)
@@ -679,7 +674,7 @@ class UpdateDownloadMetaIn(BaseModel):
def update_download_meta( def update_download_meta(
job_id: int, job_id: int,
body: UpdateDownloadMetaIn, body: UpdateDownloadMetaIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Update a download's display metadata: title, channel name + link, and extra reference URLs. """Update a download's display metadata: title, channel name + link, and extra reference URLs.
@@ -702,7 +697,7 @@ def update_download_meta(
@router.post("/{job_id}/pause") @router.post("/{job_id}/pause")
def pause_download( def pause_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
job = _own_job_for_update(db, user, job_id) job = _own_job_for_update(db, user, job_id)
service.pause(db, job) service.pause(db, job)
@@ -711,7 +706,7 @@ def pause_download(
@router.post("/{job_id}/resume") @router.post("/{job_id}/resume")
def resume_download( def resume_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
job = _own_job_for_update(db, user, job_id) job = _own_job_for_update(db, user, job_id)
service.resume(db, job) service.resume(db, job)
@@ -720,7 +715,7 @@ def resume_download(
@router.post("/{job_id}/redownload") @router.post("/{job_id}/redownload")
def redownload_download( def redownload_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree. """Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree.
@@ -747,7 +742,7 @@ def redownload_download(
@router.post("/{job_id}/cancel") @router.post("/{job_id}/cancel")
def cancel_download( def cancel_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
job = _own_job_for_update(db, user, job_id) job = _own_job_for_update(db, user, job_id)
service.cancel(db, job) service.cancel(db, job)
@@ -756,7 +751,7 @@ def cancel_download(
@router.delete("/{job_id}") @router.delete("/{job_id}")
def delete_download( def delete_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
job = _own_job_for_update(db, user, job_id) job = _own_job_for_update(db, user, job_id)
service.delete(db, job) service.delete(db, job)
@@ -786,7 +781,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
def download_file( def download_file(
job_id: int, job_id: int,
request: Request, request: Request,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
@@ -825,7 +820,7 @@ def download_file(
@router.get("/{job_id}/storyboard") @router.get("/{job_id}/storyboard")
def storyboard_meta( def storyboard_meta(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""Filmstrip geometry for the editor's scrub track, generating the sprite on first use. """Filmstrip geometry for the editor's scrub track, generating the sprite on first use.
@@ -850,7 +845,7 @@ def storyboard_meta(
@router.get("/{job_id}/poster.jpg") @router.get("/{job_id}/poster.jpg")
def poster_image(job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)): def poster_image(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""The locally-generated poster frame for a download whose source had no thumbnail.""" """The locally-generated poster frame for a download whose source had no thumbnail."""
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
@@ -864,7 +859,7 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session =
@router.get("/{job_id}/storyboard.jpg") @router.get("/{job_id}/storyboard.jpg")
def storyboard_image( def storyboard_image(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
): ):
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
@@ -887,7 +882,7 @@ class ShareDownloadIn(BaseModel):
def share_download( def share_download(
job_id: int, job_id: int,
body: ShareDownloadIn, body: ShareDownloadIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download") job = owned_or_404(db, DownloadJob, job_id, user, what="download")
@@ -899,7 +894,7 @@ def share_download(
recipient = db.execute( recipient = db.execute(
select(User).where(func.lower(User.email) == target) select(User).where(func.lower(User.email) == target)
).scalar_one_or_none() ).scalar_one_or_none()
# Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so # Uniform "no user" for a missing OR non-messageable (suspended/deactivated) target, so
# sharing can't be used to probe which addresses exist or to reach a suspended account. # sharing can't be used to probe which addresses exist or to reach a suspended account.
if not is_messageable_user(recipient): if not is_messageable_user(recipient):
raise not_found("recipient") raise not_found("recipient")
@@ -937,7 +932,7 @@ def share_download(
def unshare_download( def unshare_download(
job_id: int, job_id: int,
email: str, email: str,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
owned_or_404( owned_or_404(
@@ -961,7 +956,7 @@ def unshare_download(
@router.delete("/shared/{job_id}") @router.delete("/shared/{job_id}")
def remove_shared_with_me( def remove_shared_with_me(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes """Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes
only the recipient's share grant — the owner's job and the physical file are untouched (this is only the recipient's share grant — the owner's job and the physical file are untouched (this is
@@ -982,10 +977,10 @@ def remove_shared_with_me(
@router.get("/recipients") @router.get("/recipients")
def share_recipients( def share_recipients(
user: User = Depends(require_human), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]: ) -> list[dict]:
"""Registered users this user can share a download with (excludes self + any non-messageable """Registered users this user can share a download with (excludes self + any non-messageable
account — demo, suspended, or deactivated — so the picker never leaks those addresses).""" account — suspended or deactivated — so the picker never leaks those addresses)."""
rows = ( rows = (
db.execute( db.execute(
select(User) select(User)
@@ -1021,7 +1016,7 @@ def _link_expiry(days: int | None) -> datetime | None:
@router.get("/{job_id}/links") @router.get("/{job_id}/links")
def list_links( def list_links(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]: ) -> list[dict]:
owned_or_404(db, DownloadJob, job_id, user, what="download") owned_or_404(db, DownloadJob, job_id, user, what="download")
rows = ( rows = (
@@ -1051,7 +1046,7 @@ class CreateLinkIn(BaseModel):
def create_link( def create_link(
job_id: int, job_id: int,
body: CreateLinkIn, body: CreateLinkIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download") job = owned_or_404(db, DownloadJob, job_id, user, what="download")
@@ -1082,7 +1077,7 @@ class UpdateLinkIn(BaseModel):
def update_link( def update_link(
link_id: int, link_id: int,
body: UpdateLinkIn, body: UpdateLinkIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
link = _own_link(db, user, link_id) link = _own_link(db, user, link_id)
@@ -1103,7 +1098,7 @@ def update_link(
@router.delete("/links/{link_id}") @router.delete("/links/{link_id}")
def revoke_link( def revoke_link(
link_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) link_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
link = _own_link(db, user, link_id) link = _own_link(db, user, link_id)
db.delete(link) db.delete(link)
+1 -5
View File
@@ -419,11 +419,7 @@ def get_video_detail(
"duration_seconds": v.duration_seconds, "duration_seconds": v.duration_seconds,
} }
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit.
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
if user.is_demo:
raise not_found("video")
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP),
+5 -16
View File
@@ -1,6 +1,6 @@
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, Request
from pydantic import BaseModel, RootModel from pydantic import BaseModel, RootModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -131,7 +131,6 @@ def get_me(
"display_name": user.display_name, "display_name": user.display_name,
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"role": user.role, "role": user.role,
"is_demo": user.is_demo,
"has_google": user.google_sub is not None, "has_google": user.google_sub is not None,
# The address Google answers with, when it DIFFERS from the account's own. Same string for # The address Google answers with, when it DIFFERS from the account's own. Same string for
# most people, and then this is None so no surface has to decide whether to show it — the # most people, and then this is None so no surface has to decide whether to show it — the
@@ -141,7 +140,7 @@ def get_me(
), ),
"has_password": user.password_hash is not None, "has_password": user.password_hash is not None,
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the # The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
# user does not recognise is something they can see (null: none since 0.88, or the demo). # user does not recognise is something they can see (null: none since 0.88).
"last_login_at": iso(user.last_login_at), "last_login_at": iso(user.last_login_at),
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides # Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
# YouTube-access affordances and the onboarding nudge when it isn't configured). # YouTube-access affordances and the onboarding nudge when it isn't configured).
@@ -184,15 +183,8 @@ def delete_my_account(
"""GDPR self-service erasure: permanently delete the signed-in account and ALL its personal """GDPR self-service erasure: permanently delete the signed-in account and ALL its personal
data — OAuth tokens, subscriptions, tags, watch/save/hide states, playlists, notifications — data — OAuth tokens, subscriptions, tags, watch/save/hide states, playlists, notifications —
which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL). which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL).
The shared demo account can't be deleted, and the last remaining admin can't delete itself The last remaining admin can't delete itself (that would lock everyone out of the admin
(that would lock everyone out of the admin surfaces).""" surfaces)."""
# Structured, like the other three demo refusals: `req` leaves 403 to the caller and
# `notifyActionError` discards a plain-string detail in favour of the caller's translated
# fallback, so a hand-written English sentence here reaches a Hungarian UI as a generic
# "couldn't do that" — or, on this affordance, as nothing at all. `reason` distinguishes it
# from the other sites' bare `{code: "demo"}` so the UI can say what specifically is refused.
if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
if user.role == "admin" and count_admins(db) <= 1: if user.role == "admin" and count_admins(db) <= 1:
raise refused(400, "only_admin_delete") raise refused(400, "only_admin_delete")
user_id = user.id user_id = user.id
@@ -242,10 +234,7 @@ def update_diagnostics(
) -> dict: ) -> dict:
"""Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the """Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the
base. The expiry is the server's: a raised level always lapses after base. The expiry is the server's: a raised level always lapses after
`diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise `diagnostics.ELEVATION_HOURS`, whatever the client asks."""
it — its log would interleave every visitor's session under one account."""
if user.is_demo and body.level != diagnostics.BASE:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "diagnostics"})
diagnostics.set_level(user, body.level) diagnostics.set_level(user, body.level)
db.commit() db.commit()
# The rest of THIS request, and the process's elevation registry, follow the new level now. # The rest of THIS request, and the process's elevation registry, follow the new level now.
+1 -5
View File
@@ -1,9 +1,5 @@
"""Per-user notification inbox (phase 1): the durable, server-backed center that coexists """Per-user notification inbox (phase 1): the durable, server-backed center that coexists
with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations. with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations."""
All endpoints use `current_user` (not `require_human`): notifications and their read/dismiss
state are personal UI state with no quota or YouTube involvement, so the shared demo account
manages its own inbox normally."""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
+2 -2
View File
@@ -1,7 +1,7 @@
"""Plex route package — assembles the admin/browse/playback sub-routers. """Plex route package — assembles the admin/browse/playback sub-routers.
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo + Read endpoints (libraries/browse/show/image) are available to ANY authenticated user
pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv (pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv
account, and playback is a local file. Admin endpoints test the connection and run the catalog sync. account, and playback is a local file. Admin endpoints test the connection and run the catalog sync.
Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app
+1 -1
View File
@@ -1,6 +1,6 @@
"""Public, login-free surface for shared download links (the `/watch/{token}` player page). """Public, login-free surface for shared download links (the `/watch/{token}` player page).
These routes are deliberately OUTSIDE the auth/`require_human` gate — the unguessable token IS the These routes are deliberately OUTSIDE the auth gate — the unguessable token IS the
credential (a capability URL). They only ever expose one file's stream + minimal metadata, never credential (a capability URL). They only ever expose one file's stream + minimal metadata, never
the app or any account data. Password-protected links exchange the password once at `/unlock` for the app or any account data. Password-protected links exchange the password once at `/unlock` for
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header). a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
+7 -8
View File
@@ -4,15 +4,14 @@ from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import require_human from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import SavedView, User from app.models import SavedView, User
from app.routes._common import owned_or_404 from app.routes._common import owned_or_404
from app.refusal import refused from app.refusal import refused
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by # Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list # `source`).
# (mirrors how other personal-state writes are scoped).
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"]) router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
# The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller # The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller
@@ -37,7 +36,7 @@ def _serialize(v: SavedView) -> dict:
@router.get("") @router.get("")
def list_views( def list_views(
source: str = "feed", source: str = "feed",
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[dict]: ) -> list[dict]:
source = _norm_source(source) source = _norm_source(source)
@@ -81,7 +80,7 @@ class CreateViewIn(BaseModel):
@router.post("") @router.post("")
def create_view( def create_view(
body: CreateViewIn, body: CreateViewIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
name = body.name.strip() name = body.name.strip()
@@ -124,7 +123,7 @@ class UpdateViewIn(BaseModel):
def update_view( def update_view(
view_id: int, view_id: int,
body: UpdateViewIn, body: UpdateViewIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
view = owned_or_404(db, SavedView, view_id, user, what="view") view = owned_or_404(db, SavedView, view_id, user, what="view")
@@ -163,7 +162,7 @@ def update_view(
@router.delete("/{view_id}") @router.delete("/{view_id}")
def delete_view( def delete_view(
view_id: int, view_id: int,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
view = owned_or_404(db, SavedView, view_id, user, what="view") view = owned_or_404(db, SavedView, view_id, user, what="view")
@@ -179,7 +178,7 @@ class ReorderViewsIn(BaseModel):
@router.post("/reorder") @router.post("/reorder")
def reorder_views( def reorder_views(
body: ReorderViewsIn, body: ReorderViewsIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
ids = body.ids ids = body.ids
+5 -5
View File
@@ -5,8 +5,8 @@ with the normal feed cards + in-app player and gain per-user state (watch / save
source is admin-selectable (``search_source``): **scrape** (InnerTube, the website's own source is admin-selectable (``search_source``): **scrape** (InnerTube, the website's own
endpoint, zero API quota — the default) or **api** (search.list, **100 quota units per page**). endpoint, zero API quota — the default) or **api** (search.list, **100 quota units per page**).
Either way it's gated behind a per-user daily cap, the API source additionally behind a budget Either way it's gated behind a per-user daily cap, the API source additionally behind a budget
pre-check, and only real users (never the shared demo account) may call it. The result ids are pre-check. The result ids are enriched through the cheap shared videos.list path (1 unit / 50)
enriched through the cheap shared videos.list path (1 unit / 50) regardless of source. regardless of source.
Ingested results are flagged (videos.via_search / channels.from_search) so the Library can hide Ingested results are flagged (videos.via_search / channels.from_search) so the Library can hide
this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never
@@ -23,7 +23,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app import logs, quota, sysconfig from app import logs, quota, sysconfig
from app.auth import require_human from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING, LIVE_OR_UPCOMING,
@@ -251,7 +251,7 @@ def search_youtube(
q: str | None = None, q: str | None = None,
cursor: str | None = None, cursor: str | None = None,
limit: int = 20, limit: int = 20,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Live YouTube search results materialised into the catalog. `limit` is how many results to """Live YouTube search results materialised into the catalog. `limit` is how many results to
@@ -402,7 +402,7 @@ class ClearSearchIn(BaseModel):
@router.post("/clear") @router.post("/clear")
def clear_search( def clear_search(
body: ClearSearchIn, body: ClearSearchIn,
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Discard the given live-search results "as if never added": drop this user's search-finds """Discard the given live-search results "as if never added": drop this user's search-finds
+3 -3
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app import audit, quota, state from app import audit, quota, state
from app.audit import AuditAction from app.audit import AuditAction
from app.auth import admin_user, current_user, has_read_scope, require_human from app.auth import admin_user, current_user, has_read_scope
from app.db import get_db from app.db import get_db
from app.models import Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
from app.routes._common import youtube_refusal from app.routes._common import youtube_refusal
@@ -26,7 +26,7 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
@router.post("/subscriptions") @router.post("/subscriptions")
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def sync_subscriptions(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
if not has_read_scope(user): if not has_read_scope(user):
# No YouTube read grant yet — the onboarding wizard hasn't been completed. # No YouTube read grant yet — the onboarding wizard hasn't been completed.
raise refused(403, "youtube_read_scope") raise refused(403, "youtube_read_scope")
@@ -138,7 +138,7 @@ def my_status(user: User = Depends(current_user), db: Session = Depends(get_db))
@router.post("/deep-all") @router.post("/deep-all")
def deep_all( def deep_all(
user: User = Depends(require_human), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
on: bool = True, on: bool = True,
) -> dict: ) -> dict:
+2 -17
View File
@@ -13,9 +13,8 @@ from app import audit, debugsession, logs, logsink, progress, quota
from app.audit import AuditAction from app.audit import AuditAction
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.demo import reset_demo_state
from app.downloads.gc import run_download_gc from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting, User from app.models import SchedulerSetting
from app.plex.sync import sync as run_plex_sync from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich from app.plex.tmdb import run_people_enrich
@@ -63,7 +62,6 @@ JOB_INTERVALS: dict[str, int] = {
"subscriptions": settings.subscriptions_resync_minutes, "subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes, "playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes, "maintenance": settings.maintenance_interval_minutes,
"demo_reset": settings.demo_reset_minutes,
"explore_cleanup": settings.explore_cleanup_minutes, "explore_cleanup": settings.explore_cleanup_minutes,
"download_gc": settings.download_gc_minutes, "download_gc": settings.download_gc_minutes,
"plex_sync": settings.plex_sync_interval_min, "plex_sync": settings.plex_sync_interval_min,
@@ -139,8 +137,7 @@ def _notify_done(
def _run_changed(result) -> bool: def _run_changed(result) -> bool:
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.) """Did an automatic job run actually do something worth an audit row? (No-op polls don't.)
A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex
off — the default) or {"skipped": "no demo account"} are no-ops, not changes, so they don't off — the default) are no-ops, not changes, so they don't flood the trail every interval."""
flood the trail every interval."""
if result is None: if result is None:
return False return False
if isinstance(result, dict): if isinstance(result, dict):
@@ -370,17 +367,6 @@ def _maintenance_job() -> None:
_job("maintenance", work) _job("maintenance", work)
def _demo_reset_job() -> None:
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo account.
def work(db):
demo = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if demo is None:
return {"skipped": "no demo account"}
return {"playlists_seeded": reset_demo_state(db, demo)}
_job("demo_reset", work)
def _explore_cleanup_job() -> None: def _explore_cleanup_job() -> None:
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after # Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
# their grace periods. 0 quota (pure DB), so no quota attribution. # their grace periods. 0 quota (pure DB), so no quota attribution.
@@ -466,7 +452,6 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"subscriptions": _subscriptions_job, "subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job, "playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job, "maintenance": _maintenance_job,
"demo_reset": _demo_reset_job,
"explore_cleanup": _explore_cleanup_job, "explore_cleanup": _explore_cleanup_job,
"download_gc": _download_gc_job, "download_gc": _download_gc_job,
"plex_sync": _plex_sync_job, "plex_sync": _plex_sync_job,
+8 -9
View File
@@ -1,23 +1,22 @@
"""Shared "who is a real, active, non-suspended human" predicate. """Shared "who is an active, non-suspended account" predicate.
Used wherever a user must be filtered out of a directory / recipient list or rejected as a target: Used wherever a user must be filtered out of a recipient list or rejected as a target: the download
the messaging directory AND the download share-by-email / recipient-picker endpoints. Kept in one share-by-email and recipient-picker endpoints. Kept in one place so the two forms (SQL clauses for a
place so the two forms (SQL clauses for a query, Python check for a loaded row) can't drift apart, query, Python check for a loaded row) can't drift apart, and so a route module doesn't have to import
and so a route module doesn't have to import another route module just to reuse the rule. another route module just to reuse the rule.
""" """
from app.models import User from app.models import User
def messageable_clauses() -> list: def messageable_clauses() -> list:
"""SQL clauses for "a real, active, non-suspended human" — splat into a directory/recipient query.""" """SQL clauses for "an active, non-suspended account" — splat into a recipient query."""
return [ return [
User.is_demo.is_(False),
User.is_active.is_(True), User.is_active.is_(True),
User.is_suspended.is_(False), User.is_suspended.is_(False),
] ]
def is_messageable_user(u: User | None) -> bool: def is_messageable_user(u: User | None) -> bool:
"""Python mirror of `messageable_clauses()` for an already-loaded row (WS auth, send recipient).""" """Python mirror of `messageable_clauses()` for an already-loaded row (a share recipient)."""
return bool(u and not u.is_demo and u.is_active and not u.is_suspended) return bool(u and u.is_active and not u.is_suspended)
+1 -1
View File
@@ -13,7 +13,7 @@ def iso(dt: date | None) -> str | None:
# Single source of truth for "looks like an email" — used by every endpoint that accepts an # Single source of truth for "looks like an email" — used by every endpoint that accepts an
# address (auth register/reset/demo, admin invites + demo whitelist, the setup wizard). Keep # address (auth register/reset, admin invites, the setup wizard). Keep
# the strictness in one place so routes don't drift between this and a looser "@"-in check. # the strictness in one place so routes don't drift between this and a looser "@"-in check.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
-6
View File
@@ -22,12 +22,6 @@ def test_clearing_the_audit_log_keeps_the_count_as_data(as_user, db, make_user):
assert _after(db, "audit_clear") == {"cleared": 2} assert _after(db, "audit_clear") == {"cleared": 2}
def test_a_demo_reset_keeps_the_count_as_data(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin")
seeded = as_user(admin).post("/api/admin/demo/reset").json()["playlists_seeded"]
assert _after(db, "demo_reset") == {"playlists_seeded": seeded}
def test_a_discovery_purge_keeps_its_counts_as_data(as_user, db, make_user): def test_a_discovery_purge_keeps_its_counts_as_data(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin") admin = make_user(email="boss@example.com", role="admin")
body = as_user(admin).post("/api/admin/purge-discovery").json() body = as_user(admin).post("/api/admin/purge-discovery").json()
+2 -2
View File
@@ -120,7 +120,7 @@ def test_a_raised_ceiling_is_still_a_ceiling():
"""The difference between this and an exemption, and the reason the list is a mapping: a route """The difference between this and an exemption, and the reason the list is a mapping: a route
that needs more bytes does not thereby get unlimited bytes. Left uncounted, the upload route that needs more bytes does not thereby get unlimited bytes. Left uncounted, the upload route
would let an ANONYMOUS caller stream without bound — FastAPI parses a declared body before the would let an ANONYMOUS caller stream without bound — FastAPI parses a declared body before the
first dependency, so `require_human` has not run yet when the bytes land.""" first dependency, so the auth dependency has not run yet when the bytes land."""
over = b"x" * (BIG[("POST", "/api/things/{tid}/blob")] + 1) over = b"x" * (BIG[("POST", "/api/things/{tid}/blob")] + 1)
r = _client(routes=BIG).post("/api/things/abc/blob", content=over) r = _client(routes=BIG).post("/api/things/abc/blob", content=over)
assert r.status_code == 413 assert r.status_code == 413
@@ -514,7 +514,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa
A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE. A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE.
FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model) FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model)
BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only
thing between an anonymous caller and the whole allowance, `require_human` never gets a say, and thing between an anonymous caller and the whole allowance, the auth dependency never gets a say, and
the route's in-handler cap measures bytes that have already landed. That is exactly what P1 the route's in-handler cap measures bytes that have already landed. That is exactly what P1
closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a
`file: UploadFile = File(...)` body for one release. `file: UploadFile = File(...)` body for one release.
+4 -14
View File
@@ -1,5 +1,5 @@
"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table), """R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table),
the update_channel -> immediate-backfill wiring (incl. the demo guard), and the interactive the update_channel -> immediate-backfill wiring, and the interactive
"get full history" guard that keeps it from racing the scheduler's cursor or draining the budget.""" "get full history" guard that keeps it from racing the scheduler's cursor or draining the budget."""
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -89,7 +89,7 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
assert resp.json()["hidden"] is False assert resp.json()["hidden"] is False
def _setup_channel_and_sub(db, *, email, is_demo=False): def _setup_channel_and_sub(db, *, email):
ch = Channel( ch = Channel(
id="UC_wire", id="UC_wire",
title="Wire", title="Wire",
@@ -98,7 +98,7 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
uploads_playlist_id="UU_wire", uploads_playlist_id="UU_wire",
) )
db.add(ch) db.add(ch)
user = User(email=email, is_demo=is_demo) user = User(email=email)
db.add(user) db.add(user)
db.flush() db.flush()
db.add(Subscription(user_id=user.id, channel_id=ch.id, deep_requested=False)) db.add(Subscription(user_id=user.id, channel_id=ch.id, deep_requested=False))
@@ -117,7 +117,7 @@ def _patch_deep_requested(db, monkeypatch, override_dep, user):
return resp, calls return resp, calls
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch, override_dep): def test_deep_request_schedules_immediate_backfill(db, monkeypatch, override_dep):
ch, me = _setup_channel_and_sub(db, email="wire@example.com") ch, me = _setup_channel_and_sub(db, email="wire@example.com")
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me) resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@@ -126,16 +126,6 @@ def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch,
assert calls == [(ch.id, me.id)] assert calls == [(ch.id, me.id)]
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch, override_dep):
# The SHARED demo account must not be able to kick immediate full backfills (budget drain).
_ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True)
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, demo)
assert resp.status_code == 200, resp.text
assert calls == [], "demo must not schedule an immediate backfill"
# …and deep_requested is not persisted on the shared demo subscription either.
assert resp.json()["deep_requested"] is False
class TestImmediateBackfillGuard: class TestImmediateBackfillGuard:
"""backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per- """backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per-
channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a
+1 -6
View File
@@ -2,7 +2,7 @@
Four parts: Four parts:
* the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it * the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it
on its own, the demo account cannot record, nobody reaches another user's session (DB lane); on its own, nobody reaches another user's session (DB lane);
* the tag: a request naming the caller's own open session is logged at its level with every line * the tag: a request naming the caller's own open session is logged at its level with every line
tagged, while a header naming anything else is ignored (a real sign-in through `_session_user`); tagged, while a header naming anything else is ignored (a real sign-in through `_session_user`);
* the browser's batches: a session counts as consent to its level, and only its own events keep * the browser's batches: a session counts as consent to its level, and only its own events keep
@@ -71,11 +71,6 @@ def test_only_the_two_raised_levels_can_be_recorded(db, make_user, as_user):
assert client.post("/api/debug-sessions", json={"level": bogus}).status_code == 422 assert client.post("/api/debug-sessions", json={"level": bogus}).status_code == 422
def test_the_demo_account_cannot_record(db, make_user, as_user):
r = as_user(make_user(is_demo=True)).post("/api/debug-sessions", json={"level": "DEBUG"})
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
def test_stop_ends_it_and_me_stops_reporting_it(db, client, make_user): def test_stop_ends_it_and_me_stops_reporting_it(db, client, make_user):
_signed_in(client, make_user) _signed_in(client, make_user)
sid = _start(client)["id"] sid = _start(client)["id"]
-92
View File
@@ -1,92 +0,0 @@
"""The shared demo account's refusal says WHY, in the reader's language (deep-review round 6).
`require_human` answered `403 "Not available in the demo account."` — a plain string. `req` leaves
403 to the caller, and round 5 taught `notifyActionError` to discard a plain-string detail in favour
of the caller's already-translated fallback, so from that commit on the demo user got a generic
"couldn't do that" and no hint that the demo account was the reason. Not every affordance behind
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
Pinned at every site: the shared dependency, the two auth routes that hand-roll the same check,
the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
the code the way round 6's did. (E22 S5 added the fifth: raising the diagnostics level.) Needs the DB.
"""
import re
from pathlib import Path
from app.models import User
def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as_user):
demo = make_user(email="demo@example.com", is_demo=True)
client = as_user(demo)
for method, url, body in [
# `require_human` itself, through an affordance the UI does NOT gate on is_demo.
("post", "/api/discover/pins", {"kind": "video", "ref": "v1"}),
# The three routes that repeat the check inline rather than depending on the gate.
("post", "/auth/logout-others", None),
("post", "/auth/set-password", {"password": "a long enough password"}),
("delete", "/api/me/account", None),
# E22 S5: the shared account's log would interleave every visitor's session.
("put", "/api/me/diagnostics", {"level": "DEBUG"}),
]:
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
assert r.status_code == 403, (url, r.text)
detail = r.json()["detail"]
# Every one of them is a dict whose `code` the frontend can localize; the delete route adds
# a `reason` so its refusal can be worded specifically.
assert isinstance(detail, dict), (url, detail)
assert detail["code"] == "demo", (url, detail)
def test_every_is_demo_refusal_is_on_the_list():
"""A fifth site must not be able to appear unnoticed.
Round 6 converted three of four `is_demo` 403s and wrote a docstring saying it had converted
them all; the fourth kept answering a bare English string for another round because nothing
counted them. So count them here: every `is_demo`-guarded 403 in `app/` has to raise a
structured detail, and the number of them has to match what this file exercises."""
app_dir = Path(__file__).resolve().parents[1] / "app"
sites = []
for path in sorted(app_dir.rglob("*.py")):
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
# `if user.is_demo:` / `if u.is_demo and …:` — the hand-rolled gate, not the ORM
# filters (`User.is_demo.is_(False)`) and not the column definition.
if not re.search(r"^\s*if\s+\w+\.is_demo\b[^:]*:", line):
continue
block = "\n".join(lines[i : i + 6])
if "403" not in block:
continue
sites.append(f"{path.relative_to(app_dir)}:{i + 1}")
assert 'detail={"code": "demo"' in block, (
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
f"unstructured detail — the frontend cannot localize it:\n{block}"
)
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete and diagnostics level. A
# new one is welcome, but it has to be added to the loop above in the same commit.
assert len(sites) == 5, sites
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
"""The other half: the structured detail must not come from having broken the gate open the
other way. A non-demo account gets past `require_human` and fails (or succeeds) on its own
merits — anything but a 403 demo refusal."""
client = as_user(make_user(email="real@example.com"))
r = client.post("/auth/logout-others")
assert r.status_code == 200, r.text
assert not isinstance(r.json().get("detail"), dict)
def test_the_gate_reads_the_flag_and_not_the_email(db):
"""`is_demo` is a column, not a convention about the address — a real account that happens to
be called demo@ must not be refused."""
from app.auth import require_human
assert require_human(User(email="demo@example.com", is_demo=False)) is not None
+2 -9
View File
@@ -1,8 +1,8 @@
"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in. """E22 S5 — a user's consented diagnostics level, and the browser log it lets in.
Three parts: Three parts:
* the level itself: the server computes its expiry, a lapsed one reads as the base, the demo * the level itself: the server computes its expiry, a lapsed one reads as the base (DB lane,
account cannot raise it (DB lane, through the real routes); through the real routes);
* `POST /api/diagnostics/events`: refused at the base level, and nothing in a batch is trusted — * `POST /api/diagnostics/events`: refused at the base level, and nothing in a batch is trusted —
the account comes from the session, everything is redacted again, events under the consented the account comes from the session, everything is redacted again, events under the consented
level are dropped, sizes and pace are capped (DB lane); level are dropped, sizes and pace are capped (DB lane);
@@ -93,13 +93,6 @@ def test_only_the_three_levels_can_be_chosen(db, make_user, as_user):
assert client.put("/api/me/diagnostics", json={"level": bogus}).status_code == 422 assert client.put("/api/me/diagnostics", json={"level": bogus}).status_code == 422
def test_the_demo_account_can_return_to_the_base_but_never_raise_it(db, make_user, as_user):
client = as_user(make_user(is_demo=True))
r = client.put("/api/me/diagnostics", json={"level": "TRACE"})
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
def test_preferences_must_be_a_json_object(db, make_user, as_user): def test_preferences_must_be_a_json_object(db, make_user, as_user):
client = as_user(make_user()) client = as_user(make_user())
assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422 assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422
+2 -15
View File
@@ -2,14 +2,13 @@
The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It
would not have: only password login called it, and the Google callback carried its own copy of the would not have: only password login called it, and the Google callback carried its own copy of the
same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp same four lines. So these pin BOTH doors through the real routes — plus the one that must not stamp
(a refused password, the shared demo account) and the admin list that shows it. (a refused password) and the admin list that shows it.
""" """
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.config import settings from app.config import settings
from app.models import DemoWhitelist
from app.routes import auth as auth_routes from app.routes import auth as auth_routes
_PW = "correct horse battery" _PW = "correct horse battery"
@@ -73,18 +72,6 @@ def test_a_google_sign_in_stamps_last_login(client, db, make_user, monkeypatch):
assert client.get("/api/me").json()["id"] == user.id assert client.get("/api/me").json()["id"] == user.id
def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
db.add(DemoWhitelist(email="visitor@example.com"))
db.commit()
r = client.post("/auth/demo", json={"email": "visitor@example.com"})
assert r.json() == {"ok": True, "authenticated": True}
demo = auth_routes.get_or_create_demo_user(db)
db.refresh(demo)
assert demo.last_login_at is None
def test_the_admin_user_list_carries_last_login(as_user, db, make_user): def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin") admin = make_user(email="boss@example.com", role="admin")
never = make_user(email="never@example.com") never = make_user(email="never@example.com")
+1 -1
View File
@@ -1,4 +1,4 @@
"""The sliding-window limiter behind /auth/register, password-login, password-reset and demo. """The sliding-window limiter behind /auth/register, password-login and password-reset.
Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears
the module-level limiters between tests), so without a test here a refactor could turn it into a the module-level limiters between tests), so without a test here a refactor could turn it into a
-2
View File
@@ -46,9 +46,7 @@ SERIALIZERS = {
"_collection_card", "_collection_card",
"_playlist_card", "_playlist_card",
"_serialize", "_serialize",
"_serialize_demo",
"_serialize_job", "_serialize_job",
"_serialize_msg",
"_serialize_profile", "_serialize_profile",
"_serialize_user", "_serialize_user",
"_summary", "_summary",
+1 -1
View File
@@ -16,7 +16,7 @@ def _client(db, user, monkeypatch, override_dep):
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
override_dep(get_db, db.info["request"]) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) # require_human resolves through this override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
+2 -2
View File
@@ -104,7 +104,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import app.state as state import app.state as state
from app.auth import require_human from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.routes import search as search_mod from app.routes import search as search_mod
@@ -146,7 +146,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom) monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
override_dep(get_db, db.info["request"]) override_dep(get_db, db.info["request"])
override_dep(require_human, lambda: user) override_dep(current_user, lambda: user)
resp = TestClient(app).get("/api/search/youtube?q=test+term") resp = TestClient(app).get("/api/search/youtube?q=test+term")
assert resp.status_code == 422, resp.text assert resp.status_code == 422, resp.text
-12
View File
@@ -155,18 +155,6 @@ def test_only_an_admin_can_mint_one(db, as_user, make_user):
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0 assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
"""The demo account is shared, so handing anyone a way to change its password hands them
everyone else's session on it."""
admin = make_user(role="admin")
demo = make_user(is_demo=True)
db.commit()
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "demo"
def test_the_access_panel_reports_the_gates(db, as_user, make_user): def test_the_access_panel_reports_the_gates(db, as_user, make_user):
admin = make_user(role="admin") admin = make_user(role="admin")
target = make_user(email_verified=False, is_active=False) target = make_user(email_verified=False, is_active=False)
+3 -4
View File
@@ -31,12 +31,11 @@ BIG_MODULES: dict[str, int] = {
"app/downloads/edit.py": 833, "app/downloads/edit.py": 833,
"app/downloads/pipeline.py": 870, "app/downloads/pipeline.py": 870,
"app/logs.py": 839, "app/logs.py": 839,
"app/models.py": 1607, "app/models.py": 1525,
"app/plex/stream.py": 1128, "app/plex/stream.py": 1128,
"app/plex/subtitles.py": 1092, "app/plex/subtitles.py": 1092,
"app/routes/admin.py": 864, "app/routes/auth.py": 1273,
"app/routes/auth.py": 1353, "app/routes/downloads.py": 1232,
"app/routes/downloads.py": 1237,
} }
COMPLEX: dict[str, int] = { COMPLEX: dict[str, int] = {
+1 -3
View File
@@ -186,7 +186,7 @@ def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
"""P1 — the defect E27 bounded but did not close. """P1 — the defect E27 bounded but did not close.
While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body
BEFORE the first dependency ran, so `require_human` could not gate it: an unauthenticated BEFORE the first dependency ran, so the auth dependency could not gate it: an unauthenticated
caller could make the server write the whole per-file allowance (500 MiB by default) to temp, caller could make the server write the whole per-file allowance (500 MiB by default) to temp,
per request. SEC-20's ceiling capped the number; it could not stop the writing. per request. SEC-20's ceiling capped the number; it could not stop the writing.
@@ -532,7 +532,6 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# file has transferred — so each of these has to be bounded, and each one here has been read. # file has transferred — so each of these has to be bounded, and each one here has been read.
# #
# Bounded by `display_text` unless noted: # Bounded by `display_text` unless noted:
# auth.get_or_create_demo_user a literal.
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the # service.redownload CLEARS the field so the fresh metadata wins — `None` is the
# one value that needs no bound, and a clear is still a write. # one value that needs no bound, and a clear is still a write.
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback # service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
@@ -544,7 +543,6 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails # `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails
# when a site appears, when one disappears, and when one changes what it writes. # when a site appears, when one disappears, and when one changes what it writes.
DISPLAY_WRITE_SITES = { DISPLAY_WRITE_SITES = {
("auth.py", "get_or_create_demo_user"): {"'Demo'"},
("downloads/service.py", "redownload"): {"None"}, ("downloads/service.py", "redownload"): {"None"},
("downloads/service.py", "enqueue"): { ("downloads/service.py", "enqueue"): {
"display_name or (asset.title[:DISPLAY_TEXT_MAX] " "display_name or (asset.title[:DISPLAY_TEXT_MAX] "
+6 -7
View File
@@ -1,16 +1,16 @@
"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging """is_messageable_user — the "active, non-suspended account" predicate reused by the download share
directory and the download share endpoints. A row must be non-demo AND active AND not-suspended.""" endpoints. A row must be active AND not-suspended."""
from types import SimpleNamespace from types import SimpleNamespace
from app.userscope import is_messageable_user from app.userscope import is_messageable_user
def _user(is_demo=False, is_active=True, is_suspended=False): def _user(is_active=True, is_suspended=False):
return SimpleNamespace(is_demo=is_demo, is_active=is_active, is_suspended=is_suspended) return SimpleNamespace(is_active=is_active, is_suspended=is_suspended)
def test_a_real_active_human_is_messageable(): def test_an_active_account_is_messageable():
assert is_messageable_user(_user()) is True assert is_messageable_user(_user()) is True
@@ -18,7 +18,6 @@ def test_none_is_not_messageable():
assert is_messageable_user(None) is False assert is_messageable_user(None) is False
def test_demo_suspended_or_inactive_are_not_messageable(): def test_suspended_or_inactive_are_not_messageable():
assert is_messageable_user(_user(is_demo=True)) is False
assert is_messageable_user(_user(is_suspended=True)) is False assert is_messageable_user(_user(is_suspended=True)) is False
assert is_messageable_user(_user(is_active=False)) is False assert is_messageable_user(_user(is_active=False)) is False
+1 -1
View File
@@ -40,7 +40,7 @@ login-free `/watch/<token>` share link).
## Account & fixture ## Account & fixture
Tests run as a dedicated, **non-demo admin** account `e2e@siftlode.local` (never a real account; Tests run as a dedicated **admin** account `e2e@siftlode.local` (never a real account;
admin so the suite can reach the admin-only pages / render branches, matching the real primary admin so the suite can reach the admin-only pages / render branches, matching the real primary
user), reset to a deterministic baseline every run by user), reset to a deterministic baseline every run by
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run [`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
+1 -3
View File
@@ -42,7 +42,6 @@ import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap"; import { useGlobalKeymap } from "./lib/keymap";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import VerifyBanner from "./components/VerifyBanner"; import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab"; import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module // The built app version, NOT imported from releaseNotes — importing anything from that module
@@ -438,7 +437,6 @@ export default function App() {
banners={ banners={
<> <>
<VerifyBanner entry={verifyEntry} /> <VerifyBanner entry={verifyEntry} />
{me!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} /> <VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</> </>
} }
@@ -459,7 +457,7 @@ export default function App() {
<KeyboardHelp /> <KeyboardHelp />
<AppCommandPalette /> <AppCommandPalette />
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */} {/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
{me && !me.is_demo && <DebugRecorder />} {me && <DebugRecorder />}
<Suspense fallback={null}> <Suspense fallback={null}>
{aboutOpen && ( {aboutOpen && (
<About <About
+3 -10
View File
@@ -175,18 +175,14 @@ export default function AccountPanel({
} }
tone={a.has_google ? "ok" : "muted"} tone={a.has_google ? "ok" : "muted"}
/> />
{/* The exact moment; the list row beside it only says how long ago. Null has two different {/* The exact moment; the list row beside it only says how long ago. */}
meanings, so it says which: nothing recorded yet, or the shared demo account, which is
deliberately never stamped. */}
<AccessRow <AccessRow
icon={LogIn} icon={LogIn}
label={t("users.account.lastLogin")} label={t("users.account.lastLogin")}
value={ value={
a.last_login_at a.last_login_at
? formatDateTime(a.last_login_at, lang) ? formatDateTime(a.last_login_at, lang)
: a.is_demo : t("users.account.lastLoginNone")
? t("users.account.lastLoginDemo")
: t("users.account.lastLoginNone")
} }
/> />
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */} {/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
@@ -223,9 +219,7 @@ export default function AccountPanel({
icon={<Send />} icon={<Send />}
loading={resend.isPending} loading={resend.isPending}
onClick={() => resend.mutate()} onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as disabled={!a.email_configured || a.email_verified}
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified}
> >
<span className="flex-1 text-left">{t("users.account.resend")}</span> <span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the {/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
@@ -243,7 +237,6 @@ export default function AccountPanel({
fullWidth fullWidth
icon={<KeyRound />} icon={<KeyRound />}
loading={issue.isPending} loading={issue.isPending}
disabled={user.is_demo}
onClick={() => issue.mutate()} onClick={() => issue.mutate()}
className="border border-accent/40 text-accent" className="border border-accent/40 text-accent"
> >
@@ -44,7 +44,6 @@ const row = {
email: "gone@x", email: "gone@x",
role: "user", role: "user",
is_suspended: false, is_suspended: false,
is_demo: false,
email_verified: true, email_verified: true,
is_active: true, is_active: true,
} as unknown as AdminUserRow; } as unknown as AdminUserRow;
@@ -83,7 +82,6 @@ describe("the last sign-in column (E22 S1)", () => {
vi.spyOn(api, "adminUsers").mockResolvedValue([ vi.spyOn(api, "adminUsers").mockResolvedValue([
{ ...row, id: 3, email: "seen@x", last_login_at: at }, { ...row, id: 3, email: "seen@x", last_login_at: at },
{ ...row, id: 4, email: "never@x", last_login_at: null }, { ...row, id: 4, email: "never@x", last_login_at: null },
{ ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null },
] as never); ] as never);
render( render(
<QueryClientProvider <QueryClientProvider
@@ -94,7 +92,7 @@ describe("the last sign-in column (E22 S1)", () => {
); );
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy(); expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(2); expect(screen.getAllByText("—")).toHaveLength(1);
}); });
}); });
+9 -140
View File
@@ -53,9 +53,9 @@ import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the // Admin-only user & access management: roles and access requests (the Invite whitelist). Each
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab // section is its own tab (persisted across reloads); the Access tab carries a badge with the count
// carries a badge with the count of pending requests so it's visible without switching to it. // of pending requests so it's visible without switching to it.
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can // (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.) // pre-select the tab without importing this lazy-loaded page — see that file.)
@@ -81,7 +81,6 @@ export default function AdminUsers() {
label: t("users.tabs.access"), label: t("users.tabs.access"),
badge: me.pending_invites + me.pending_activations, badge: me.pending_invites + me.pending_activations,
}, },
{ id: "demo", label: t("users.tabs.demo") },
]; ];
const active = tabs.some((x) => x.id === tab) ? tab : "roles"; const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return ( return (
@@ -100,7 +99,6 @@ export default function AdminUsers() {
<PendingAccess /> <PendingAccess />
</> </>
)} )}
{active === "demo" && <AdminDemo />}
</div> </div>
</> </>
); );
@@ -160,7 +158,7 @@ function UsersRoles({ me }: { me: Me }) {
}); });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id), onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (demo / self / last admin) surface via the global error dialog; the rest, as above. // Guards (self / last admin) surface via the global error dialog; the rest, as above.
onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")), onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")),
}); });
const del = useMutation({ const del = useMutation({
@@ -213,16 +211,14 @@ function UsersRoles({ me }: { me: Me }) {
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and // The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row, // the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state // a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (the demo, yourself) stays listed with its reason. // badges, the last sign-in. A locked action (on yourself) stays listed with its reason.
const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id }); const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id });
const menuItems = (u: AdminUserRow): ActionMenuItem[] => { const menuItems = (u: AdminUserRow): ActionMenuItem[] => {
const self = u.id === me.id; const self = u.id === me.id;
const pending = pendingIds.has(u.id); const pending = pendingIds.has(u.id);
const locked = (demoNote: string, selfNote: string) => const roleNote = self ? t("users.roles.selfLocked") : undefined;
u.is_demo ? demoNote : self ? selfNote : undefined; const suspendNote = self ? t("users.suspend.selfLocked") : undefined;
const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked")); const deleteNote = self ? t("users.delete.locked") : undefined;
const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked"));
const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined;
return [ return [
{ {
id: "role", id: "role",
@@ -301,7 +297,6 @@ function UsersRoles({ me }: { me: Me }) {
<div className="text-2xs text-muted truncate">{u.email}</div> <div className="text-2xs text-muted truncate">{u.email}</div>
</Pressable> </Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>} {u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>} {u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>} {!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
{/* A Google sign-in proves the email, so "unverified" only applies to a {/* A Google sign-in proves the email, so "unverified" only applies to a
@@ -317,9 +312,7 @@ function UsersRoles({ me }: { me: Me }) {
hint={ hint={
u.last_login_at u.last_login_at
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) }) ? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) })
: u.is_demo : t("users.roles.lastLoginNone")
? t("users.roles.lastLoginDemo")
: t("users.roles.lastLoginNone")
} }
> >
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums"> <span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
@@ -784,130 +777,6 @@ function AdminInvites() {
); );
} }
function AdminDemo() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const list = useQuery({ queryKey: qk.demoWhitelist(), queryFn: () => api.adminDemoWhitelist() });
const [newEmail, setNewEmail] = useState("");
const add = useMutation({
mutationFn: (email: string) => api.addDemoWhitelist(email),
onSuccess: (_d, email) => {
setNewEmail("");
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.added", { email }) });
},
onError: (e) => notifyActionError(e, t("settings.demo.addFailed")),
});
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: (e) => notifyActionError(e, t("settings.demo.removeFailed")),
});
const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.demo.removeTitle"),
message: t("settings.demo.removeConfirm", { email: r.email }),
confirmLabel: t("settings.demo.remove"),
danger: true,
});
if (ok) remove.mutate(r.id);
};
const reset = useMutation({
mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) =>
notify({
level: "success",
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
}),
onError: (e) => notifyActionError(e, t("settings.demo.resetFailed")),
});
const rows = list.data ?? [];
return (
<Section card title={t("settings.demo.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
{rows.length > 0 && (
<div className="space-y-1.5 mb-3">
{rows.map((r) => (
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
<div className="text-2xs text-muted truncate">
{t("settings.demo.addedBy", { who: r.added_by })}
</div>
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<IconButton
noTitle
label={t("settings.demo.remove")}
size="sm"
variant="outline-danger"
onClick={() => onRemove(r)}
disabled={remove.isPending}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
</div>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2"
>
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")}
/>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
</Button>
</form>
<div className="mt-4 pt-3 border-t border-border">
<Tooltip hint={t("settings.demo.resetHint")}>
<Button
variant="glass"
size="sm"
icon={<RotateCcw />}
loading={reset.isPending}
onClick={async () => {
if (
await confirm({
title: t("settings.demo.resetTitle"),
message: t("settings.demo.resetConfirm"),
confirmLabel: t("settings.demo.reset"),
danger: true,
})
)
reset.mutate();
}}
>
{t("settings.demo.reset")}
</Button>
</Tooltip>
</div>
</Section>
);
}
function InviteRow({ function InviteRow({
inv, inv,
onApprove, onApprove,
@@ -68,7 +68,6 @@ import AppCommandPalette from "./AppCommandPalette";
const user = (over: Record<string, unknown> = {}) => ({ const user = (over: Record<string, unknown> = {}) => ({
id: 1, id: 1,
role: "user", role: "user",
is_demo: false,
plex_enabled: false, plex_enabled: false,
...over, ...over,
}); });
@@ -143,7 +142,7 @@ describe("AppCommandPalette", () => {
expect(requestSurprise).toHaveBeenCalledTimes(1); expect(requestSurprise).toHaveBeenCalledTimes(1);
}); });
it("offers the debug recorder by its state, and not to the demo account", () => { it("offers the debug recorder by its state", () => {
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:")); const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
mount(); mount();
expect(recorder()).toEqual(["action:recorder:start"]); expect(recorder()).toEqual(["action:recorder:start"]);
@@ -153,10 +152,5 @@ describe("AppCommandPalette", () => {
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } }); me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
mount(); mount();
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]); expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
cleanup();
me = user({ is_demo: true });
mount();
expect(recorder()).toEqual([]);
}); });
}); });
+24 -27
View File
@@ -151,34 +151,31 @@ export default function AppCommandPalette() {
icon: <Keyboard className="w-4 h-4" />, icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp, run: toggleKeyboardHelp,
}, },
// The debug recorder (E16) — not for the shared demo account, which cannot record. While // The debug recorder (E16). While it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// player or a dialog where the pill's buttons are a long Tab away. // player or a dialog where the pill's buttons are a long Tab away.
...(me?.is_demo ...(recordingSession
? [] ? [
: recordingSession {
? [ id: "action:recorder:step",
{ label: t("recorder.palette.step"),
id: "action:recorder:step", icon: <Flag className="w-4 h-4" />,
label: t("recorder.palette.step"), run: () => markStep(""),
icon: <Flag className="w-4 h-4" />, },
run: () => markStep(""), {
}, id: "action:recorder:stop",
{ label: t("recorder.palette.stop"),
id: "action:recorder:stop", icon: <Square className="w-4 h-4" />,
label: t("recorder.palette.stop"), run: () => stopMutate(recordingSession.id),
icon: <Square className="w-4 h-4" />, },
run: () => stopMutate(recordingSession.id), ]
}, : [
] {
: [ id: "action:recorder:start",
{ label: t("recorder.palette.start"),
id: "action:recorder:start", icon: <Bug className="w-4 h-4" />,
label: t("recorder.palette.start"), run: openRecorderStart,
icon: <Bug className="w-4 h-4" />, },
run: openRecorderStart, ]),
},
]),
...SCHEMES.map((s) => ({ ...SCHEMES.map((s) => ({
id: `action:theme:${s.id}`, id: `action:theme:${s.id}`,
label: t("palette.theme", { name: s.name }), label: t("palette.theme", { name: s.name }),
+23 -28
View File
@@ -152,18 +152,17 @@ export default function ChannelPage({
[channelId, qc, t], [channelId, qc, t],
); );
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo // First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
// account (explore spends shared quota) and when videos are already present (a revisit, or a // already present (a revisit, or a subscribed channel whose uploads the scheduler backfills).
// subscribed channel whose uploads the scheduler backfills).
useEffect(() => { useEffect(() => {
if (!ch || me.is_demo) return; if (!ch) return;
if (autoTried.current === channelId) return; if (autoTried.current === channelId) return;
if (ch.subscribed || ch.blocked) return; if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return; if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId; autoTried.current = channelId;
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel. // eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
void runExplore(); void runExplore();
}, [ch, channelId, me.is_demo, runExplore]); }, [ch, channelId, runExplore]);
const block = useMutation({ const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)), mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
@@ -238,9 +237,9 @@ export default function ChannelPage({
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic // Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success // on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too. // invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an // Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed
// unsubscribed/demo channel page never shows the control, so the request would be thrown away. // channel page never shows the control, so the request would be thrown away.
const tagsQuery = useTags({ enabled: !!ch?.subscribed && !me.is_demo }); const tagsQuery = useTags({ enabled: !!ch?.subscribed });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({ const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) => optimisticToggle: (id, tagId, attach) =>
@@ -503,7 +502,7 @@ export default function ChannelPage({
<SlidersHorizontal className="w-4 h-4" /> <SlidersHorizontal className="w-4 h-4" />
</IconButton> </IconButton>
)} )}
{ch?.subscribed && !me.is_demo && ( {ch?.subscribed && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border"> <div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span> <span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper <PriorityStepper
@@ -520,7 +519,7 @@ export default function ChannelPage({
While loading, the label carries the live stored/total count (the detail poll refreshes While loading, the label carries the live stored/total count (the detail poll refreshes
it) so a large channel's paced tail reads as visible progress rather than a stuck it) so a large channel's paced tail reads as visible progress rather than a stuck
spinner. */} spinner. */}
{ch?.subscribed && !ch.backfill_done && !me.is_demo && ( {ch?.subscribed && !ch.backfill_done && (
<Button <Button
variant="outline" variant="outline"
onClick={() => !ch.deep_requested && deepBackfill.mutate()} onClick={() => !ch.deep_requested && deepBackfill.mutate()}
@@ -545,23 +544,21 @@ export default function ChannelPage({
</span> </span>
</Button> </Button>
)} )}
{!me.is_demo && ( <IconButton
<IconButton // Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle // light up on hover, as if a click would do something.
// light up on hover, as if a click would do something. size="md"
size="md" variant={ch?.blocked ? "danger-soft" : "outline-danger"}
variant={ch?.blocked ? "danger-soft" : "outline-danger"} onClick={() => block.mutate()}
onClick={() => block.mutate()} loading={block.isPending}
loading={block.isPending} label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")} >
> <Ban />
<Ban /> </IconButton>
</IconButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */} from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && ( {ch?.subscribed && (
<IconButton <IconButton
size="md" size="md"
variant={ch.hidden ? "outline-accent" : "outline"} variant={ch.hidden ? "outline-accent" : "outline"}
@@ -573,8 +570,7 @@ export default function ChannelPage({
{ch.hidden ? <EyeOff /> : <Eye />} {ch.hidden ? <EyeOff /> : <Eye />}
</IconButton> </IconButton>
)} )}
{!me.is_demo && {!ch?.blocked &&
!ch?.blocked &&
(ch?.subscribed ? ( (ch?.subscribed ? (
<Button <Button
variant="outline" variant="outline"
@@ -598,7 +594,7 @@ export default function ChannelPage({
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */} channel you actually follow. */}
{ch?.subscribed && !me.is_demo && ( {ch?.subscribed && (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<ChannelTagsControl <ChannelTagsControl
userTags={userTags} userTags={userTags}
@@ -631,7 +627,6 @@ export default function ChannelPage({
view={view} view={view}
setView={setView} setView={setView}
canRead={me.can_read} canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null} ytSearch={null}
onYtSearch={() => {}} onYtSearch={() => {}}
onExitYtSearch={() => {}} onExitYtSearch={() => {}}
@@ -117,7 +117,6 @@ function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
qc.setQueryData(qk.me(), { qc.setQueryData(qk.me(), {
id: 1, id: 1,
is_demo: false,
preferences: {}, preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null, debug_session: null,
@@ -317,7 +316,6 @@ describe("the past sessions a user goes back to check", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { qc.setQueryData(qk.me(), {
id: 1, id: 1,
is_demo: false,
preferences: {}, preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null, debug_session: null,
+1 -1
View File
@@ -36,7 +36,7 @@ import { SURFACE } from "./ui/surfaces";
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast // While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the // host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the // bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account other than the demo. // app shell, for a signed-in account.
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */ /** The time since `since`, as the app's other clocks show it, re-rendered every second. */
function useElapsed(since: string): string { function useElapsed(since: string): string {
-15
View File
@@ -1,15 +0,0 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import Banner from "./ui/Banner";
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
export default function DemoBanner() {
const { t } = useTranslation();
return (
<Banner tone="warning" icon={AlertTriangle}>
<span className="font-semibold">{t("common.demoTitle")}</span>
<span className="text-muted"> — {t("common.demoSharedNotice")}</span>
</Banner>
);
}
@@ -60,7 +60,6 @@ function mount(me: Partial<Me>) {
const qc = new QueryClient(); const qc = new QueryClient();
qc.setQueryData(qk.me(), { qc.setQueryData(qk.me(), {
id: 1, id: 1,
is_demo: false,
preferences: {}, preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
...me, ...me,
@@ -212,7 +211,6 @@ describe("DiagnosticsSettings", () => {
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy()); await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
answerStale({ answerStale({
id: 1, id: 1,
is_demo: false,
preferences: {}, preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 }, diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
}); });
@@ -229,10 +227,4 @@ describe("DiagnosticsSettings", () => {
); );
expect(screen.queryByTestId("diagnostics-status")).toBeNull(); expect(screen.queryByTestId("diagnostics-status")).toBeNull();
}); });
it("the shared demo account gets an explanation instead of the picker", () => {
mount({ is_demo: true });
expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
});
}); });
+17 -24
View File
@@ -7,7 +7,6 @@ import { levelInForce } from "../lib/diagnostics";
import { focusIsUnclaimed } from "../lib/useFocusKeeper"; import { focusIsUnclaimed } from "../lib/useFocusKeeper";
import { updateMeCache, useMe } from "../lib/useMe"; import { updateMeCache, useMe } from "../lib/useMe";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer"; import MyLogViewer from "./MyLogViewer";
@@ -99,24 +98,20 @@ export default function DiagnosticsSettings() {
return ( return (
<> <>
<Section title={t("settings.diagnostics.title")}> <Section title={t("settings.diagnostics.title")}>
{me.is_demo ? ( <SettingRow
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice> label={t("settings.diagnostics.level")}
) : ( hint={t("settings.diagnostics.levelHint")}
<SettingRow >
label={t("settings.diagnostics.level")} <div ref={pickerRef}>
hint={t("settings.diagnostics.levelHint")} <SegmentedToggle
> size="md"
<div ref={pickerRef}> ariaLabel={t("settings.diagnostics.level")}
<SegmentedToggle value={pending ?? current}
size="md" onChange={choose}
ariaLabel={t("settings.diagnostics.level")} options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
value={pending ?? current} />
onChange={choose} </div>
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))} </SettingRow>
/>
</div>
</SettingRow>
)}
{pending && ( {pending && (
<div <div
@@ -186,11 +181,9 @@ export default function DiagnosticsSettings() {
)} )}
</Section> </Section>
{!me.is_demo && ( <Section title={t("recorder.settings.title")}>
<Section title={t("recorder.settings.title")}> <RecorderSettings />
<RecorderSettings /> </Section>
</Section>
)}
<Section title={t("settings.myLog.title")}> <Section title={t("settings.myLog.title")}>
<MyLogViewer /> <MyLogViewer />
+3 -11
View File
@@ -1,17 +1,16 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is // Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
// clicked, so it never ships in the feed chunk for the majority who just browse. // clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog")); const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api"; import { api } from "../lib/api";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
// Self-contained download affordance for a video card / the player. Hidden for the demo account // Self-contained download affordance for a video card / the player. Reflects the video's current state from
// (downloads spend server disk + need a real identity). Reflects the video's current state from
// the shared per-user download index (one polled query shared across every card). // the shared per-user download index (one polled query shared across every card).
export default function DownloadButton({ export default function DownloadButton({
videoId, videoId,
@@ -25,20 +24,13 @@ export default function DownloadButton({
size?: "sm" | "md"; size?: "sm" | "md";
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const me = qc.getQueryData<Me>(qk.me());
const isDemo = !!me?.is_demo;
const indexQ = useQuery({ const indexQ = useQuery({
queryKey: qk.downloadIndex(), queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex, queryFn: api.downloadIndex,
enabled: !isDemo,
staleTime: 10000, staleTime: 10000,
}); });
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (isDemo) return null;
const status = indexQ.data?.[videoId]; const status = indexQ.data?.[videoId];
const downloaded = status === "done"; const downloaded = status === "done";
const inQueue = status === "queued" || status === "running" || status === "paused"; const inQueue = status === "queued" || status === "running" || status === "paused";
+16 -35
View File
@@ -111,7 +111,6 @@ export default function Feed({
view, view,
setView, setView,
canRead, canRead,
isDemo = false,
ytSearch, ytSearch,
onYtSearch, onYtSearch,
onExitYtSearch, onExitYtSearch,
@@ -125,11 +124,10 @@ export default function Feed({
view: FeedView; view: FeedView;
setView: (v: FeedView) => void; setView: (v: FeedView) => void;
canRead: boolean; canRead: boolean;
isDemo?: boolean;
// Live YouTube search: the active search term (null = normal feed). Entering a search and // Live YouTube search: the active search term (null = normal feed). Entering a search and
// leaving it both step browser history (the search is a feed sub-view with its own history // leaving it both step browser history (the search is a feed sub-view with its own history
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
// onExitYtSearch. Search affordances are hidden for demo. // onExitYtSearch.
ytSearch: string | null; ytSearch: string | null;
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
onExitYtSearch: () => void; onExitYtSearch: () => void;
@@ -595,41 +593,24 @@ export default function Feed({
{t("feed.loadError")} {t("feed.loadError")}
</StateMessage> </StateMessage>
); );
// Empty "my" feed with no YouTube access. The shared demo account can never connect // Empty "my" feed with no YouTube access: the onboarding prompt, plus a way into the shared
// YouTube, so it gets a gentle nudge into the shared library instead of the connect // library. No toolbar (sort/type are meaningless).
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my") if (items.length === 0 && !canRead && filters.scope === "my")
return ( return (
<div className="p-8 grid place-items-center text-center"> <div className="p-8 grid place-items-center text-center">
<div className="max-w-sm"> <div className="max-w-sm">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")} <p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
</h2> <Button variant="primary" size="lg" onClick={onOpenWizard}>
<p className="text-sm text-muted mt-2 mb-4"> {t("feed.setUp")}
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")} </Button>
</p> <Button
{isDemo ? ( variant="link-muted"
<Button className="mx-auto mt-3 flex text-sm"
variant="primary" onClick={() => setFilters({ ...filters, scope: "all" })}
size="lg" >
onClick={() => setFilters({ ...filters, scope: "all" })} {t("feed.browseShared")}
> </Button>
{t("feed.browseLibrary")}
</Button>
) : (
<>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.browseShared")}
</Button>
</>
)}
</div> </div>
</div> </div>
); );
@@ -879,7 +860,7 @@ export default function Feed({
</Button> </Button>
</div> </div>
)} )}
{filters.q.trim() && !isDemo && ( {filters.q.trim() && (
<Button <Button
className="mt-4" className="mt-4"
icon={<YouTubeIcon />} icon={<YouTubeIcon />}
@@ -1,12 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// The shared demo account has no subscriptions, so its feed opens on the whole library. The filters are // The feed's filters are seeded at mount from the account's stored view. A share link's filters are
// seeded from the `me` cache at mount — BootGate mounts the tree only once it is there. The adopt effect // written to the account once at mount too, so the view the link opened survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
// this replaced applied the demo default after mounting; with it gone, a seed that did not read `me`
// would open the demo account on its empty subscriptions scope.
//
// A share link's filters are written to the account once at mount too, so the view the link opened
// survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -49,20 +44,14 @@ afterEach(() => {
}); });
describe("FeedFiltersProvider seed", () => { describe("FeedFiltersProvider seed", () => {
it("opens the shared demo account on the whole library from its first render", () => { it("opens an account on its own scope", () => {
mount({ is_demo: true }); mount({});
expect(scopes.length).toBeGreaterThan(0);
expect(scopes).toEqual(scopes.map(() => "all"));
});
it("opens any other account on its own scope", () => {
mount({ is_demo: false });
expect(scopes[0]).toBe("my"); expect(scopes[0]).toBe("my");
}); });
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => { it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
window.history.replaceState(null, "", "/?scope=all"); window.history.replaceState(null, "", "/?scope=all");
mount({ is_demo: false }); mount({});
expect(scopes[0]).toBe("all"); expect(scopes[0]).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all"); expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
}); });
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys"; import { getActiveAccount, type FeedFilters } from "../lib/api";
import { useQueryClient } from "@tanstack/react-query";
import { getActiveAccount, type FeedFilters, type Me } from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror"; import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, writeJSON } from "../lib/storage"; import { accountKey, writeJSON } from "../lib/storage";
@@ -28,21 +26,16 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI
// feedFiltersInit.ts. // feedFiltersInit.ts.
// URL wins over localStorage so a pasted link reproduces the exact view. // URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(me: Me | null | undefined): FeedFilters { function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
const f = loadAccountFilters(getActiveAccount()); return loadAccountFilters(getActiveAccount());
// The shared demo account has no subscriptions, so default it to the whole library.
return me?.is_demo ? { ...f, scope: "all" } : f;
} }
export function FeedFiltersProvider({ children }: { children: ReactNode }) { export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
// Seeded for the account this tab is — BootGate mounts this provider only once the account is // Seeded for the account this tab is — BootGate mounts this provider only once the account is
// pinned and `me` is cached, so this is the account's own view from the first render. // pinned, so this is the account's own view from the first render.
const [filters, setFiltersState] = useState<FeedFilters>(() => const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
loadInitialFilters(qc.getQueryData<Me | null>(qk.me())),
);
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured // Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account. // once at mount — such filters win over the account's stored view and are persisted to the account.
const [initialHadUrlFilters] = useState(() => const [initialHadUrlFilters] = useState(() =>
@@ -31,7 +31,7 @@ import type { Me } from "../lib/api";
afterEach(cleanup); afterEach(cleanup);
function tipFor(source: Me["search_source"]): string | null { function tipFor(source: Me["search_source"]): string | null {
const me = { is_demo: false, plex_enabled: false, search_source: source } as Me; const me = { plex_enabled: false, search_source: source } as Me;
render(<Header me={me} onYtSearch={() => {}} />); render(<Header me={me} onYtSearch={() => {}} />);
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title"); return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
} }
+2 -3
View File
@@ -23,8 +23,7 @@ const Header = forwardRef<
HTMLDivElement, HTMLDivElement,
{ {
me: Me; me: Me;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account, // Trigger a live YouTube search for the current term (feed only).
// which can't spend the shared quota).
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
} }
>(function Header({ me, onYtSearch }, ref) { >(function Header({ me, onYtSearch }, ref) {
@@ -64,7 +63,7 @@ const Header = forwardRef<
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip", me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
)} )}
goIcon={<YouTubeIcon className="w-4 h-4" />} goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed || me.is_demo} goDisabled={!trimmed}
/> />
); );
} else if (page === "plex" && me.plex_enabled) { } else if (page === "plex" && me.plex_enabled) {
-1
View File
@@ -124,7 +124,6 @@ export default function NavSidebar({
// lightweight per-user index the feed cards poll, so no extra request shape. // lightweight per-user index the feed cards poll, so no extra request shape.
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, { const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
intervalMs: 30000, intervalMs: 30000,
enabled: !me.is_demo,
}); });
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
@@ -43,7 +43,7 @@ function mount() {
} }
render( render(
<> <>
<Header me={{ is_demo: false, plex_enabled: false } as Me} onYtSearch={() => {}} /> <Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
<Probe /> <Probe />
</>, </>,
); );
+6 -12
View File
@@ -20,7 +20,6 @@ import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal"; import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext"; import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext"; import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe";
import { DismissibleChip } from "./ui/Chip"; import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
@@ -51,9 +50,8 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// hand-off to PlexBrowse via the shared `playlistOpen` slot. // hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex(); const { scope, show, filters } = usePlex();
const { setFilters } = usePlexActions(); const { setFilters } = usePlexActions();
const isDemo = useMe().is_demo;
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the // Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
// title opens the full manager modal (same shared components as the feed). Hidden for the demo account. // title opens the full manager modal (same shared components as the feed).
const { openModal, modalOpen } = useSavedViews(); const { openModal, modalOpen } = useSavedViews();
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails // This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now. // share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
@@ -314,15 +312,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the // The Plex widgets are simple label + control rows now, not collapsible islands (matches the
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it. // feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
collapsible={false} collapsible={false}
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden // Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source). // Same shared components as the feed rail (SavedViewsProvider switches source).
iconExpanded={ iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
isDemo ? undefined : ( onTitleClick={openModal}
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} /> titleActionLabel={t("views.manageTitle")}
)
}
onTitleClick={isDemo ? undefined : openModal}
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
/> />
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */} {/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />} {modalOpen && <PersonalViewsModal />}
@@ -33,7 +33,6 @@ const me = (extra: Partial<Me>): Me =>
display_name: "Someone", display_name: "Someone",
avatar_url: null, avatar_url: null,
role: "user", role: "user",
is_demo: false,
last_login_at: null, last_login_at: null,
...extra, ...extra,
}) as Me; }) as Me;
@@ -47,14 +46,10 @@ describe("AccountHeader", () => {
expect(screen.getByText("nav.role.user")).toBeTruthy(); expect(screen.getByText("nav.role.user")).toBeTruthy();
}); });
it("says when none is recorded, and says nothing about it for the demo", () => { it("says when none is recorded", () => {
render(<AccountHeader me={me({})} />); render(<AccountHeader me={me({})} />);
expect(screen.getByTestId("account-last-login").textContent).toBe( expect(screen.getByTestId("account-last-login").textContent).toBe(
"settings.account.lastLoginNone", "settings.account.lastLoginNone",
); );
cleanup();
render(<AccountHeader me={me({ is_demo: true })} />);
expect(screen.queryByTestId("account-last-login")).toBeNull();
expect(screen.getByText("nav.role.demo")).toBeTruthy();
}); });
}); });
+18 -26
View File
@@ -342,7 +342,7 @@ function AccessRow({
// Sign-in methods: link a Google account to a password account (or vice-versa set a password), // Sign-in methods: link a Google account to a password account (or vice-versa set a password),
// so either method can reach the same account. Google connect is a full-page OAuth round-trip // so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the current session via /auth/link); the password form posts // (auth.py attaches the identity to the current session via /auth/link); the password form posts
// directly with inline errors. Demo accounts never see this (handled by the caller). // directly with inline errors.
function SignInMethods({ me }: { me: Me }) { function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -785,13 +785,11 @@ export function AccountHeader({ me }: { me: Me }) {
<div className="text-xs text-muted truncate">{me.email}</div> <div className="text-xs text-muted truncate">{me.email}</div>
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */} {/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
<div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div> <div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div>
{!me.is_demo && ( <div className="text-xs text-muted" data-testid="account-last-login">
<div className="text-xs text-muted" data-testid="account-last-login"> {me.last_login_at
{me.last_login_at ? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) }) : t("settings.account.lastLoginNone")}
: t("settings.account.lastLoginNone")} </div>
</div>
)}
</div> </div>
</div> </div>
</Section> </Section>
@@ -823,17 +821,13 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
<> <>
<AccountHeader me={me} /> <AccountHeader me={me} />
{!me.is_demo && <SignInMethods me={me} />} <SignInMethods me={me} />
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />} {me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
{!me.is_demo && <DownloadAuth />} <DownloadAuth />
{me.is_demo ? ( {me.google_enabled ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
</Section>
) : me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured. // YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}> <Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1"> <p className="text-xs text-muted leading-relaxed mb-1">
@@ -865,17 +859,15 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</Section> </Section>
) : null} ) : null}
{!me.is_demo && ( <Section title={t("settings.account.dangerZone")}>
<Section title={t("settings.account.dangerZone")}> <p className="text-xs text-muted leading-relaxed mb-2">
<p className="text-xs text-muted leading-relaxed mb-2"> {t("settings.account.deleteHint")}
{t("settings.account.deleteHint")} </p>
</p> <Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}> <Trash2 className="w-4 h-4" />
<Trash2 className="w-4 h-4" /> {t("settings.account.deleteAccount")}
{t("settings.account.deleteAccount")} </Button>
</Button> </Section>
</Section>
)}
</> </>
); );
} }
+4 -6
View File
@@ -21,7 +21,6 @@ import Combobox from "./ui/Combobox";
import MatchToggle from "./ui/MatchToggle"; import MatchToggle from "./ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext"; import { useChannelsActions } from "./channelsContext";
import { useMe } from "../lib/useMe";
import { useTags } from "../lib/useTags"; import { useTags } from "../lib/useTags";
import { compareText } from "../lib/collate"; import { compareText } from "../lib/collate";
@@ -51,7 +50,6 @@ export default function Sidebar({
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const isDemo = useMe().is_demo;
const { openModal } = useSavedViews(); const { openModal } = useSavedViews();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
@@ -245,14 +243,14 @@ export default function Sidebar({
// The feed widgets are simple label + control rows, not collapsible islands. // The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false} collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the // Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only. // full manager modal. Feed rail only.
iconExpanded={ iconExpanded={
!isDemo && source === "feed" ? ( source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} /> <SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined ) : undefined
} }
onTitleClick={!isDemo && source === "feed" ? openModal : undefined} onTitleClick={source === "feed" ? openModal : undefined}
titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined} titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
> >
{tagManagerOpen && ( {tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> <TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
+28 -30
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react"; import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { api, type AdminQuotaRow } from "../lib/api";
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format"; import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -61,13 +61,13 @@ export default function Stats() {
</PageToolbar> </PageToolbar>
)} )}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />} {tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
</div> </div>
); );
} }
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. // Per-user view: sync status + your own API usage + the manual sync actions.
function Overview({ me }: { me: Me }) { function Overview() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
@@ -203,32 +203,30 @@ function Overview({ me }: { me: Me }) {
)} )}
</Section> </Section>
{!me.is_demo && ( <Section title={t("stats.sync.actions")}>
<Section title={t("stats.sync.actions")}> <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2"> <Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}> <Button
<Button variant="glass"
variant="glass" icon={<RefreshCw />}
icon={<RefreshCw />} loading={syncSubs.isPending}
loading={syncSubs.isPending} onClick={() => syncSubs.mutate()}
onClick={() => syncSubs.mutate()} >
> {t("stats.sync.syncSubscriptions")}
{t("stats.sync.syncSubscriptions")} </Button>
</Button> </Tooltip>
</Tooltip> <Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}> <Button
<Button variant="glass"
variant="glass" icon={<History />}
icon={<History />} loading={deepAll.isPending}
loading={deepAll.isPending} onClick={() => deepAll.mutate()}
onClick={() => deepAll.mutate()} >
> {t("stats.sync.backfillEverything")}
{t("stats.sync.backfillEverything")} </Button>
</Button> </Tooltip>
</Tooltip> </div>
</div> </Section>
</Section>
)}
</> </>
); );
} }
@@ -9,7 +9,6 @@ import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -99,7 +98,7 @@ function Feed({
); );
} }
function setup(v: Video, { demo = false } = {}) { function setup(v: Video) {
const handlers = { const handlers = {
onState: vi.fn(), onState: vi.fn(),
onToggleSave: vi.fn(), onToggleSave: vi.fn(),
@@ -107,7 +106,6 @@ function setup(v: Video, { demo = false } = {}) {
onOpen: vi.fn(), onOpen: vi.fn(),
}; };
const qc = new QueryClient(); const qc = new QueryClient();
qc.setQueryData(qk.me(), { is_demo: demo });
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<FloatingTree> <FloatingTree>
@@ -175,12 +173,6 @@ describe("a video's menu", () => {
expect(await screen.findByText("playlists.noneYet")).toBeTruthy(); expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
}); });
it("has no download row on the demo account", () => {
setup(video({}), { demo: true });
rightClick();
expect(labels()).not.toContain("downloads.menu.download");
});
it("the '…' opens the same menu, says so, and a second press closes it", () => { it("the '…' opens the same menu, says so, and a second press closes it", () => {
setup(video({})); setup(video({}));
expect(more().getAttribute("aria-haspopup")).toBe("menu"); expect(more().getAttribute("aria-haspopup")).toBe("menu");
+8 -62
View File
@@ -61,7 +61,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
}; };
// Public landing + authentication. One scrollable page: a hero with the auth card (email+ // Public landing + authentication. One scrollable page: a hero with the auth card (email+
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown // password, Google) over a short marketing pitch with feature cards. Shown
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the // whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
// backend); errors are surfaced inline (the api calls use the quiet flag). // backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome({ verify }: { verify: VerifyEntry }) { export default function Welcome({ verify }: { verify: VerifyEntry }) {
@@ -211,7 +211,7 @@ function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
} }
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder // Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
// frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/). // frame instead of a broken image (the screenshots drop into frontend/public/welcome/).
// When loaded it's a button that opens the full-size lightbox (onOpen). // When loaded it's a button that opens the full-size lightbox (onOpen).
function Preview({ function Preview({
src, src,
@@ -446,13 +446,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setDeadReset(false); setDeadReset(false);
} }
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
const [showDemo, setShowDemo] = useState(false);
const [demoEmail, setDemoEmail] = useState("");
const [demoBusy, setDemoBusy] = useState(false);
const [demoError, setDemoError] = useState("");
const reset = () => { const reset = () => {
setError(""); setError("");
setDone(""); setDone("");
@@ -533,25 +526,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setBusy(false); setBusy(false);
} }
async function onDemo(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setDemoError("");
const mail = demoEmail.trim().toLowerCase();
if (!EMAIL_RE.test(mail)) {
setDemoError(t("welcome.auth.invalidEmail"));
return;
}
setDemoBusy(true);
try {
const r = await api.demoLogin(mail);
if (r.authenticated) window.location.reload();
else setDemoError(t("welcome.auth.demoDenied"));
} catch {
setDemoError(t("welcome.auth.demoDenied"));
}
setDemoBusy(false);
}
// A named state owns the card's heading while it is up, so it reads as the card's subject rather // A named state owns the card's heading while it is up, so it reads as the card's subject rather
// than a box sitting inside a heading that still says "Set a new password". // than a box sitting inside a heading that still says "Set a new password".
const titleKey = blocked const titleKey = blocked
@@ -736,46 +710,18 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
)} )}
</div> </div>
{mode === "signin" && ( {/* The divider separates two ways in, so it goes with the second one: on an instance with
no Google sign-in there is nothing to say "or" to. */}
{mode === "signin" && googleEnabled && (
<> <>
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted"> <div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
{t("welcome.auth.or")} {t("welcome.auth.or")}
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
</div> </div>
{googleEnabled && ( <LinkButton href="/auth/login" size="lg" fullWidth>
<LinkButton href="/auth/login" size="lg" fullWidth> {t("welcome.auth.google")}
{t("welcome.auth.google")} </LinkButton>
</LinkButton>
)}
{!showDemo ? (
<Button
variant="outline"
size="lg"
fullWidth
className="mt-2"
onClick={() => setShowDemo(true)}
>
{t("welcome.auth.tryDemo")}
</Button>
) : (
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
<p className="text-2xs text-muted">{t("welcome.auth.demoHint")}</p>
<div className="flex gap-2">
<Input
className="w-full"
type="email"
value={demoEmail}
onChange={(e) => setDemoEmail(e.target.value)}
placeholder={t("welcome.auth.demoEmail")}
/>
<Button type="submit" variant="outline" size="lg" disabled={demoBusy}>
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
</Button>
</div>
{demoError && <p className="text-xs text-danger">{demoError}</p>}
</form>
)}
</> </>
)} )}
</> </>
+4 -6
View File
@@ -34,14 +34,13 @@ export function WizardProvider({ children }: { children: ReactNode }) {
const meId = me?.id; const meId = me?.id;
const canRead = me?.can_read; const canRead = me?.can_read;
const canWrite = me?.can_write; const canWrite = me?.can_write;
const isDemo = me?.is_demo;
const googleEnabled = me?.google_enabled; const googleEnabled = me?.google_enabled;
useEffect(() => { useEffect(() => {
if (meId == null || canRead == null) return; if (meId == null || canRead == null) return;
const account = { can_read: canRead, is_demo: isDemo, google_enabled: googleEnabled }; const account = { can_read: canRead, google_enabled: googleEnabled };
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent. // eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
if (shouldAutoOpenOnboarding(account)) setOpen(true); if (shouldAutoOpenOnboarding(account)) setOpen(true);
}, [meId, canRead, canWrite, isDemo, googleEnabled]); }, [meId, canRead, canWrite, googleEnabled]);
const actions = useMemo<WizardActions>( const actions = useMemo<WizardActions>(
() => ({ openWizard, closeWizard }), () => ({ openWizard, closeWizard }),
@@ -51,10 +50,9 @@ export function WizardProvider({ children }: { children: ReactNode }) {
return ( return (
<WizardActionsContext.Provider value={actions}> <WizardActionsContext.Provider value={actions}>
{children} {children}
{/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never {/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
opens there. Lazy — its chunk loads only when first opened. */}
<Suspense fallback={null}> <Suspense fallback={null}>
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />} {open && me && <OnboardingWizard me={me} onClose={closeWizard} />}
</Suspense> </Suspense>
</WizardActionsContext.Provider> </WizardActionsContext.Provider>
); );
@@ -58,7 +58,6 @@ const user = {
email: "stuck@example.com", email: "stuck@example.com",
display_name: null, display_name: null,
role: "user", role: "user",
is_demo: false,
is_active: false, is_active: false,
is_suspended: false, is_suspended: false,
}; };
@@ -148,15 +147,3 @@ describe("the account panel reports a refused recovery action", () => {
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed")); await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
}); });
}); });
describe("the account panel on the demo account", () => {
// The demo account's address is never confirmed, so "send the confirmation link again" looked
// available — and the server refused it as "nothing left to confirm", beside a row reading
// "unconfirmed" (deep-review E11 round 3).
it("does not offer it a confirmation mail, which the server refuses", async () => {
mount({ ...user, is_demo: true });
await screen.findByText("users.account.recovery");
const resend = screen.getByText("users.account.resend").closest("button")!;
expect(resend.hasAttribute("disabled")).toBe(true);
});
});
@@ -79,7 +79,6 @@ const row = (over: Record<string, unknown> = {}) => ({
is_active: false, is_active: false,
is_suspended: false, is_suspended: false,
email_verified: true, email_verified: true,
is_demo: false,
has_password: true, has_password: true,
has_google: false, has_google: false,
created_at: "2026-09-01T10:00:00Z", created_at: "2026-09-01T10:00:00Z",
+1 -5
View File
@@ -74,11 +74,7 @@ function localDetails(
head: t("scheduler.maintenance.batchLabel"), head: t("scheduler.maintenance.batchLabel"),
sub: formatNumber(after.revalidate_batch, lang), sub: formatNumber(after.revalidate_batch, lang),
}; };
if ( if (row.action === "audit_clear" || row.action === "discovery_purge") {
row.action === "audit_clear" ||
row.action === "demo_reset" ||
row.action === "discovery_purge"
) {
const result = counts(after); const result = counts(after);
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined; return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -159,7 +159,7 @@ export function QuotaModal({
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) { function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = useMemo(() => (users.data ?? []).filter((u) => !u.is_demo), [users.data]); const list = users.data ?? [];
return ( return (
<div className="w-full sm:w-72"> <div className="w-full sm:w-72">
<UserPicker <UserPicker
+1 -2
View File
@@ -33,12 +33,11 @@ export function FeedPage() {
view={view} view={view}
setView={setView} setView={setView}
canRead={me.can_read} canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch} ytSearch={ytSearch}
onYtSearch={enterYtSearch} onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch} onExitYtSearch={exitYtSearch}
/> />
{modalOpen && !me.is_demo && <PersonalViewsModal />} {modalOpen && <PersonalViewsModal />}
</> </>
); );
} }
+2 -2
View File
@@ -20,7 +20,7 @@ const TONES: Record<BannerTone, { bar: string; icon: string; action: "primary" |
}, },
}; };
// Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this; // Shared top-of-app notification bar. VersionBanner and VerifyBanner are thin wrappers over this;
// a future news-ticker variant can build on the same shell (rotating content as children). // a future news-ticker variant can build on the same shell (rotating content as children).
export default function Banner({ export default function Banner({
tone, tone,
@@ -41,7 +41,7 @@ export default function Banner({
* control is not an acceptable default, and `Button`'s type now says so. */ * control is not an acceptable default, and `Button`'s type now says so. */
dismissTitle?: string; dismissTitle?: string;
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen /** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the * reader announces it. Left unset for the permanent bars (version): those are part of the
* page when it loads, and announcing them on every visit is noise, not information. */ * page when it loads, and announcing them on every visit is noise, not information. */
role?: "status"; role?: "status";
}) { }) {
+1 -1
View File
@@ -41,7 +41,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are // - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
// the visible identity, and a screen reader still lands on a named page. A view that // the visible identity, and a screen reader still lands on a named page. A view that
// shows its own title renders it through `PageHeading`, and this one steps aside. // shows its own title renders it through `PageHeading`, and this one steps aside.
// - `banners` — in-flow, fixed notices (demo/version) above the scroll region. // - `banners` — in-flow, fixed notices (verify/version) above the scroll region.
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a // - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing. // module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two // - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
+10 -18
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Bookmark, Bookmark,
BookmarkX, BookmarkX,
@@ -17,7 +17,7 @@ import {
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
import { api, type Me, type Video, type VideoStatus } from "../lib/api"; import { api, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed"; import { isResettable } from "../lib/api/feed";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { pinVideo, useComfortPin } from "./comfortPin"; import { pinVideo, useComfortPin } from "./comfortPin";
@@ -48,17 +48,13 @@ const icon = "w-4 h-4";
* panel to render beside the menu (one per list). */ * panel to render beside the menu (one per list). */
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
const pin = useComfortPin(open); const pin = useComfortPin(open);
const copy = useCopyItems(); const copy = useCopyItems();
// Downloads spend server disk and need a real identity: the demo account has none (as the
// player's DownloadButton).
const canDownload = !qc.getQueryData<Me>(qk.me())?.is_demo;
const index = useQuery({ const index = useQuery({
queryKey: qk.downloadIndex(), queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex, queryFn: api.downloadIndex,
enabled: open && canDownload, enabled: open,
staleTime: 10000, staleTime: 10000,
}); });
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null); const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null);
@@ -139,17 +135,13 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
}, },
] ]
: []), : []),
...(canDownload {
? [ id: "download",
{ label: t("downloads.menu.download"),
id: "download", note: dlState,
label: t("downloads.menu.download"), icon: <Download className={icon} />,
note: dlState, onSelect: () => setFollowUp({ kind: "download", video: v }),
icon: <Download className={icon} />, },
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
]
: []),
...(details ...(details
? [ ? [
{ {
@@ -228,7 +228,7 @@ describe("backend ids have their names in both languages", () => {
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"])); expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"])); expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0); expect(quotaActions.length).toBeGreaterThan(0);
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"])); expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
expect(opaqueWhats).toEqual([]); expect(opaqueWhats).toEqual([]);
@@ -9,9 +9,6 @@
"invite_approve": "Access request approved", "invite_approve": "Access request approved",
"invite_deny": "Access request denied", "invite_deny": "Access request denied",
"invite_add": "Email whitelisted", "invite_add": "Email whitelisted",
"demo_add": "Demo email added",
"demo_remove": "Demo email removed",
"demo_reset": "Demo reset",
"discovery_purge": "Discovery purged", "discovery_purge": "Discovery purged",
"config_set": "Setting changed", "config_set": "Setting changed",
"config_reset": "Setting reset", "config_reset": "Setting reset",
-2
View File
@@ -20,8 +20,6 @@
"accessRequestsMessage_one": "{{count}} pending — review it on the Users page.", "accessRequestsMessage_one": "{{count}} pending — review it on the Users page.",
"accessRequestsMessage_other": "{{count}} pending — review them on the Users page.", "accessRequestsMessage_other": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review", "accessRequestsReview": "Review",
"demoTitle": "Demo account",
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top", "backToTop": "Back to top",
"retry": "Retry", "retry": "Retry",
"retryWithEnter": "Press Enter to try again.", "retryWithEnter": "Press Enter to try again.",
+1 -7
View File
@@ -1,7 +1,6 @@
{ {
"title": "Couldn't complete that", "title": "Couldn't complete that",
"generic": "The server couldn't carry out that action. Please try again.", "generic": "The server couldn't carry out that action. Please try again.",
"demo": "This isn't available in the demo account",
"validation": "Some of the values you entered weren't valid. Please check them and try again.", "validation": "Some of the values you entered weren't valid. Please check them and try again.",
"tooLarge": "That was too large for the server to accept", "tooLarge": "That was too large for the server to accept",
"server": "Server error", "server": "Server error",
@@ -45,8 +44,7 @@
"not_applicable": "This account has nothing left to confirm", "not_applicable": "This account has nothing left to confirm",
"not_approved": "Only an approved address can be invited", "not_approved": "Only an approved address can be invited",
"email_off": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email / SMTP.", "email_off": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email / SMTP.",
"send_failed": "The mail couldn't be sent: the mail server was unreachable, or it refused the login or the message. Check Configuration → Email / SMTP, then try again.", "send_failed": "The mail couldn't be sent: the mail server was unreachable, or it refused the login or the message. Check Configuration → Email / SMTP, then try again."
"demo": "The demo account is shared, so it can't get a set-password link"
}, },
"reason": { "reason": {
"unreachable": "The server couldn't be reached. Try again.", "unreachable": "The server couldn't be reached. Try again.",
@@ -132,10 +130,6 @@
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync", "collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync",
"collection_read_only": "This collection is read-only", "collection_read_only": "This collection is read-only",
"collection_smart": "Smart and auto-list collections can't be made editable", "collection_smart": "Smart and auto-list collections can't be made editable",
"demo_activate": "The demo account can't be activated",
"demo_delete": "The demo account can't be deleted",
"demo_role": "The demo account's role can't be changed",
"demo_suspend": "The demo account can't be suspended",
"download_not_ready": "This download isn't ready", "download_not_ready": "This download isn't ready",
"edit_unfinished": "Only a finished download can be edited", "edit_unfinished": "Only a finished download can be edited",
"filters_not_object": "The view's filters are malformed", "filters_not_object": "The view's filters are malformed",
-3
View File
@@ -17,9 +17,6 @@
"emptyBody": "Connect your YouTube account to import your subscriptions and build your Feed.", "emptyBody": "Connect your YouTube account to import your subscriptions and build your Feed.",
"setUp": "Set up my Feed", "setUp": "Set up my Feed",
"browseShared": "Or just browse the shared Library", "browseShared": "Or just browse the shared Library",
"demoEmptyTitle": "Nothing here in the demo",
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared Library is yours to explore, filter and sort.",
"browseLibrary": "Browse the Library",
"noMatches": "No videos match these filters.", "noMatches": "No videos match these filters.",
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared Library.", "channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared Library.",
"viewInLibrary": "View in Library", "viewInLibrary": "View in Library",
+1 -2
View File
@@ -4,8 +4,7 @@
"skipToContent": "Skip to content", "skipToContent": "Skip to content",
"role": { "role": {
"admin": "Admin", "admin": "Admin",
"user": "User", "user": "User"
"demo": "Demo"
}, },
"rail": { "rail": {
"home": "Home", "home": "Home",
@@ -69,7 +69,6 @@
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the Feed.", "shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the Feed.",
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.", "subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.", "playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"demo_reset": "Resets the shared demo account (its playlists and state) so every visitor starts from the same sandbox. Does nothing when there is no demo account. If it stops, the demo keeps whatever the last visitor left behind.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the Feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.", "maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the Feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.", "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.", "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
@@ -90,7 +89,6 @@
"subscriptions": "Subscription sync from YouTube", "subscriptions": "Subscription sync from YouTube",
"playlist_sync": "Playlist sync from YouTube", "playlist_sync": "Playlist sync from YouTube",
"maintenance": "Maintenance + validation", "maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset",
"explore_cleanup": "Explored-channel cleanup", "explore_cleanup": "Explored-channel cleanup",
"download_gc": "Download cleanup", "download_gc": "Download cleanup",
"plex_sync": "Plex library sync", "plex_sync": "Plex library sync",
@@ -139,7 +137,6 @@
"revalidated": "revalidated", "revalidated": "revalidated",
"flagged": "flagged", "flagged": "flagged",
"skipped": "skipped", "skipped": "skipped",
"playlists_seeded": "playlists seeded",
"expired": "expired", "expired": "expired",
"videos_deleted": "videos deleted", "videos_deleted": "videos deleted",
"channels_deleted": "channels deleted", "channels_deleted": "channels deleted",
@@ -183,7 +180,6 @@
"disabled": "disabled", "disabled": "disabled",
"PlexserverURLortokenisnotconfigured": "Plex server URL or token is not configured", "PlexserverURLortokenisnotconfigured": "Plex server URL or token is not configured",
"tmdbapikeynotset": "TMDB API key not set", "tmdbapikeynotset": "TMDB API key not set",
"nodemoaccount": "no demo account",
"noserviceuser": "no service user", "noserviceuser": "no service user",
"syncpaused": "sync paused", "syncpaused": "sync paused",
"lowquota": "low quota", "lowquota": "low quota",
@@ -73,7 +73,6 @@
"deleteTitle": "Delete your account?", "deleteTitle": "Delete your account?",
"deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.", "deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.",
"deleteConfirmButton": "Delete everything", "deleteConfirmButton": "Delete everything",
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared Library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
"signInMethods": "Sign-in methods", "signInMethods": "Sign-in methods",
"googleLink": { "googleLink": {
"title": "Google account", "title": "Google account",
@@ -132,28 +131,6 @@
"removeConfirmAction": "Delete", "removeConfirmAction": "Delete",
"loadError": "Couldn't load your download authentication settings" "loadError": "Couldn't load your download authentication settings"
}, },
"demo": {
"title": "Demo access",
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
"addPlaceholder": "Whitelist an email for demo…",
"add": "Add",
"added": "{{email}} added to the demo whitelist",
"addFailed": "Couldn't add that email",
"removeFailed": "Couldn't remove that email",
"remove": "Remove",
"removeHint": "Remove this email from the demo whitelist.",
"addedBy": "added by {{who}}",
"reset": "Reset demo state",
"resetHint": "Wipe the demo account's playlists, hidden/watched/saved videos and settings, then re-seed a few sample playlists.",
"resetTitle": "Reset demo state?",
"resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.",
"resetDone_one": "Demo reset — {{count}} sample playlist seeded",
"resetDone_other": "Demo reset — {{count}} sample playlists seeded",
"resetFailed": "Couldn't reset the demo account",
"removed": "Removed from the demo whitelist",
"removeTitle": "Remove demo access?",
"removeConfirm": "Remove {{email}} from the demo whitelist? They'll no longer be able to enter the demo account."
},
"invites": { "invites": {
"title": "Access requests", "title": "Access requests",
"noPending": "No pending requests.", "noPending": "No pending requests.",
@@ -228,7 +205,6 @@
}, },
"status": "{{level}} logging · back to “Basic” in {{time}}", "status": "{{level}} logging · back to “Basic” in {{time}}",
"revert": "Back to “Basic” now", "revert": "Back to “Basic” now",
"demo": "The demo account is shared by several people, so detailed logging can't be turned on here.",
"failed": "Couldn't change the logging level" "failed": "Couldn't change the logging level"
}, },
"myLog": { "myLog": {
+1 -7
View File
@@ -1,8 +1,7 @@
{ {
"tabs": { "tabs": {
"roles": "Users & roles", "roles": "Users & roles",
"access": "Access requests", "access": "Access requests"
"demo": "Demo"
}, },
"pendingAccess": { "pendingAccess": {
"title": "Signed up, but still outside", "title": "Signed up, but still outside",
@@ -26,14 +25,11 @@
"empty": "No users yet.", "empty": "No users yet.",
"you": "you", "you": "you",
"admin": "Admin", "admin": "Admin",
"demo": "Demo",
"suspended": "Suspended", "suspended": "Suspended",
"pending": "Pending approval", "pending": "Pending approval",
"unverified": "Unverified email", "unverified": "Unverified email",
"lastLogin": "Last sign-in: {{time}}", "lastLogin": "Last sign-in: {{time}}",
"lastLoginNone": "No sign-in recorded yet", "lastLoginNone": "No sign-in recorded yet",
"lastLoginDemo": "Not recorded for the demo account",
"demoLocked": "The demo account's role can't be changed.",
"selfLocked": "You can't change your own role.", "selfLocked": "You can't change your own role.",
"makeAdmin": "Make admin", "makeAdmin": "Make admin",
"makeUser": "Make user", "makeUser": "Make user",
@@ -53,7 +49,6 @@
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.", "undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
"done": "{{email}} suspended", "done": "{{email}} suspended",
"undone": "{{email}} unsuspended", "undone": "{{email}} unsuspended",
"demoLocked": "The demo account can't be suspended.",
"selfLocked": "You can't suspend your own account.", "selfLocked": "You can't suspend your own account.",
"failed": "Couldn't change the suspension" "failed": "Couldn't change the suspension"
}, },
@@ -86,7 +81,6 @@
"invite": "Invite", "invite": "Invite",
"lastLogin": "Last sign-in", "lastLogin": "Last sign-in",
"lastLoginNone": "none recorded yet", "lastLoginNone": "none recorded yet",
"lastLoginDemo": "not recorded for the demo account",
"inviteMissing": "this address is not on the allow-list", "inviteMissing": "this address is not on the allow-list",
"linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.", "linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.",
"mailOff": "no mail configured", "mailOff": "no mail configured",
@@ -77,11 +77,6 @@
"backToSignin": "Back to sign in", "backToSignin": "Back to sign in",
"or": "or", "or": "or",
"google": "Continue with Google", "google": "Continue with Google",
"tryDemo": "Try the demo",
"demoHint": "Enter an email that's been enabled for the demo account.",
"demoEmail": "Demo email",
"demoEnter": "Enter",
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
"registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.", "registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.",
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.", "forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
"resetDone": "Your password has been set — you can sign in now", "resetDone": "Your password has been set — you can sign in now",
@@ -9,9 +9,6 @@
"invite_approve": "Hozzáférési kérelem jóváhagyva", "invite_approve": "Hozzáférési kérelem jóváhagyva",
"invite_deny": "Hozzáférési kérelem elutasítva", "invite_deny": "Hozzáférési kérelem elutasítva",
"invite_add": "E-mail fehérlistára téve", "invite_add": "E-mail fehérlistára téve",
"demo_add": "Demó e-mail hozzáadva",
"demo_remove": "Demó e-mail eltávolítva",
"demo_reset": "Demó visszaállítva",
"discovery_purge": "Felfedezés ürítve", "discovery_purge": "Felfedezés ürítve",
"config_set": "Beállítás módosítva", "config_set": "Beállítás módosítva",
"config_reset": "Beállítás visszaállítva", "config_reset": "Beállítás visszaállítva",
-2
View File
@@ -20,8 +20,6 @@
"accessRequestsMessage_one": "{{count}} függőben — nézd át a Felhasználók oldalon.", "accessRequestsMessage_one": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsMessage_other": "{{count}} függőben — nézd át a Felhasználók oldalon.", "accessRequestsMessage_other": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés", "accessRequestsReview": "Áttekintés",
"demoTitle": "Demó fiók",
"demoSharedNotice": "Egy közös demó fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demó látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére", "backToTop": "Vissza a tetejére",
"retry": "Újra", "retry": "Újra",
"retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.", "retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.",
+1 -7
View File
@@ -1,7 +1,6 @@
{ {
"title": "Nem sikerült", "title": "Nem sikerült",
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
"demo": "Ez a demó fiókban nem érhető el",
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.", "validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
"tooLarge": "Ez túl nagy, a szerver nem fogadta el", "tooLarge": "Ez túl nagy, a szerver nem fogadta el",
"server": "Szerverhiba", "server": "Szerverhiba",
@@ -45,8 +44,7 @@
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni", "not_applicable": "Ezen a fiókon már nincs mit megerősíteni",
"not_approved": "Csak jóváhagyott címre küldhető meghívó", "not_approved": "Csak jóváhagyott címre küldhető meghívó",
"email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.", "email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.",
"send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.", "send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra."
"demo": "A demó fiók közös, ezért nem kaphat jelszóbeállító linket"
}, },
"reason": { "reason": {
"unreachable": "A szerver nem volt elérhető. Próbáld újra.", "unreachable": "A szerver nem volt elérhető. Próbáld újra.",
@@ -132,10 +130,6 @@
"collection_pending_sync": "A kollekció a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja", "collection_pending_sync": "A kollekció a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja",
"collection_read_only": "Ez a kollekció csak olvasható", "collection_read_only": "Ez a kollekció csak olvasható",
"collection_smart": "Az okos és az automatikus listás kollekciók nem tehetők szerkeszthetővé", "collection_smart": "Az okos és az automatikus listás kollekciók nem tehetők szerkeszthetővé",
"demo_activate": "A demó fiók nem aktiválható",
"demo_delete": "A demó fiók nem törölhető",
"demo_role": "A demó fiók szerepköre nem módosítható",
"demo_suspend": "A demó fiók nem függeszthető fel",
"download_not_ready": "Ez a letöltés még nincs kész", "download_not_ready": "Ez a letöltés még nincs kész",
"edit_unfinished": "Csak befejezett letöltés szerkeszthető", "edit_unfinished": "Csak befejezett letöltés szerkeszthető",
"filters_not_object": "A nézet szűrői hibás formátumúak", "filters_not_object": "A nézet szűrői hibás formátumúak",
-3
View File
@@ -17,9 +17,6 @@
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a Feededet.", "emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a Feededet.",
"setUp": "Feed beállítása", "setUp": "Feed beállítása",
"browseShared": "Vagy csak böngészd a közös Könyvtárat", "browseShared": "Vagy csak böngészd a közös Könyvtárat",
"demoEmptyTitle": "Itt a demóban nincs semmi",
"demoEmptyBody": "A demó fióknak nincsenek saját feliratkozásai — de a teljes közös Könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"browseLibrary": "Könyvtár böngészése",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.", "noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös Könyvtárban vannak.", "channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös Könyvtárban vannak.",
"viewInLibrary": "Megnézés a Könyvtárban", "viewInLibrary": "Megnézés a Könyvtárban",
+1 -2
View File
@@ -4,8 +4,7 @@
"skipToContent": "Ugrás a tartalomra", "skipToContent": "Ugrás a tartalomra",
"role": { "role": {
"admin": "Admin", "admin": "Admin",
"user": "Felhasználó", "user": "Felhasználó"
"demo": "Demó"
}, },
"rail": { "rail": {
"home": "Kezdőlap", "home": "Kezdőlap",
@@ -69,7 +69,6 @@
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a Feedben.", "shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a Feedben.",
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.", "subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.", "playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"demo_reset": "Visszaállítja a közös demó fiókot (lejátszási listák és állapot), hogy minden látogató ugyanabból a homokozóból induljon. Ha nincs demó fiók, nem csinál semmit. Ha leáll, a demóban az marad, amit az előző látogató hagyott benne.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a Feedből, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.", "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a Feedből, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.", "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.", "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
@@ -90,7 +89,6 @@
"subscriptions": "Feliratkozások szinkronizálása a YouTube-ról", "subscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
"playlist_sync": "Lejátszási listák szinkronizálása a YouTube-ról", "playlist_sync": "Lejátszási listák szinkronizálása a YouTube-ról",
"maintenance": "Karbantartás + ellenőrzés", "maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demó fiók visszaállítása",
"explore_cleanup": "Felfedezett csatornák takarítása", "explore_cleanup": "Felfedezett csatornák takarítása",
"download_gc": "Letöltések takarítása", "download_gc": "Letöltések takarítása",
"plex_sync": "Plex-könyvtár szinkron", "plex_sync": "Plex-könyvtár szinkron",
@@ -139,7 +137,6 @@
"revalidated": "újraérvényesítve", "revalidated": "újraérvényesítve",
"flagged": "megjelölve", "flagged": "megjelölve",
"skipped": "kihagyva", "skipped": "kihagyva",
"playlists_seeded": "létrehozott lejátszási lista",
"expired": "lejárt", "expired": "lejárt",
"videos_deleted": "törölt videó", "videos_deleted": "törölt videó",
"channels_deleted": "törölt csatorna", "channels_deleted": "törölt csatorna",
@@ -183,7 +180,6 @@
"disabled": "kikapcsolva", "disabled": "kikapcsolva",
"PlexserverURLortokenisnotconfigured": "a Plex-szerver címe vagy tokenje nincs beállítva", "PlexserverURLortokenisnotconfigured": "a Plex-szerver címe vagy tokenje nincs beállítva",
"tmdbapikeynotset": "nincs TMDB API-kulcs", "tmdbapikeynotset": "nincs TMDB API-kulcs",
"nodemoaccount": "nincs demófiók",
"noserviceuser": "nincs szolgáltatásfiók", "noserviceuser": "nincs szolgáltatásfiók",
"syncpaused": "a szinkron szünetel", "syncpaused": "a szinkron szünetel",
"lowquota": "kevés a kvóta", "lowquota": "kevés a kvóta",
@@ -73,7 +73,6 @@
"deleteTitle": "Törlöd a fiókodat?", "deleteTitle": "Törlöd a fiókodat?",
"deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.", "deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.",
"deleteConfirmButton": "Minden törlése", "deleteConfirmButton": "Minden törlése",
"demoNotice": "Ez a közös demó fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös Könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.",
"signInMethods": "Bejelentkezési módok", "signInMethods": "Bejelentkezési módok",
"googleLink": { "googleLink": {
"title": "Google-fiók", "title": "Google-fiók",
@@ -132,28 +131,6 @@
"removeConfirmAction": "Törlés", "removeConfirmAction": "Törlés",
"loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat" "loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat"
}, },
"demo": {
"title": "Demó hozzáférés",
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demó fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
"addPlaceholder": "E-mail fehérlistára a demóhoz…",
"add": "Hozzáadás",
"added": "{{email}} hozzáadva a demó fehérlistához",
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
"removeFailed": "Nem sikerült eltávolítani ezt az e-mailt",
"remove": "Eltávolítás",
"removeHint": "E-mail eltávolítása a demó fehérlistáról.",
"addedBy": "hozzáadta: {{who}}",
"reset": "Demó állapot visszaállítása",
"resetHint": "Törli a demó fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.",
"resetTitle": "Visszaállítod a demó állapotot?",
"resetConfirm": "Ez törli mindazt, amit a demó fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.",
"resetDone_one": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva",
"resetDone_other": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva",
"resetFailed": "Nem sikerült visszaállítani a demó fiókot",
"removed": "Eltávolítva a demó fehérlistáról",
"removeTitle": "Visszavonod a demó hozzáférést?",
"removeConfirm": "Eltávolítod {{email}} címet a demó fehérlistáról? Többé nem tud belépni a demó fiókba."
},
"invites": { "invites": {
"title": "Hozzáférési kérelmek", "title": "Hozzáférési kérelmek",
"noPending": "Nincs függőben lévő kérelem.", "noPending": "Nincs függőben lévő kérelem.",
@@ -228,7 +205,6 @@
}, },
"status": "{{level}} naplózás · {{time}} múlva visszaáll „Alap” szintre", "status": "{{level}} naplózás · {{time}} múlva visszaáll „Alap” szintre",
"revert": "Visszaállítás most", "revert": "Visszaállítás most",
"demo": "A demó fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.",
"failed": "Nem sikerült módosítani a naplózási szintet" "failed": "Nem sikerült módosítani a naplózási szintet"
}, },
"myLog": { "myLog": {
+1 -7
View File
@@ -1,8 +1,7 @@
{ {
"tabs": { "tabs": {
"roles": "Felhasználók és szerepek", "roles": "Felhasználók és szerepek",
"access": "Hozzáférési kérelmek", "access": "Hozzáférési kérelmek"
"demo": "Demó"
}, },
"pendingAccess": { "pendingAccess": {
"title": "Regisztrált, de még kívül van", "title": "Regisztrált, de még kívül van",
@@ -26,14 +25,11 @@
"empty": "Még nincs felhasználó.", "empty": "Még nincs felhasználó.",
"you": "te", "you": "te",
"admin": "Admin", "admin": "Admin",
"demo": "Demó",
"suspended": "Felfüggesztve", "suspended": "Felfüggesztve",
"pending": "Jóváhagyásra vár", "pending": "Jóváhagyásra vár",
"unverified": "Nem igazolt e-mail", "unverified": "Nem igazolt e-mail",
"lastLogin": "Utolsó belépés: {{time}}", "lastLogin": "Utolsó belépés: {{time}}",
"lastLoginNone": "Nincs még rögzített belépés", "lastLoginNone": "Nincs még rögzített belépés",
"lastLoginDemo": "A demó fióknál nem rögzítjük",
"demoLocked": "A demó fiók szerepe nem módosítható.",
"selfLocked": "A saját szerepedet nem módosíthatod.", "selfLocked": "A saját szerepedet nem módosíthatod.",
"makeAdmin": "Adminná tesz", "makeAdmin": "Adminná tesz",
"makeUser": "Felhasználóvá tesz", "makeUser": "Felhasználóvá tesz",
@@ -53,7 +49,6 @@
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.", "undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
"done": "{{email}} felfüggesztve", "done": "{{email}} felfüggesztve",
"undone": "{{email}} felfüggesztése feloldva", "undone": "{{email}} felfüggesztése feloldva",
"demoLocked": "A demó fiók nem függeszthető fel.",
"selfLocked": "A saját fiókodat nem függesztheted fel.", "selfLocked": "A saját fiókodat nem függesztheted fel.",
"failed": "Nem sikerült módosítani a felfüggesztést" "failed": "Nem sikerült módosítani a felfüggesztést"
}, },
@@ -86,7 +81,6 @@
"invite": "Meghívó", "invite": "Meghívó",
"lastLogin": "Utolsó belépés", "lastLogin": "Utolsó belépés",
"lastLoginNone": "még nincs rögzítve", "lastLoginNone": "még nincs rögzítve",
"lastLoginDemo": "a demó fióknál nem rögzítjük",
"inviteMissing": "ez a cím nincs a fehérlistán", "inviteMissing": "ez a cím nincs a fehérlistán",
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.", "linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
"mailOff": "nincs beállítva levelezés", "mailOff": "nincs beállítva levelezés",
@@ -77,11 +77,6 @@
"backToSignin": "Vissza a belépéshez", "backToSignin": "Vissza a belépéshez",
"or": "vagy", "or": "vagy",
"google": "Folytatás Google-lel", "google": "Folytatás Google-lel",
"tryDemo": "Demó kipróbálása",
"demoHint": "Adj meg egy e-mailt, amely engedélyezve van a demó fiókhoz.",
"demoEmail": "Demó e-mail",
"demoEnter": "Belépés",
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.", "registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.", "forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
"resetDone": "A jelszavad be van állítva — most már beléphetsz", "resetDone": "A jelszavad be van állítva — most már beléphetsz",
-21
View File
@@ -87,27 +87,6 @@ describe("notifyActionError", () => {
}); });
}); });
it("tells a demo user that the demo account is the reason", async () => {
// `require_human`'s refusal was a plain English string, so round 5's fix — discard a
// plain-string detail, show the caller's translated fallback — discarded the only explanation
// the demo user got: a generic "couldn't pin it" with no hint why (round 6). The gate answers
// a structured code now, and `localizeDetail` has a branch for it in both locales.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "demo" } }), {
status: 403,
headers: { "content-type": "application/json" },
}),
);
const err = await api.addComfortPin("video", "v1").catch((e: unknown) => e);
notifyMock.mockClear();
notifyActionError(err, "GENERIC FALLBACK");
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "GENERIC FALLBACK",
message: i18n.t("errors.demo"),
});
});
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => { it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language // `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
// AND more specific than anything a status can say — quota numbers, which row is gone. // AND more specific than anything a status can say — quota numbers, which row is gone.
+3 -5
View File
@@ -9,7 +9,6 @@ export interface Me {
display_name: string | null; display_name: string | null;
avatar_url: string | null; avatar_url: string | null;
role: string; role: string;
is_demo: boolean;
/** The latest sign-in on any device (ISO), or null when none is recorded. */ /** The latest sign-in on any device (ISO), or null when none is recorded. */
last_login_at: string | null; last_login_at: string | null;
has_google: boolean; has_google: boolean;
@@ -40,10 +39,9 @@ export interface Me {
issues: { repo: string } | null; issues: { repo: string } | null;
} }
/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user` /** The `nav.role.*` key an account is shown under. */
* row), so it comes first. */ export function roleKey(a: Pick<Me, "role">): "admin" | "user" {
export function roleKey(a: Pick<Me, "is_demo" | "role">): "admin" | "user" | "demo" { return a.role === "admin" ? "admin" : "user";
return a.is_demo ? "demo" : a.role === "admin" ? "admin" : "user";
} }
/** What an account is called on screen: its display name, else the local part of its email. */ /** What an account is called on screen: its display name, else the local part of its email. */
+2 -19
View File
@@ -1,18 +1,9 @@
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the // The admin surface: discovery purge, system configuration, users & roles, the audit log, the
// diagnostics console's server log, the scheduler + maintenance controls, the demo-account // diagnostics console's server log, the scheduler + maintenance controls, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
// whitelist, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
// module as the cohesive "admin" domain.) // module as the cohesive "admin" domain.)
import type { Level } from "../log"; import type { Level } from "../log";
import { req } from "./core"; import { req } from "./core";
export interface DemoWhitelistEntry {
id: number;
email: string;
note: string | null;
added_by: string | null;
created_at: string | null;
}
export interface Invite { export interface Invite {
id: number; id: number;
email: string; email: string;
@@ -78,7 +69,6 @@ export interface AdminUserRow {
is_active: boolean; is_active: boolean;
is_suspended: boolean; is_suspended: boolean;
email_verified: boolean; email_verified: boolean;
is_demo: boolean;
has_password: boolean; has_password: boolean;
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only /** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
* account) from "we took it away" — different problems needing different help. */ * account) from "we took it away" — different problems needing different help. */
@@ -87,7 +77,7 @@ export interface AdminUserRow {
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */ /** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
google_email: string | null; google_email: string | null;
created_at: string | null; created_at: string | null;
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill), or the demo account. */ /** Last sign-in (Google or password). Null = not since 0.88 (no backfill). */
last_login_at: string | null; last_login_at: string | null;
} }
@@ -288,13 +278,6 @@ export const adminApi = {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ revalidate_batch: revalidateBatch }), body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}), }),
// --- demo whitelist ---
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
req("/api/admin/demo/reset", { method: "POST" }),
// --- access invites --- // --- access invites ---
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25. // Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"), adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),

Some files were not shown because too many files have changed in this diff Show More