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:
@@ -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,7 +1,7 @@
|
||||
# Backend conventions
|
||||
|
||||
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
|
||||
I copy".
|
||||
|
||||
|
||||
@@ -39,9 +39,6 @@ class AuditAction:
|
||||
# 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.
|
||||
INVITE_SEND = "invite_send"
|
||||
DEMO_ADD = "demo_add"
|
||||
DEMO_REMOVE = "demo_remove"
|
||||
DEMO_RESET = "demo_reset"
|
||||
DISCOVERY_PURGE = "discovery_purge"
|
||||
# Config
|
||||
CONFIG_SET = "config_set"
|
||||
|
||||
+3
-39
@@ -24,8 +24,6 @@ def operator_contact() -> str | None:
|
||||
return next(iter(sorted(settings.admin_email_set)), None)
|
||||
|
||||
|
||||
DEMO_GOOGLE_SUB = "__demo__"
|
||||
DEMO_EMAIL = "demo@siftlode.local"
|
||||
MAX_SESSION_ACCOUNTS = 6
|
||||
|
||||
# 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
|
||||
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 is_allowed(db, user.email)
|
||||
|
||||
@@ -147,7 +145,6 @@ def count_pending_activations(db: Session) -> int:
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User).where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.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
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
@@ -402,25 +381,10 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
|
||||
return None
|
||||
|
||||
|
||||
def require_human(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:
|
||||
def require_write_scope(user: User = Depends(current_user)) -> User:
|
||||
"""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)`
|
||||
checks that had drifted into three different 403 messages across channels/playlists. Layers on
|
||||
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
||||
checks that had drifted into three different 403 messages across channels/playlists."""
|
||||
if not has_write_scope(user):
|
||||
raise refused(403, "youtube_editing_off")
|
||||
return user
|
||||
|
||||
@@ -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,
|
||||
# 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
|
||||
# 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
|
||||
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
|
||||
#
|
||||
|
||||
@@ -146,8 +146,6 @@ class Settings(BaseSettings):
|
||||
autotag_interval_minutes: int = 30
|
||||
subscriptions_resync_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 ---
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
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`.
|
||||
"""
|
||||
|
||||
+3
-22
@@ -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
|
||||
# sign-in for accounts linked before this column existed.
|
||||
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))
|
||||
# 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
|
||||
@@ -123,7 +123,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
||||
# account can't sign in by any method and its live sessions are rejected; the user is told
|
||||
# why and pointed at the operator's contact. The demo account is never suspendable.
|
||||
# why and pointed at the operator's contact.
|
||||
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
|
||||
# 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")
|
||||
# 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
|
||||
# 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))
|
||||
# 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
|
||||
@@ -146,12 +146,6 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
# 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.
|
||||
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…).
|
||||
preferences: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
@@ -224,19 +218,6 @@ class Invite(Base, CanonicalEmailMixin):
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
+9
-119
@@ -1,5 +1,4 @@
|
||||
"""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."""
|
||||
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,15 +18,12 @@ from app.auth import (
|
||||
count_admins,
|
||||
env_allowed_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
is_allowed,
|
||||
operator_contact,
|
||||
purge_user,
|
||||
)
|
||||
from app.db import get_db
|
||||
from app.demo import reset_demo_state
|
||||
from app.models import (
|
||||
DemoWhitelist,
|
||||
Invite,
|
||||
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)
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
if user is None:
|
||||
return None, False, hours
|
||||
if user.email_verified or not email_mod.email_enabled(db):
|
||||
return None, True, hours
|
||||
@@ -400,7 +396,6 @@ def _serialize_user(u: User) -> dict:
|
||||
"is_active": u.is_active,
|
||||
"is_suspended": u.is_suspended,
|
||||
"email_verified": u.email_verified,
|
||||
"is_demo": u.is_demo,
|
||||
"has_password": u.password_hash is not None,
|
||||
# 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
|
||||
@@ -412,7 +407,7 @@ def _serialize_user(u: User) -> dict:
|
||||
# confusing sign-in.
|
||||
"google_email": u.google_email if u.google_email != u.email else None,
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -435,14 +430,12 @@ def set_user_role(
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo
|
||||
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
|
||||
"""Promote/demote a user. Guards: can't change your own role and can't remove the
|
||||
last remaining admin. Emails the user when it actually changes."""
|
||||
role = body.role
|
||||
if role not in ("user", "admin"):
|
||||
raise refused(400, "invalid_role")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise refused(400, "demo_role")
|
||||
if target.id == admin.id:
|
||||
raise refused(400, "own_role")
|
||||
if (
|
||||
@@ -490,14 +483,11 @@ def set_user_suspended(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
|
||||
live session is dropped on its next request. Guards: can't suspend the demo account, can't
|
||||
suspend yourself, and can't suspend the last active admin (that would lock admin out). On
|
||||
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
|
||||
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||
sign-in)."""
|
||||
suspended = body.suspended
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise refused(400, "demo_suspend")
|
||||
if target.id == admin.id:
|
||||
raise refused(400, "own_suspend")
|
||||
if (
|
||||
@@ -538,10 +528,8 @@ def admin_delete_user(
|
||||
) -> dict:
|
||||
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
|
||||
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
|
||||
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
||||
not yourself (use Settings → Account), and not the last admin."""
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise refused(400, "demo_delete")
|
||||
if target.id == admin.id:
|
||||
raise refused(400, "own_delete")
|
||||
if (
|
||||
@@ -563,9 +551,6 @@ def admin_delete_user(
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
# --- Demo account: email whitelist + state reset -------------------------------------
|
||||
|
||||
|
||||
# --- 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(
|
||||
select(User)
|
||||
.where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.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
|
||||
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
|
||||
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
|
||||
change its password is handing them everyone else's session."""
|
||||
one it refuses."""
|
||||
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)
|
||||
# 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,
|
||||
@@ -692,8 +671,6 @@ def activate_user(
|
||||
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."""
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise refused(400, "demo_activate")
|
||||
if target.is_active:
|
||||
return _serialize_user(target) # idempotent: a double click is not an error
|
||||
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
|
||||
of the public endpoint is exactly what this exists to escape."""
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo or target.email_verified:
|
||||
if target.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "resend", "reason": "not_applicable", "email": target.email},
|
||||
@@ -757,93 +734,6 @@ def resend_user_verification(
|
||||
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")
|
||||
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
|
||||
|
||||
+11
-60
@@ -31,7 +31,6 @@ from app.auth import (
|
||||
env_admin_canonicals,
|
||||
env_allowed_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
google_enabled,
|
||||
is_allowed,
|
||||
log,
|
||||
@@ -40,7 +39,7 @@ from app.auth import (
|
||||
)
|
||||
from app.config import settings
|
||||
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.security import encrypt, hash_password, hash_token, verify_password
|
||||
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"])
|
||||
|
||||
_oauth = OAuth()
|
||||
@@ -161,18 +158,6 @@ def notify_admins_of_request(
|
||||
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):
|
||||
"""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:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
user = db.get(User, link_uid)
|
||||
if user is None or user.is_demo:
|
||||
if user is None:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
other = db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
|
||||
if other is not None:
|
||||
@@ -634,36 +619,12 @@ async def logout(request: Request):
|
||||
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:
|
||||
"""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
|
||||
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
|
||||
wallet (it is the proof they may be switched to). The demo entry deliberately does not come
|
||||
through here: it clears the wallet instead, and its shared account has no meaningful last login."""
|
||||
wallet (it is the proof they may be switched to)."""
|
||||
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
|
||||
accounts.append(user.id)
|
||||
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
|
||||
# and bumps the session epoch. The residual is logged as SEC-16.
|
||||
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)
|
||||
return
|
||||
# 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
|
||||
with SessionLocal() as db:
|
||||
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)
|
||||
|
||||
|
||||
@@ -1027,11 +988,11 @@ def password_login(
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
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.
|
||||
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)
|
||||
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")
|
||||
if user.is_suspended:
|
||||
_notify_suspended(background, user)
|
||||
@@ -1070,7 +1031,7 @@ def password_reset_request(
|
||||
|
||||
def _send_reset_if_eligible(email: str) -> None:
|
||||
"""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
|
||||
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."""
|
||||
with SessionLocal() as db:
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
if user is None:
|
||||
return
|
||||
if user.password_hash or user.password_revoked_at:
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
@@ -1208,9 +1169,7 @@ def logout_others(
|
||||
) -> dict:
|
||||
"""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
|
||||
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
in somewhere" / a suspected session compromise."""
|
||||
# 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).
|
||||
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
|
||||
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."""
|
||||
if user.is_demo:
|
||||
return RedirectResponse(url="/")
|
||||
client = google_oauth(db)
|
||||
if client is None:
|
||||
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
|
||||
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."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < 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` /
|
||||
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
||||
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)
|
||||
if client is None:
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from app.collation import collated, sorted_words
|
||||
from app.mailtext import lang_for_user
|
||||
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.models import (
|
||||
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
|
||||
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
|
||||
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
|
||||
if user.yt_channel_id is None and user.token is not None and not user.is_demo:
|
||||
# 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:
|
||||
try:
|
||||
with (
|
||||
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
|
||||
# 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]
|
||||
# Skip the quota-spending enrich for the demo account (it just sees the stub metadata).
|
||||
if need and not user.is_demo:
|
||||
if need:
|
||||
try:
|
||||
with (
|
||||
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
|
||||
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,
|
||||
cached after), so it's skipped for the quota-less demo account."""
|
||||
cached after)."""
|
||||
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
|
||||
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
|
||||
stale = details_stale(channel, datetime.now(timezone.utc))
|
||||
if stale and not user.is_demo:
|
||||
if stale:
|
||||
try:
|
||||
with (
|
||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||
@@ -389,7 +388,7 @@ def channel_detail(
|
||||
def explore_channel(
|
||||
channel_id: str,
|
||||
page_token: str | None = None,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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
|
||||
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
|
||||
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")
|
||||
if _is_blocked(db, user, channel_id):
|
||||
raise refused(403, "channel_blocked")
|
||||
@@ -493,10 +492,7 @@ def update_channel(
|
||||
sub.priority = body.priority
|
||||
if body.hidden is not None:
|
||||
sub.hidden = body.hidden
|
||||
# The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto
|
||||
# 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:
|
||||
if body.deep_requested is not None:
|
||||
# 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
|
||||
# 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
|
||||
# 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:
|
||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||
run_recent_backfill(db, [channel], max_channels=1)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
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
|
||||
for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account
|
||||
cannot record: its log would interleave every visitor under one account.
|
||||
for an admin, who reads everyone's lines in the diagnostics console instead.
|
||||
|
||||
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
|
||||
@@ -21,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, debugsession, logs, logsink
|
||||
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.issues import bundle, gitea
|
||||
from app.ratelimit import RateLimiter
|
||||
@@ -51,7 +50,7 @@ _new_sessions = RateLimiter(max_events=20, window_seconds=3600)
|
||||
@router.post("")
|
||||
def start_session(
|
||||
body: StartIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Start recording, or join the recording already running (another tab started it)."""
|
||||
@@ -79,7 +78,7 @@ def start_session(
|
||||
|
||||
@router.post("/{sid}/stop")
|
||||
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:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
if session.ended_at is None:
|
||||
@@ -93,7 +92,7 @@ def stop_session(
|
||||
|
||||
|
||||
@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(
|
||||
select(DebugSession)
|
||||
.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}")
|
||||
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:
|
||||
return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session"))
|
||||
|
||||
|
||||
@router.get("/{sid}/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:
|
||||
"""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")
|
||||
@@ -141,7 +140,7 @@ class DraftIn(BaseModel):
|
||||
def save_draft(
|
||||
sid: str,
|
||||
body: DraftIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
@@ -162,7 +161,7 @@ def save_draft(
|
||||
|
||||
@router.get("/{sid}/export")
|
||||
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:
|
||||
"""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
|
||||
@@ -208,9 +207,7 @@ def _issues_rate_limited() -> HTTPException:
|
||||
|
||||
|
||||
@router.post("/{sid}/issue")
|
||||
def send_issue(
|
||||
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
def send_issue(sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""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)."""
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
@@ -302,7 +299,7 @@ class PreviewIn(BaseModel):
|
||||
def preview_issue(
|
||||
sid: str,
|
||||
body: PreviewIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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")
|
||||
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:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]}
|
||||
|
||||
|
||||
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:
|
||||
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}")
|
||||
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:
|
||||
shot = _session_screenshot(db, sid, shot_id, user)
|
||||
# `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}")
|
||||
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:
|
||||
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
||||
shot = _screenshot_of(db, session, shot_id)
|
||||
|
||||
@@ -17,7 +17,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.discover import hero as hero_mod
|
||||
from app.discover import pins, rails
|
||||
@@ -127,15 +127,14 @@ def list_pins(
|
||||
@router.post("/pins")
|
||||
def add_pin(
|
||||
body: PinIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Pin something to the comfort shelf.
|
||||
|
||||
`require_human`, like every other personal-state write: the shared demo account must not be able
|
||||
to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the
|
||||
existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique
|
||||
constraint is the mechanism, not the message."""
|
||||
Re-pinning something already on the shelf is a no-op that returns the existing pin rather than
|
||||
a 409 — the gesture is "make sure this is on my shelf", and the unique constraint is the
|
||||
mechanism, not the message."""
|
||||
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
||||
if resolved is None:
|
||||
raise not_found("item")
|
||||
@@ -178,7 +177,7 @@ def add_pin(
|
||||
@router.delete("/pins/{pin_id}")
|
||||
def remove_pin(
|
||||
pin_id: int,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pin = owned_or_404(db, ComfortPin, pin_id, user, what="pin")
|
||||
@@ -190,7 +189,7 @@ def remove_pin(
|
||||
@router.post("/pins/reorder")
|
||||
def reorder_pins(
|
||||
body: ReorderIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Rewrite the shelf order from the list of pin ids the client shows.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""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
|
||||
real identity). Admin routes add `admin_user`. Live job state is polled by the frontend (the
|
||||
worker is a separate process); this module never downloads — it enqueues, manages job state,
|
||||
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||
All routes are behind `current_user`; admin routes add `admin_user`. Live job state is polled by the
|
||||
frontend (the worker is a separate process); this module never downloads — it enqueues, manages job
|
||||
state, serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -24,7 +23,7 @@ from sqlalchemy.orm import Session
|
||||
from app.mailtext import lang_for_user
|
||||
from app.collation import collated
|
||||
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.db import get_db, release_read_connection
|
||||
from app.downloads import credentials as dlcreds
|
||||
@@ -212,7 +211,7 @@ def _serialize_profile(p: DownloadProfile) -> dict:
|
||||
|
||||
|
||||
@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 = (
|
||||
db.execute(
|
||||
select(DownloadProfile)
|
||||
@@ -234,7 +233,7 @@ class CreateProfileIn(BaseModel):
|
||||
|
||||
@router.post("/profiles")
|
||||
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:
|
||||
name = body.name.strip()
|
||||
spec = body.spec
|
||||
@@ -262,7 +261,7 @@ class UpdateProfileIn(BaseModel):
|
||||
def update_profile(
|
||||
profile_id: int,
|
||||
body: UpdateProfileIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
p = db.get(DownloadProfile, profile_id)
|
||||
@@ -284,7 +283,7 @@ def update_profile(
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
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:
|
||||
p = db.get(DownloadProfile, profile_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")
|
||||
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)}
|
||||
|
||||
|
||||
@@ -312,7 +311,7 @@ class SetCredentialIn(BaseModel):
|
||||
def set_credential(
|
||||
host_key: str,
|
||||
body: SetCredentialIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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}")
|
||||
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:
|
||||
if not dlcreds.is_supported_host(host_key):
|
||||
raise not_found("host")
|
||||
@@ -388,7 +387,7 @@ class EnqueueDownloadIn(BaseModel):
|
||||
|
||||
@router.post("")
|
||||
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:
|
||||
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
|
||||
@@ -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
|
||||
# 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
|
||||
# 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
|
||||
@@ -454,13 +453,13 @@ def _header_text(request: Request, name: str) -> str:
|
||||
|
||||
async def spool_upload(
|
||||
request: Request,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AsyncIterator[SpooledUpload]:
|
||||
"""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
|
||||
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
|
||||
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."""
|
||||
@@ -532,7 +531,7 @@ class EnqueueEditIn(BaseModel):
|
||||
|
||||
@router.post("/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:
|
||||
"""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("")
|
||||
def list_downloads(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
jobs = (
|
||||
db.execute(
|
||||
select(DownloadJob)
|
||||
@@ -578,7 +575,7 @@ def list_downloads(
|
||||
|
||||
|
||||
@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 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")
|
||||
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.
|
||||
Small + cheap so the feed can poll it without loading the full job list."""
|
||||
rows = db.execute(
|
||||
@@ -617,9 +614,7 @@ def my_download_index(user: User = Depends(require_human), db: Session = Depends
|
||||
|
||||
|
||||
@router.get("/shared")
|
||||
def shared_with_me(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
jobs = (
|
||||
db.execute(
|
||||
select(DownloadJob)
|
||||
@@ -679,7 +674,7 @@ class UpdateDownloadMetaIn(BaseModel):
|
||||
def update_download_meta(
|
||||
job_id: int,
|
||||
body: UpdateDownloadMetaIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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")
|
||||
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:
|
||||
job = _own_job_for_update(db, user, job_id)
|
||||
service.pause(db, job)
|
||||
@@ -711,7 +706,7 @@ def pause_download(
|
||||
|
||||
@router.post("/{job_id}/resume")
|
||||
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:
|
||||
job = _own_job_for_update(db, user, job_id)
|
||||
service.resume(db, job)
|
||||
@@ -720,7 +715,7 @@ def resume_download(
|
||||
|
||||
@router.post("/{job_id}/redownload")
|
||||
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:
|
||||
"""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")
|
||||
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:
|
||||
job = _own_job_for_update(db, user, job_id)
|
||||
service.cancel(db, job)
|
||||
@@ -756,7 +751,7 @@ def cancel_download(
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
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:
|
||||
job = _own_job_for_update(db, user, job_id)
|
||||
service.delete(db, job)
|
||||
@@ -786,7 +781,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
def download_file(
|
||||
job_id: int,
|
||||
request: Request,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
job = _accessible_job(db, user, job_id)
|
||||
@@ -825,7 +820,7 @@ def download_file(
|
||||
|
||||
@router.get("/{job_id}/storyboard")
|
||||
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:
|
||||
"""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")
|
||||
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."""
|
||||
job = _accessible_job(db, user, job_id)
|
||||
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")
|
||||
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)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
@@ -887,7 +882,7 @@ class ShareDownloadIn(BaseModel):
|
||||
def share_download(
|
||||
job_id: int,
|
||||
body: ShareDownloadIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
@@ -899,7 +894,7 @@ def share_download(
|
||||
recipient = db.execute(
|
||||
select(User).where(func.lower(User.email) == target)
|
||||
).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.
|
||||
if not is_messageable_user(recipient):
|
||||
raise not_found("recipient")
|
||||
@@ -937,7 +932,7 @@ def share_download(
|
||||
def unshare_download(
|
||||
job_id: int,
|
||||
email: str,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
owned_or_404(
|
||||
@@ -961,7 +956,7 @@ def unshare_download(
|
||||
|
||||
@router.delete("/shared/{job_id}")
|
||||
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:
|
||||
"""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
|
||||
@@ -982,10 +977,10 @@ def remove_shared_with_me(
|
||||
|
||||
@router.get("/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]:
|
||||
"""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 = (
|
||||
db.execute(
|
||||
select(User)
|
||||
@@ -1021,7 +1016,7 @@ def _link_expiry(days: int | None) -> datetime | None:
|
||||
|
||||
@router.get("/{job_id}/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]:
|
||||
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
rows = (
|
||||
@@ -1051,7 +1046,7 @@ class CreateLinkIn(BaseModel):
|
||||
def create_link(
|
||||
job_id: int,
|
||||
body: CreateLinkIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
@@ -1082,7 +1077,7 @@ class UpdateLinkIn(BaseModel):
|
||||
def update_link(
|
||||
link_id: int,
|
||||
body: UpdateLinkIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
link = _own_link(db, user, link_id)
|
||||
@@ -1103,7 +1098,7 @@ def update_link(
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
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:
|
||||
link = _own_link(db, user, link_id)
|
||||
db.delete(link)
|
||||
|
||||
@@ -419,11 +419,7 @@ def get_video_detail(
|
||||
"duration_seconds": v.duration_seconds,
|
||||
}
|
||||
|
||||
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
|
||||
# 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")
|
||||
|
||||
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit.
|
||||
try:
|
||||
with (
|
||||
quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -131,7 +131,6 @@ def get_me(
|
||||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"is_demo": user.is_demo,
|
||||
"has_google": user.google_sub is not None,
|
||||
# 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
|
||||
@@ -141,7 +140,7 @@ def get_me(
|
||||
),
|
||||
"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
|
||||
# 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),
|
||||
# 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).
|
||||
@@ -184,15 +183,8 @@ def delete_my_account(
|
||||
"""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 —
|
||||
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
|
||||
(that would lock everyone out of the admin 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"})
|
||||
The last remaining admin can't delete itself (that would lock everyone out of the admin
|
||||
surfaces)."""
|
||||
if user.role == "admin" and count_admins(db) <= 1:
|
||||
raise refused(400, "only_admin_delete")
|
||||
user_id = user.id
|
||||
@@ -242,10 +234,7 @@ def update_diagnostics(
|
||||
) -> dict:
|
||||
"""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
|
||||
`diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise
|
||||
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.ELEVATION_HOURS`, whatever the client asks."""
|
||||
diagnostics.set_level(user, body.level)
|
||||
db.commit()
|
||||
# The rest of THIS request, and the process's elevation registry, follow the new level now.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""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.
|
||||
|
||||
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."""
|
||||
with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Plex route package — assembles the admin/browse/playback sub-routers.
|
||||
|
||||
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo +
|
||||
pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""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
|
||||
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).
|
||||
|
||||
@@ -4,15 +4,14 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.models import SavedView, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.refusal import refused
|
||||
|
||||
# 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
|
||||
# (mirrors how other personal-state writes are scoped).
|
||||
# `source`).
|
||||
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
|
||||
@@ -37,7 +36,7 @@ def _serialize(v: SavedView) -> dict:
|
||||
@router.get("")
|
||||
def list_views(
|
||||
source: str = "feed",
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
source = _norm_source(source)
|
||||
@@ -81,7 +80,7 @@ class CreateViewIn(BaseModel):
|
||||
@router.post("")
|
||||
def create_view(
|
||||
body: CreateViewIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
name = body.name.strip()
|
||||
@@ -124,7 +123,7 @@ class UpdateViewIn(BaseModel):
|
||||
def update_view(
|
||||
view_id: int,
|
||||
body: UpdateViewIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||
@@ -163,7 +162,7 @@ def update_view(
|
||||
@router.delete("/{view_id}")
|
||||
def delete_view(
|
||||
view_id: int,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
||||
@@ -179,7 +178,7 @@ class ReorderViewsIn(BaseModel):
|
||||
@router.post("/reorder")
|
||||
def reorder_views(
|
||||
body: ReorderViewsIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
ids = body.ids
|
||||
|
||||
@@ -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
|
||||
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
|
||||
pre-check, and only real users (never the shared demo account) may call it. The result ids are
|
||||
enriched through the cheap shared videos.list path (1 unit / 50) regardless of source.
|
||||
pre-check. The result ids are enriched through the cheap shared videos.list path (1 unit / 50)
|
||||
regardless of source.
|
||||
|
||||
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
|
||||
@@ -23,7 +23,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
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.models import (
|
||||
LIVE_OR_UPCOMING,
|
||||
@@ -251,7 +251,7 @@ def search_youtube(
|
||||
q: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = 20,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Live YouTube search results materialised into the catalog. `limit` is how many results to
|
||||
@@ -402,7 +402,7 @@ class ClearSearchIn(BaseModel):
|
||||
@router.post("/clear")
|
||||
def clear_search(
|
||||
body: ClearSearchIn,
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Discard the given live-search results "as if never added": drop this user's search-finds
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, quota, state
|
||||
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.models import Channel, Subscription, User, Video
|
||||
from app.routes._common import youtube_refusal
|
||||
@@ -26,7 +26,7 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
@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):
|
||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||
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")
|
||||
def deep_all(
|
||||
user: User = Depends(require_human),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
on: bool = True,
|
||||
) -> dict:
|
||||
|
||||
@@ -13,9 +13,8 @@ from app import audit, debugsession, logs, logsink, progress, quota
|
||||
from app.audit import AuditAction
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.demo import reset_demo_state
|
||||
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.tmdb import run_enrich as run_tmdb_enrich
|
||||
from app.plex.tmdb import run_people_enrich
|
||||
@@ -63,7 +62,6 @@ JOB_INTERVALS: dict[str, int] = {
|
||||
"subscriptions": settings.subscriptions_resync_minutes,
|
||||
"playlist_sync": settings.playlist_sync_minutes,
|
||||
"maintenance": settings.maintenance_interval_minutes,
|
||||
"demo_reset": settings.demo_reset_minutes,
|
||||
"explore_cleanup": settings.explore_cleanup_minutes,
|
||||
"download_gc": settings.download_gc_minutes,
|
||||
"plex_sync": settings.plex_sync_interval_min,
|
||||
@@ -139,8 +137,7 @@ def _notify_done(
|
||||
def _run_changed(result) -> bool:
|
||||
"""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
|
||||
off — the default) or {"skipped": "no demo account"} are no-ops, not changes, so they don't
|
||||
flood the trail every interval."""
|
||||
off — the default) are no-ops, not changes, so they don't flood the trail every interval."""
|
||||
if result is None:
|
||||
return False
|
||||
if isinstance(result, dict):
|
||||
@@ -370,17 +367,6 @@ def _maintenance_job() -> None:
|
||||
_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:
|
||||
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
|
||||
# their grace periods. 0 quota (pure DB), so no quota attribution.
|
||||
@@ -466,7 +452,6 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||
"subscriptions": _subscriptions_job,
|
||||
"playlist_sync": _playlist_sync_job,
|
||||
"maintenance": _maintenance_job,
|
||||
"demo_reset": _demo_reset_job,
|
||||
"explore_cleanup": _explore_cleanup_job,
|
||||
"download_gc": _download_gc_job,
|
||||
"plex_sync": _plex_sync_job,
|
||||
|
||||
@@ -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:
|
||||
the messaging directory AND the download share-by-email / recipient-picker endpoints. Kept in one
|
||||
place so the two forms (SQL clauses for a query, Python check for a loaded row) can't drift apart,
|
||||
and so a route module doesn't have to import another route module just to reuse the rule.
|
||||
Used wherever a user must be filtered out of a recipient list or rejected as a target: the download
|
||||
share-by-email and recipient-picker endpoints. Kept in one place so the two forms (SQL clauses for a
|
||||
query, Python check for a loaded row) can't drift apart, and so a route module doesn't have to import
|
||||
another route module just to reuse the rule.
|
||||
"""
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
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 [
|
||||
User.is_demo.is_(False),
|
||||
User.is_active.is_(True),
|
||||
User.is_suspended.is_(False),
|
||||
]
|
||||
|
||||
|
||||
def is_messageable_user(u: User | None) -> bool:
|
||||
"""Python mirror of `messageable_clauses()` for an already-loaded row (WS auth, send recipient)."""
|
||||
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
|
||||
"""Python mirror of `messageable_clauses()` for an already-loaded row (a share recipient)."""
|
||||
return bool(u and u.is_active and not u.is_suspended)
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
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):
|
||||
admin = make_user(email="boss@example.com", role="admin")
|
||||
body = as_user(admin).post("/api/admin/purge-discovery").json()
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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)
|
||||
r = _client(routes=BIG).post("/api/things/abc/blob", content=over)
|
||||
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.
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _setup_channel_and_sub(db, *, email, is_demo=False):
|
||||
def _setup_channel_and_sub(db, *, email):
|
||||
ch = Channel(
|
||||
id="UC_wire",
|
||||
title="Wire",
|
||||
@@ -98,7 +98,7 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
|
||||
uploads_playlist_id="UU_wire",
|
||||
)
|
||||
db.add(ch)
|
||||
user = User(email=email, is_demo=is_demo)
|
||||
user = User(email=email)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
|
||||
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)]
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Four parts:
|
||||
* 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
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
|
||||
@@ -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
|
||||
@@ -1,8 +1,8 @@
|
||||
"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in.
|
||||
|
||||
Three parts:
|
||||
* the level itself: the server computes its expiry, a lapsed one reads as the base, the demo
|
||||
account cannot raise it (DB lane, through the real routes);
|
||||
* the level itself: the server computes its expiry, a lapsed one reads as the base (DB lane,
|
||||
through the real routes);
|
||||
* `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
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
client = as_user(make_user())
|
||||
assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
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
|
||||
same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp
|
||||
(a refused password, the shared demo account) and the admin list that shows it.
|
||||
same four lines. So these pin BOTH doors through the real routes — plus the one that must not stamp
|
||||
(a refused password) and the admin list that shows it.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.config import settings
|
||||
from app.models import DemoWhitelist
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_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
|
||||
|
||||
|
||||
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):
|
||||
admin = make_user(email="boss@example.com", role="admin")
|
||||
never = make_user(email="never@example.com")
|
||||
|
||||
@@ -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
|
||||
the module-level limiters between tests), so without a test here a refactor could turn it into a
|
||||
|
||||
@@ -46,9 +46,7 @@ SERIALIZERS = {
|
||||
"_collection_card",
|
||||
"_playlist_card",
|
||||
"_serialize",
|
||||
"_serialize_demo",
|
||||
"_serialize_job",
|
||||
"_serialize_msg",
|
||||
"_serialize_profile",
|
||||
"_serialize_user",
|
||||
"_summary",
|
||||
|
||||
@@ -16,7 +16,7 @@ def _client(db, user, monkeypatch, override_dep):
|
||||
state, "_configured_cache", True
|
||||
) # the request gate 503s an unconfigured app
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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.main import app
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False, is_active=False)
|
||||
|
||||
@@ -31,12 +31,11 @@ BIG_MODULES: dict[str, int] = {
|
||||
"app/downloads/edit.py": 833,
|
||||
"app/downloads/pipeline.py": 870,
|
||||
"app/logs.py": 839,
|
||||
"app/models.py": 1607,
|
||||
"app/models.py": 1525,
|
||||
"app/plex/stream.py": 1128,
|
||||
"app/plex/subtitles.py": 1092,
|
||||
"app/routes/admin.py": 864,
|
||||
"app/routes/auth.py": 1353,
|
||||
"app/routes/downloads.py": 1237,
|
||||
"app/routes/auth.py": 1273,
|
||||
"app/routes/downloads.py": 1232,
|
||||
}
|
||||
|
||||
COMPLEX: dict[str, int] = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
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,
|
||||
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.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
@@ -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
|
||||
# when a site appears, when one disappears, and when one changes what it writes.
|
||||
DISPLAY_WRITE_SITES = {
|
||||
("auth.py", "get_or_create_demo_user"): {"'Demo'"},
|
||||
("downloads/service.py", "redownload"): {"None"},
|
||||
("downloads/service.py", "enqueue"): {
|
||||
"display_name or (asset.title[:DISPLAY_TEXT_MAX] "
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging
|
||||
directory and the download share endpoints. A row must be non-demo AND active AND not-suspended."""
|
||||
"""is_messageable_user — the "active, non-suspended account" predicate reused by the download share
|
||||
endpoints. A row must be active AND not-suspended."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.userscope import is_messageable_user
|
||||
|
||||
|
||||
def _user(is_demo=False, is_active=True, is_suspended=False):
|
||||
return SimpleNamespace(is_demo=is_demo, is_active=is_active, is_suspended=is_suspended)
|
||||
def _user(is_active=True, is_suspended=False):
|
||||
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
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ def test_none_is_not_messageable():
|
||||
assert is_messageable_user(None) is False
|
||||
|
||||
|
||||
def test_demo_suspended_or_inactive_are_not_messageable():
|
||||
assert is_messageable_user(_user(is_demo=True)) is False
|
||||
def test_suspended_or_inactive_are_not_messageable():
|
||||
assert is_messageable_user(_user(is_suspended=True)) is False
|
||||
assert is_messageable_user(_user(is_active=False)) is False
|
||||
|
||||
@@ -40,7 +40,7 @@ login-free `/watch/<token>` share link).
|
||||
|
||||
## 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
|
||||
user), reset to a deterministic baseline every run by
|
||||
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
|
||||
|
||||
@@ -42,7 +42,6 @@ import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
@@ -438,7 +437,6 @@ export default function App() {
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
{me!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
}
|
||||
@@ -459,7 +457,7 @@ export default function App() {
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||
{me && !me.is_demo && <DebugRecorder />}
|
||||
{me && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
<About
|
||||
|
||||
@@ -175,18 +175,14 @@ export default function AccountPanel({
|
||||
}
|
||||
tone={a.has_google ? "ok" : "muted"}
|
||||
/>
|
||||
{/* The exact moment; the list row beside it only says how long ago. Null has two different
|
||||
meanings, so it says which: nothing recorded yet, or the shared demo account, which is
|
||||
deliberately never stamped. */}
|
||||
{/* The exact moment; the list row beside it only says how long ago. */}
|
||||
<AccessRow
|
||||
icon={LogIn}
|
||||
label={t("users.account.lastLogin")}
|
||||
value={
|
||||
a.last_login_at
|
||||
? formatDateTime(a.last_login_at, lang)
|
||||
: a.is_demo
|
||||
? t("users.account.lastLoginDemo")
|
||||
: t("users.account.lastLoginNone")
|
||||
: t("users.account.lastLoginNone")
|
||||
}
|
||||
/>
|
||||
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||
@@ -223,9 +219,7 @@ export default function AccountPanel({
|
||||
icon={<Send />}
|
||||
loading={resend.isPending}
|
||||
onClick={() => resend.mutate()}
|
||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||
disabled={user.is_demo || !a.email_configured || a.email_verified}
|
||||
disabled={!a.email_configured || a.email_verified}
|
||||
>
|
||||
<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
|
||||
@@ -243,7 +237,6 @@ export default function AccountPanel({
|
||||
fullWidth
|
||||
icon={<KeyRound />}
|
||||
loading={issue.isPending}
|
||||
disabled={user.is_demo}
|
||||
onClick={() => issue.mutate()}
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
|
||||
@@ -44,7 +44,6 @@ const row = {
|
||||
email: "gone@x",
|
||||
role: "user",
|
||||
is_suspended: false,
|
||||
is_demo: false,
|
||||
email_verified: true,
|
||||
is_active: true,
|
||||
} as unknown as AdminUserRow;
|
||||
@@ -83,7 +82,6 @@ describe("the last sign-in column (E22 S1)", () => {
|
||||
vi.spyOn(api, "adminUsers").mockResolvedValue([
|
||||
{ ...row, id: 3, email: "seen@x", last_login_at: at },
|
||||
{ ...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);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
@@ -94,7 +92,7 @@ describe("the last sign-in column (E22 S1)", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
|
||||
expect(screen.getAllByText("—")).toHaveLength(2);
|
||||
expect(screen.getAllByText("—")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ import type { ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
// carries a badge with the count of pending requests so it's visible without switching to it.
|
||||
// Admin-only user & access management: roles and access requests (the Invite whitelist). Each
|
||||
// section is its own tab (persisted across reloads); the Access tab carries a badge with the count
|
||||
// of pending requests so it's visible without switching to it.
|
||||
// (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.)
|
||||
|
||||
@@ -81,7 +81,6 @@ export default function AdminUsers() {
|
||||
label: t("users.tabs.access"),
|
||||
badge: me.pending_invites + me.pending_activations,
|
||||
},
|
||||
{ id: "demo", label: t("users.tabs.demo") },
|
||||
];
|
||||
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
||||
return (
|
||||
@@ -100,7 +99,6 @@ export default function AdminUsers() {
|
||||
<PendingAccess />
|
||||
</>
|
||||
)}
|
||||
{active === "demo" && <AdminDemo />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -160,7 +158,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
});
|
||||
},
|
||||
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")),
|
||||
});
|
||||
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 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
|
||||
// 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 menuItems = (u: AdminUserRow): ActionMenuItem[] => {
|
||||
const self = u.id === me.id;
|
||||
const pending = pendingIds.has(u.id);
|
||||
const locked = (demoNote: string, selfNote: string) =>
|
||||
u.is_demo ? demoNote : self ? selfNote : undefined;
|
||||
const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked"));
|
||||
const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked"));
|
||||
const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined;
|
||||
const roleNote = self ? t("users.roles.selfLocked") : undefined;
|
||||
const suspendNote = self ? t("users.suspend.selfLocked") : undefined;
|
||||
const deleteNote = self ? t("users.delete.locked") : undefined;
|
||||
return [
|
||||
{
|
||||
id: "role",
|
||||
@@ -301,7 +297,6 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</Pressable>
|
||||
{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_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
|
||||
{/* A Google sign-in proves the email, so "unverified" only applies to a
|
||||
@@ -317,9 +312,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
hint={
|
||||
u.last_login_at
|
||||
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) })
|
||||
: u.is_demo
|
||||
? t("users.roles.lastLoginDemo")
|
||||
: t("users.roles.lastLoginNone")
|
||||
: t("users.roles.lastLoginNone")
|
||||
}
|
||||
>
|
||||
<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({
|
||||
inv,
|
||||
onApprove,
|
||||
|
||||
@@ -68,7 +68,6 @@ import AppCommandPalette from "./AppCommandPalette";
|
||||
const user = (over: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
plex_enabled: false,
|
||||
...over,
|
||||
});
|
||||
@@ -143,7 +142,7 @@ describe("AppCommandPalette", () => {
|
||||
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:"));
|
||||
mount();
|
||||
expect(recorder()).toEqual(["action:recorder:start"]);
|
||||
@@ -153,10 +152,5 @@ describe("AppCommandPalette", () => {
|
||||
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
|
||||
mount();
|
||||
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
|
||||
|
||||
cleanup();
|
||||
me = user({ is_demo: true });
|
||||
mount();
|
||||
expect(recorder()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,34 +151,31 @@ export default function AppCommandPalette() {
|
||||
icon: <Keyboard className="w-4 h-4" />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
// The debug recorder (E16) — not for the shared demo account, which cannot record. While
|
||||
// it runs, Stop and a step marker take Start's place: the palette is reachable inside a
|
||||
// The debug recorder (E16). While 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.
|
||||
...(me?.is_demo
|
||||
? []
|
||||
: recordingSession
|
||||
? [
|
||||
{
|
||||
id: "action:recorder:step",
|
||||
label: t("recorder.palette.step"),
|
||||
icon: <Flag className="w-4 h-4" />,
|
||||
run: () => markStep(""),
|
||||
},
|
||||
{
|
||||
id: "action:recorder:stop",
|
||||
label: t("recorder.palette.stop"),
|
||||
icon: <Square className="w-4 h-4" />,
|
||||
run: () => stopMutate(recordingSession.id),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "action:recorder:start",
|
||||
label: t("recorder.palette.start"),
|
||||
icon: <Bug className="w-4 h-4" />,
|
||||
run: openRecorderStart,
|
||||
},
|
||||
]),
|
||||
...(recordingSession
|
||||
? [
|
||||
{
|
||||
id: "action:recorder:step",
|
||||
label: t("recorder.palette.step"),
|
||||
icon: <Flag className="w-4 h-4" />,
|
||||
run: () => markStep(""),
|
||||
},
|
||||
{
|
||||
id: "action:recorder:stop",
|
||||
label: t("recorder.palette.stop"),
|
||||
icon: <Square className="w-4 h-4" />,
|
||||
run: () => stopMutate(recordingSession.id),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "action:recorder:start",
|
||||
label: t("recorder.palette.start"),
|
||||
icon: <Bug className="w-4 h-4" />,
|
||||
run: openRecorderStart,
|
||||
},
|
||||
]),
|
||||
...SCHEMES.map((s) => ({
|
||||
id: `action:theme:${s.id}`,
|
||||
label: t("palette.theme", { name: s.name }),
|
||||
|
||||
@@ -152,18 +152,17 @@ export default function ChannelPage({
|
||||
[channelId, qc, t],
|
||||
);
|
||||
|
||||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
||||
// account (explore spends shared quota) and when videos are already present (a revisit, or a
|
||||
// subscribed channel whose uploads the scheduler backfills).
|
||||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
|
||||
// already present (a revisit, or a subscribed channel whose uploads the scheduler backfills).
|
||||
useEffect(() => {
|
||||
if (!ch || me.is_demo) return;
|
||||
if (!ch) return;
|
||||
if (autoTried.current === channelId) return;
|
||||
if (ch.subscribed || ch.blocked) return;
|
||||
if (ch.explored && ch.stored_videos > 0) return;
|
||||
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.
|
||||
void runExplore();
|
||||
}, [ch, channelId, me.is_demo, runExplore]);
|
||||
}, [ch, channelId, runExplore]);
|
||||
|
||||
const block = useMutation({
|
||||
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
|
||||
// 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.
|
||||
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
|
||||
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
|
||||
const tagsQuery = useTags({ enabled: !!ch?.subscribed && !me.is_demo });
|
||||
// Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed
|
||||
// channel page never shows the control, so the request would be thrown away.
|
||||
const tagsQuery = useTags({ enabled: !!ch?.subscribed });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
@@ -503,7 +502,7 @@ export default function ChannelPage({
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</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">
|
||||
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
|
||||
<PriorityStepper
|
||||
@@ -520,7 +519,7 @@ export default function ChannelPage({
|
||||
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
|
||||
spinner. */}
|
||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||
{ch?.subscribed && !ch.backfill_done && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||
@@ -545,23 +544,21 @@ export default function ChannelPage({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<IconButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||
// light up on hover, as if a click would do something.
|
||||
size="md"
|
||||
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||
onClick={() => block.mutate()}
|
||||
loading={block.isPending}
|
||||
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
>
|
||||
<Ban />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||
// light up on hover, as if a click would do something.
|
||||
size="md"
|
||||
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||
onClick={() => block.mutate()}
|
||||
loading={block.isPending}
|
||||
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
>
|
||||
<Ban />
|
||||
</IconButton>
|
||||
{/* 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
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
{ch?.subscribed && (
|
||||
<IconButton
|
||||
size="md"
|
||||
variant={ch.hidden ? "outline-accent" : "outline"}
|
||||
@@ -573,8 +570,7 @@ export default function ChannelPage({
|
||||
{ch.hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
{!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -598,7 +594,7 @@ export default function ChannelPage({
|
||||
{/* 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
|
||||
channel you actually follow. */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
{ch?.subscribed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
@@ -631,7 +627,6 @@ export default function ChannelPage({
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
|
||||
@@ -117,7 +117,6 @@ function mount(me: Partial<Me>, extra: React.ReactNode = null) {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
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 } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
debug_session: null,
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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. */
|
||||
function useElapsed(since: string): string {
|
||||
|
||||
@@ -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();
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
...me,
|
||||
@@ -212,7 +211,6 @@ describe("DiagnosticsSettings", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
|
||||
answerStale({
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
});
|
||||
@@ -229,10 +227,4 @@ describe("DiagnosticsSettings", () => {
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { levelInForce } from "../lib/diagnostics";
|
||||
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
|
||||
import { updateMeCache, useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import MyLogViewer from "./MyLogViewer";
|
||||
@@ -99,24 +98,20 @@ export default function DiagnosticsSettings() {
|
||||
return (
|
||||
<>
|
||||
<Section title={t("settings.diagnostics.title")}>
|
||||
{me.is_demo ? (
|
||||
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
|
||||
) : (
|
||||
<SettingRow
|
||||
label={t("settings.diagnostics.level")}
|
||||
hint={t("settings.diagnostics.levelHint")}
|
||||
>
|
||||
<div ref={pickerRef}>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("settings.diagnostics.level")}
|
||||
value={pending ?? current}
|
||||
onChange={choose}
|
||||
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("settings.diagnostics.level")}
|
||||
hint={t("settings.diagnostics.levelHint")}
|
||||
>
|
||||
<div ref={pickerRef}>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("settings.diagnostics.level")}
|
||||
value={pending ?? current}
|
||||
onChange={choose}
|
||||
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{pending && (
|
||||
<div
|
||||
@@ -186,11 +181,9 @@ export default function DiagnosticsSettings() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{!me.is_demo && (
|
||||
<Section title={t("recorder.settings.title")}>
|
||||
<RecorderSettings />
|
||||
</Section>
|
||||
)}
|
||||
<Section title={t("recorder.settings.title")}>
|
||||
<RecorderSettings />
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.myLog.title")}>
|
||||
<MyLogViewer />
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
// 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.
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// Self-contained download affordance for a video card / the player. Hidden for the demo account
|
||||
// (downloads spend server disk + need a real identity). Reflects the video's current state from
|
||||
// Self-contained download affordance for a video card / the player. Reflects the video's current state from
|
||||
// the shared per-user download index (one polled query shared across every card).
|
||||
export default function DownloadButton({
|
||||
videoId,
|
||||
@@ -25,20 +24,13 @@ export default function DownloadButton({
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = qc.getQueryData<Me>(qk.me());
|
||||
const isDemo = !!me?.is_demo;
|
||||
|
||||
const indexQ = useQuery({
|
||||
queryKey: qk.downloadIndex(),
|
||||
queryFn: api.downloadIndex,
|
||||
enabled: !isDemo,
|
||||
staleTime: 10000,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isDemo) return null;
|
||||
|
||||
const status = indexQ.data?.[videoId];
|
||||
const downloaded = status === "done";
|
||||
const inQueue = status === "queued" || status === "running" || status === "paused";
|
||||
|
||||
@@ -111,7 +111,6 @@ export default function Feed({
|
||||
view,
|
||||
setView,
|
||||
canRead,
|
||||
isDemo = false,
|
||||
ytSearch,
|
||||
onYtSearch,
|
||||
onExitYtSearch,
|
||||
@@ -125,11 +124,10 @@ export default function Feed({
|
||||
view: FeedView;
|
||||
setView: (v: FeedView) => void;
|
||||
canRead: boolean;
|
||||
isDemo?: boolean;
|
||||
// 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
|
||||
// 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;
|
||||
onYtSearch: (q: string) => void;
|
||||
onExitYtSearch: () => void;
|
||||
@@ -595,41 +593,24 @@ export default function Feed({
|
||||
{t("feed.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
// Empty "my" feed with no YouTube access. The shared demo account can never connect
|
||||
// YouTube, so it gets a gentle nudge into the shared library instead of the connect
|
||||
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
|
||||
// Empty "my" feed with no YouTube access: the onboarding prompt, plus a way into the shared
|
||||
// library. No toolbar (sort/type are meaningless).
|
||||
if (items.length === 0 && !canRead && filters.scope === "my")
|
||||
return (
|
||||
<div className="p-8 grid place-items-center text-center">
|
||||
<div className="max-w-sm">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted mt-2 mb-4">
|
||||
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||
</p>
|
||||
{isDemo ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
||||
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
|
||||
<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>
|
||||
);
|
||||
@@ -879,7 +860,7 @@ export default function Feed({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.q.trim() && !isDemo && (
|
||||
{filters.q.trim() && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
icon={<YouTubeIcon />}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The shared demo account has no subscriptions, so its feed opens on the whole library. The filters are
|
||||
// seeded from the `me` cache at mount — BootGate mounts the tree only once it is there. The adopt effect
|
||||
// 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).
|
||||
// The feed's filters are seeded at mount from the account's stored view. 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 { cleanup, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -49,20 +44,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("FeedFiltersProvider seed", () => {
|
||||
it("opens the shared demo account on the whole library from its first render", () => {
|
||||
mount({ is_demo: true });
|
||||
expect(scopes.length).toBeGreaterThan(0);
|
||||
expect(scopes).toEqual(scopes.map(() => "all"));
|
||||
});
|
||||
|
||||
it("opens any other account on its own scope", () => {
|
||||
mount({ is_demo: false });
|
||||
it("opens an account on its own scope", () => {
|
||||
mount({});
|
||||
expect(scopes[0]).toBe("my");
|
||||
});
|
||||
|
||||
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
|
||||
window.history.replaceState(null, "", "/?scope=all");
|
||||
mount({ is_demo: false });
|
||||
mount({});
|
||||
expect(scopes[0]).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 { qk } from "../lib/queryKeys";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getActiveAccount, type FeedFilters, type Me } from "../lib/api";
|
||||
import { getActiveAccount, type FeedFilters } from "../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { accountKey, writeJSON } from "../lib/storage";
|
||||
@@ -28,21 +26,16 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI
|
||||
// feedFiltersInit.ts.
|
||||
|
||||
// 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);
|
||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||
const f = loadAccountFilters(getActiveAccount());
|
||||
// The shared demo account has no subscriptions, so default it to the whole library.
|
||||
return me?.is_demo ? { ...f, scope: "all" } : f;
|
||||
return loadAccountFilters(getActiveAccount());
|
||||
}
|
||||
|
||||
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
|
||||
// pinned and `me` is cached, so this is the account's own view from the first render.
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(() =>
|
||||
loadInitialFilters(qc.getQueryData<Me | null>(qk.me())),
|
||||
);
|
||||
// pinned, so this is the account's own view from the first render.
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// 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.
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
|
||||
@@ -31,7 +31,7 @@ import type { Me } from "../lib/api";
|
||||
afterEach(cleanup);
|
||||
|
||||
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={() => {}} />);
|
||||
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ const Header = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
me: Me;
|
||||
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||
// which can't spend the shared quota).
|
||||
// Trigger a live YouTube search for the current term (feed only).
|
||||
onYtSearch: (q: string) => void;
|
||||
}
|
||||
>(function Header({ me, onYtSearch }, ref) {
|
||||
@@ -64,7 +63,7 @@ const Header = forwardRef<
|
||||
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
|
||||
)}
|
||||
goIcon={<YouTubeIcon className="w-4 h-4" />}
|
||||
goDisabled={!trimmed || me.is_demo}
|
||||
goDisabled={!trimmed}
|
||||
/>
|
||||
);
|
||||
} else if (page === "plex" && me.plex_enabled) {
|
||||
|
||||
@@ -124,7 +124,6 @@ export default function NavSidebar({
|
||||
// lightweight per-user index the feed cards poll, so no extra request shape.
|
||||
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
|
||||
intervalMs: 30000,
|
||||
enabled: !me.is_demo,
|
||||
});
|
||||
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ function mount() {
|
||||
}
|
||||
render(
|
||||
<>
|
||||
<Header me={{ is_demo: false, plex_enabled: false } as Me} onYtSearch={() => {}} />
|
||||
<Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
|
||||
<Probe />
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
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.
|
||||
const { scope, show, filters } = usePlex();
|
||||
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
|
||||
// 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();
|
||||
// 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.
|
||||
@@ -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
|
||||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden
|
||||
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||
iconExpanded={
|
||||
isDemo ? undefined : (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
)
|
||||
}
|
||||
onTitleClick={isDemo ? undefined : openModal}
|
||||
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
|
||||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
|
||||
// Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
||||
onTitleClick={openModal}
|
||||
titleActionLabel={t("views.manageTitle")}
|
||||
/>
|
||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
|
||||
@@ -33,7 +33,6 @@ const me = (extra: Partial<Me>): Me =>
|
||||
display_name: "Someone",
|
||||
avatar_url: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
last_login_at: null,
|
||||
...extra,
|
||||
}) as Me;
|
||||
@@ -47,14 +46,10 @@ describe("AccountHeader", () => {
|
||||
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({})} />);
|
||||
expect(screen.getByTestId("account-last-login").textContent).toBe(
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,7 +342,7 @@ function AccessRow({
|
||||
// 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
|
||||
// (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 }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -785,13 +785,11 @@ export function AccountHeader({ me }: { me: Me }) {
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
{/* 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>
|
||||
{!me.is_demo && (
|
||||
<div className="text-xs text-muted" data-testid="account-last-login">
|
||||
{me.last_login_at
|
||||
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
|
||||
: t("settings.account.lastLoginNone")}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted" data-testid="account-last-login">
|
||||
{me.last_login_at
|
||||
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
|
||||
: t("settings.account.lastLoginNone")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -823,17 +821,13 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
<>
|
||||
<AccountHeader me={me} />
|
||||
|
||||
{!me.is_demo && <SignInMethods me={me} />}
|
||||
<SignInMethods me={me} />
|
||||
|
||||
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
||||
|
||||
{!me.is_demo && <DownloadAuth />}
|
||||
<DownloadAuth />
|
||||
|
||||
{me.is_demo ? (
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
||||
</Section>
|
||||
) : me.google_enabled ? (
|
||||
{me.google_enabled ? (
|
||||
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
@@ -865,17 +859,15 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{!me.is_demo && (
|
||||
<Section title={t("settings.account.dangerZone")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||
{t("settings.account.deleteHint")}
|
||||
</p>
|
||||
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("settings.account.deleteAccount")}
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
<Section title={t("settings.account.dangerZone")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||
{t("settings.account.deleteHint")}
|
||||
</p>
|
||||
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("settings.account.deleteAccount")}
|
||||
</Button>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import Combobox from "./ui/Combobox";
|
||||
import MatchToggle from "./ui/MatchToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { useTags } from "../lib/useTags";
|
||||
import { compareText } from "../lib/collate";
|
||||
|
||||
@@ -51,7 +50,6 @@ export default function Sidebar({
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const isDemo = useMe().is_demo;
|
||||
const { openModal } = useSavedViews();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
@@ -245,14 +243,14 @@ export default function Sidebar({
|
||||
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||
collapsible={false}
|
||||
// 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={
|
||||
!isDemo && source === "feed" ? (
|
||||
source === "feed" ? (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
) : undefined
|
||||
}
|
||||
onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
|
||||
titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined}
|
||||
onTitleClick={source === "feed" ? openModal : undefined}
|
||||
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
|
||||
>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
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 { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -61,13 +61,13 @@ export default function Stats() {
|
||||
</PageToolbar>
|
||||
)}
|
||||
|
||||
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
|
||||
{tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
||||
function Overview({ me }: { me: Me }) {
|
||||
// Per-user view: sync status + your own API usage + the manual sync actions.
|
||||
function Overview() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
@@ -203,32 +203,30 @@ function Overview({ me }: { me: Me }) {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{!me.is_demo && (
|
||||
<Section title={t("stats.sync.actions")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<RefreshCw />}
|
||||
loading={syncSubs.isPending}
|
||||
onClick={() => syncSubs.mutate()}
|
||||
>
|
||||
{t("stats.sync.syncSubscriptions")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<History />}
|
||||
loading={deepAll.isPending}
|
||||
onClick={() => deepAll.mutate()}
|
||||
>
|
||||
{t("stats.sync.backfillEverything")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
<Section title={t("stats.sync.actions")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<RefreshCw />}
|
||||
loading={syncSubs.isPending}
|
||||
onClick={() => syncSubs.mutate()}
|
||||
>
|
||||
{t("stats.sync.syncSubscriptions")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<History />}
|
||||
loading={deepAll.isPending}
|
||||
onClick={() => deepAll.mutate()}
|
||||
>
|
||||
{t("stats.sync.backfillEverything")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
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 = {
|
||||
onState: vi.fn(),
|
||||
onToggleSave: vi.fn(),
|
||||
@@ -107,7 +106,6 @@ function setup(v: Video, { demo = false } = {}) {
|
||||
onOpen: vi.fn(),
|
||||
};
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { is_demo: demo });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
@@ -175,12 +173,6 @@ describe("a video's menu", () => {
|
||||
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", () => {
|
||||
setup(video({}));
|
||||
expect(more().getAttribute("aria-haspopup")).toBe("menu");
|
||||
|
||||
@@ -61,7 +61,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
|
||||
};
|
||||
|
||||
// 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
|
||||
// backend); errors are surfaced inline (the api calls use the quiet flag).
|
||||
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
|
||||
// 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).
|
||||
function Preview({
|
||||
src,
|
||||
@@ -446,13 +446,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
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 = () => {
|
||||
setError("");
|
||||
setDone("");
|
||||
@@ -533,25 +526,6 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
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
|
||||
// than a box sitting inside a heading that still says "Set a new password".
|
||||
const titleKey = blocked
|
||||
@@ -736,46 +710,18 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
)}
|
||||
</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">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t("welcome.auth.or")}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{googleEnabled && (
|
||||
<LinkButton href="/auth/login" size="lg" fullWidth>
|
||||
{t("welcome.auth.google")}
|
||||
</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>
|
||||
)}
|
||||
<LinkButton href="/auth/login" size="lg" fullWidth>
|
||||
{t("welcome.auth.google")}
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -34,14 +34,13 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
||||
const meId = me?.id;
|
||||
const canRead = me?.can_read;
|
||||
const canWrite = me?.can_write;
|
||||
const isDemo = me?.is_demo;
|
||||
const googleEnabled = me?.google_enabled;
|
||||
useEffect(() => {
|
||||
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.
|
||||
if (shouldAutoOpenOnboarding(account)) setOpen(true);
|
||||
}, [meId, canRead, canWrite, isDemo, googleEnabled]);
|
||||
}, [meId, canRead, canWrite, googleEnabled]);
|
||||
|
||||
const actions = useMemo<WizardActions>(
|
||||
() => ({ openWizard, closeWizard }),
|
||||
@@ -51,10 +50,9 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<WizardActionsContext.Provider value={actions}>
|
||||
{children}
|
||||
{/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never
|
||||
opens there. Lazy — its chunk loads only when first opened. */}
|
||||
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
|
||||
<Suspense fallback={null}>
|
||||
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
|
||||
{open && me && <OnboardingWizard me={me} onClose={closeWizard} />}
|
||||
</Suspense>
|
||||
</WizardActionsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,6 @@ const user = {
|
||||
email: "stuck@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
is_active: 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"));
|
||||
});
|
||||
});
|
||||
|
||||
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_suspended: false,
|
||||
email_verified: true,
|
||||
is_demo: false,
|
||||
has_password: true,
|
||||
has_google: false,
|
||||
created_at: "2026-09-01T10:00:00Z",
|
||||
|
||||
@@ -74,11 +74,7 @@ function localDetails(
|
||||
head: t("scheduler.maintenance.batchLabel"),
|
||||
sub: formatNumber(after.revalidate_batch, lang),
|
||||
};
|
||||
if (
|
||||
row.action === "audit_clear" ||
|
||||
row.action === "demo_reset" ||
|
||||
row.action === "discovery_purge"
|
||||
) {
|
||||
if (row.action === "audit_clear" || row.action === "discovery_purge") {
|
||||
const result = counts(after);
|
||||
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<div className="w-full sm:w-72">
|
||||
<UserPicker
|
||||
|
||||
@@ -33,12 +33,11 @@ export function FeedPage() {
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
{modalOpen && !me.is_demo && <PersonalViewsModal />}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
export default function Banner({
|
||||
tone,
|
||||
@@ -41,7 +41,7 @@ export default function Banner({
|
||||
* control is not an acceptable default, and `Button`'s type now says so. */
|
||||
dismissTitle?: string;
|
||||
/** `"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. */
|
||||
role?: "status";
|
||||
}) {
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
// - `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
|
||||
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
|
||||
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Bookmark,
|
||||
BookmarkX,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Undo2,
|
||||
} 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 { qk } from "../lib/queryKeys";
|
||||
import { pinVideo, useComfortPin } from "./comfortPin";
|
||||
@@ -48,17 +48,13 @@ const icon = "w-4 h-4";
|
||||
* panel to render beside the menu (one per list). */
|
||||
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { openChannel } = useNavigationActions();
|
||||
const pin = useComfortPin(open);
|
||||
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({
|
||||
queryKey: qk.downloadIndex(),
|
||||
queryFn: api.downloadIndex,
|
||||
enabled: open && canDownload,
|
||||
enabled: open,
|
||||
staleTime: 10000,
|
||||
});
|
||||
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"),
|
||||
note: dlState,
|
||||
icon: <Download className={icon} />,
|
||||
onSelect: () => setFollowUp({ kind: "download", video: v }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "download",
|
||||
label: t("downloads.menu.download"),
|
||||
note: dlState,
|
||||
icon: <Download className={icon} />,
|
||||
onSelect: () => setFollowUp({ kind: "download", video: v }),
|
||||
},
|
||||
...(details
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
|
||||
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
|
||||
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(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(opaqueWhats).toEqual([]);
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
"invite_approve": "Access request approved",
|
||||
"invite_deny": "Access request denied",
|
||||
"invite_add": "Email whitelisted",
|
||||
"demo_add": "Demo email added",
|
||||
"demo_remove": "Demo email removed",
|
||||
"demo_reset": "Demo reset",
|
||||
"discovery_purge": "Discovery purged",
|
||||
"config_set": "Setting changed",
|
||||
"config_reset": "Setting reset",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"accessRequestsMessage_one": "{{count}} pending — review it on the Users page.",
|
||||
"accessRequestsMessage_other": "{{count}} pending — review them on the Users page.",
|
||||
"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",
|
||||
"retry": "Retry",
|
||||
"retryWithEnter": "Press Enter to try again.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"title": "Couldn't complete that",
|
||||
"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.",
|
||||
"tooLarge": "That was too large for the server to accept",
|
||||
"server": "Server error",
|
||||
@@ -45,8 +44,7 @@
|
||||
"not_applicable": "This account has nothing left to confirm",
|
||||
"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.",
|
||||
"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"
|
||||
"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."
|
||||
},
|
||||
"reason": {
|
||||
"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_read_only": "This collection is read-only",
|
||||
"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",
|
||||
"edit_unfinished": "Only a finished download can be edited",
|
||||
"filters_not_object": "The view's filters are malformed",
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
"emptyBody": "Connect your YouTube account to import your subscriptions and build your Feed.",
|
||||
"setUp": "Set up my Feed",
|
||||
"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.",
|
||||
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared Library.",
|
||||
"viewInLibrary": "View in Library",
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"skipToContent": "Skip to content",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"demo": "Demo"
|
||||
"user": "User"
|
||||
},
|
||||
"rail": {
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -90,7 +89,6 @@
|
||||
"subscriptions": "Subscription sync from YouTube",
|
||||
"playlist_sync": "Playlist sync from YouTube",
|
||||
"maintenance": "Maintenance + validation",
|
||||
"demo_reset": "Demo account reset",
|
||||
"explore_cleanup": "Explored-channel cleanup",
|
||||
"download_gc": "Download cleanup",
|
||||
"plex_sync": "Plex library sync",
|
||||
@@ -139,7 +137,6 @@
|
||||
"revalidated": "revalidated",
|
||||
"flagged": "flagged",
|
||||
"skipped": "skipped",
|
||||
"playlists_seeded": "playlists seeded",
|
||||
"expired": "expired",
|
||||
"videos_deleted": "videos deleted",
|
||||
"channels_deleted": "channels deleted",
|
||||
@@ -183,7 +180,6 @@
|
||||
"disabled": "disabled",
|
||||
"PlexserverURLortokenisnotconfigured": "Plex server URL or token is not configured",
|
||||
"tmdbapikeynotset": "TMDB API key not set",
|
||||
"nodemoaccount": "no demo account",
|
||||
"noserviceuser": "no service user",
|
||||
"syncpaused": "sync paused",
|
||||
"lowquota": "low quota",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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.",
|
||||
"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",
|
||||
"googleLink": {
|
||||
"title": "Google account",
|
||||
@@ -132,28 +131,6 @@
|
||||
"removeConfirmAction": "Delete",
|
||||
"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": {
|
||||
"title": "Access requests",
|
||||
"noPending": "No pending requests.",
|
||||
@@ -228,7 +205,6 @@
|
||||
},
|
||||
"status": "{{level}} logging · back to “Basic” in {{time}}",
|
||||
"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"
|
||||
},
|
||||
"myLog": {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"tabs": {
|
||||
"roles": "Users & roles",
|
||||
"access": "Access requests",
|
||||
"demo": "Demo"
|
||||
"access": "Access requests"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Signed up, but still outside",
|
||||
@@ -26,14 +25,11 @@
|
||||
"empty": "No users yet.",
|
||||
"you": "you",
|
||||
"admin": "Admin",
|
||||
"demo": "Demo",
|
||||
"suspended": "Suspended",
|
||||
"pending": "Pending approval",
|
||||
"unverified": "Unverified email",
|
||||
"lastLogin": "Last sign-in: {{time}}",
|
||||
"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.",
|
||||
"makeAdmin": "Make admin",
|
||||
"makeUser": "Make user",
|
||||
@@ -53,7 +49,6 @@
|
||||
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
|
||||
"done": "{{email}} suspended",
|
||||
"undone": "{{email}} unsuspended",
|
||||
"demoLocked": "The demo account can't be suspended.",
|
||||
"selfLocked": "You can't suspend your own account.",
|
||||
"failed": "Couldn't change the suspension"
|
||||
},
|
||||
@@ -86,7 +81,6 @@
|
||||
"invite": "Invite",
|
||||
"lastLogin": "Last sign-in",
|
||||
"lastLoginNone": "none recorded yet",
|
||||
"lastLoginDemo": "not recorded for the demo account",
|
||||
"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.",
|
||||
"mailOff": "no mail configured",
|
||||
|
||||
@@ -77,11 +77,6 @@
|
||||
"backToSignin": "Back to sign in",
|
||||
"or": "or",
|
||||
"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.",
|
||||
"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",
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
"invite_approve": "Hozzáférési kérelem jóváhagyva",
|
||||
"invite_deny": "Hozzáférési kérelem elutasítva",
|
||||
"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",
|
||||
"config_set": "Beállítás módosítva",
|
||||
"config_reset": "Beállítás visszaállítva",
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"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.",
|
||||
"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",
|
||||
"retry": "Újra",
|
||||
"retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"title": "Nem sikerült",
|
||||
"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.",
|
||||
"tooLarge": "Ez túl nagy, a szerver nem fogadta el",
|
||||
"server": "Szerverhiba",
|
||||
@@ -45,8 +44,7 @@
|
||||
"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ó",
|
||||
"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.",
|
||||
"demo": "A demó fiók közös, ezért nem kaphat jelszóbeállító linket"
|
||||
"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."
|
||||
},
|
||||
"reason": {
|
||||
"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_read_only": "Ez a kollekció csak olvasható",
|
||||
"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",
|
||||
"edit_unfinished": "Csak befejezett letöltés szerkeszthető",
|
||||
"filters_not_object": "A nézet szűrői hibás formátumúak",
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
"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",
|
||||
"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.",
|
||||
"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",
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"skipToContent": "Ugrás a tartalomra",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"user": "Felhasználó",
|
||||
"demo": "Demó"
|
||||
"user": "Felhasználó"
|
||||
},
|
||||
"rail": {
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -90,7 +89,6 @@
|
||||
"subscriptions": "Feliratkozások 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",
|
||||
"demo_reset": "Demó fiók visszaállítása",
|
||||
"explore_cleanup": "Felfedezett csatornák takarítása",
|
||||
"download_gc": "Letöltések takarítása",
|
||||
"plex_sync": "Plex-könyvtár szinkron",
|
||||
@@ -139,7 +137,6 @@
|
||||
"revalidated": "újraérvényesítve",
|
||||
"flagged": "megjelölve",
|
||||
"skipped": "kihagyva",
|
||||
"playlists_seeded": "létrehozott lejátszási lista",
|
||||
"expired": "lejárt",
|
||||
"videos_deleted": "törölt videó",
|
||||
"channels_deleted": "törölt csatorna",
|
||||
@@ -183,7 +180,6 @@
|
||||
"disabled": "kikapcsolva",
|
||||
"PlexserverURLortokenisnotconfigured": "a Plex-szerver címe vagy tokenje nincs beállítva",
|
||||
"tmdbapikeynotset": "nincs TMDB API-kulcs",
|
||||
"nodemoaccount": "nincs demófiók",
|
||||
"noserviceuser": "nincs szolgáltatásfiók",
|
||||
"syncpaused": "a szinkron szünetel",
|
||||
"lowquota": "kevés a kvóta",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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.",
|
||||
"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",
|
||||
"googleLink": {
|
||||
"title": "Google-fiók",
|
||||
@@ -132,28 +131,6 @@
|
||||
"removeConfirmAction": "Törlés",
|
||||
"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": {
|
||||
"title": "Hozzáférési kérelmek",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"myLog": {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"tabs": {
|
||||
"roles": "Felhasználók és szerepek",
|
||||
"access": "Hozzáférési kérelmek",
|
||||
"demo": "Demó"
|
||||
"access": "Hozzáférési kérelmek"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Regisztrált, de még kívül van",
|
||||
@@ -26,14 +25,11 @@
|
||||
"empty": "Még nincs felhasználó.",
|
||||
"you": "te",
|
||||
"admin": "Admin",
|
||||
"demo": "Demó",
|
||||
"suspended": "Felfüggesztve",
|
||||
"pending": "Jóváhagyásra vár",
|
||||
"unverified": "Nem igazolt e-mail",
|
||||
"lastLogin": "Utolsó belépés: {{time}}",
|
||||
"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.",
|
||||
"makeAdmin": "Adminná tesz",
|
||||
"makeUser": "Felhasználóvá tesz",
|
||||
@@ -53,7 +49,6 @@
|
||||
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
|
||||
"done": "{{email}} felfüggesztve",
|
||||
"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.",
|
||||
"failed": "Nem sikerült módosítani a felfüggesztést"
|
||||
},
|
||||
@@ -86,7 +81,6 @@
|
||||
"invite": "Meghívó",
|
||||
"lastLogin": "Utolsó belépés",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -77,11 +77,6 @@
|
||||
"backToSignin": "Vissza a belépéshez",
|
||||
"or": "vagy",
|
||||
"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.",
|
||||
"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",
|
||||
|
||||
@@ -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", () => {
|
||||
// `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.
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface Me {
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
/** The latest sign-in on any device (ISO), or null when none is recorded. */
|
||||
last_login_at: string | null;
|
||||
has_google: boolean;
|
||||
@@ -40,10 +39,9 @@ export interface Me {
|
||||
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`
|
||||
* row), so it comes first. */
|
||||
export function roleKey(a: Pick<Me, "is_demo" | "role">): "admin" | "user" | "demo" {
|
||||
return a.is_demo ? "demo" : a.role === "admin" ? "admin" : "user";
|
||||
/** The `nav.role.*` key an account is shown under. */
|
||||
export function roleKey(a: Pick<Me, "role">): "admin" | "user" {
|
||||
return a.role === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
/** What an account is called on screen: its display name, else the local part of its email. */
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// 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
|
||||
// whitelist, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
|
||||
// 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
|
||||
// module as the cohesive "admin" domain.)
|
||||
import type { Level } from "../log";
|
||||
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 {
|
||||
id: number;
|
||||
email: string;
|
||||
@@ -78,7 +69,6 @@ export interface AdminUserRow {
|
||||
is_active: boolean;
|
||||
is_suspended: boolean;
|
||||
email_verified: boolean;
|
||||
is_demo: boolean;
|
||||
has_password: boolean;
|
||||
/** 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. */
|
||||
@@ -87,7 +77,7 @@ export interface AdminUserRow {
|
||||
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
|
||||
google_email: 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;
|
||||
}
|
||||
|
||||
@@ -288,13 +278,6 @@ export const adminApi = {
|
||||
method: "PATCH",
|
||||
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 ---
|
||||
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
|
||||
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user