From 9cac2cd335c44027dcd5579a21dbba0e1ca609b8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 09:17:14 +0200 Subject: [PATCH 1/4] feat(demo): demo-account schema + reusable rate limiter Add the shared demo-account plumbing: users.is_demo marks the single shared demo user, demo_whitelist holds the admin-curated emails that may enter it without Google sign-in, and a small in-process RateLimiter (generic groundwork) for throttling the demo-login endpoint per IP. --- backend/alembic/versions/0014_demo_account.py | 59 +++++++++++++++++++ backend/app/models.py | 22 +++++++ backend/app/ratelimit.py | 43 ++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 backend/alembic/versions/0014_demo_account.py create mode 100644 backend/app/ratelimit.py diff --git a/backend/alembic/versions/0014_demo_account.py b/backend/alembic/versions/0014_demo_account.py new file mode 100644 index 0000000..3d9a8f3 --- /dev/null +++ b/backend/alembic/versions/0014_demo_account.py @@ -0,0 +1,59 @@ +"""demo account: users.is_demo + demo_whitelist + +Revision ID: 0014_demo_account +Revises: 0013_playlist_fingerprint +Create Date: 2026-06-16 + +Adds the shared demo-account plumbing: + - users.is_demo — marks the single shared demo user (no OAuth token / YouTube scope). + - demo_whitelist — admin-curated emails that may enter the demo account from the login + page without Google sign-in. Multiple emails are just keys to the same shared door. +The demo user row itself is created lazily on first demo login (works across all three DBs +without a data migration), so this only adds the schema. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0014_demo_account" +down_revision: Union[str, None] = "0013_playlist_fingerprint" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "is_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 + ) + + +def downgrade() -> None: + 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") diff --git a/backend/app/models.py b/backend/app/models.py index cf4d044..55f694c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -28,6 +28,12 @@ class User(Base): display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") + # 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) created_at: Mapped[datetime] = mapped_column( @@ -81,6 +87,22 @@ class Invite(Base): decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email +class DemoWhitelist(Base): + """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 + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + class Channel(Base): """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.""" diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py new file mode 100644 index 0000000..7df0b5f --- /dev/null +++ b/backend/app/ratelimit.py @@ -0,0 +1,43 @@ +"""A tiny in-process sliding-window rate limiter. + +Generic groundwork: keyed by an arbitrary string (e.g. a client IP), so it can throttle any +endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which +is fine for abuse throttling (not for anything that must survive a deploy). Not shared across +processes — if we ever run multiple workers, swap the backing store for Redis behind the same +``allow()`` interface. +""" +import threading +import time + + +class RateLimiter: + def __init__(self, max_events: int, window_seconds: float): + self.max_events = max_events + self.window = window_seconds + self._hits: dict[str, list[float]] = {} + self._lock = threading.Lock() + + def allow(self, key: str) -> bool: + """Record an attempt for ``key`` and return whether it is within the limit. + + True -> under the cap (the attempt is counted). + False -> the cap for the current window is already reached (attempt NOT counted, so a + blocked caller can't keep pushing the window forward).""" + now = time.monotonic() + cutoff = now - self.window + with self._lock: + hits = [t for t in self._hits.get(key, []) if t > cutoff] + if len(hits) >= self.max_events: + self._hits[key] = hits + return False + hits.append(now) + self._hits[key] = hits + # Opportunistic cleanup so abandoned keys don't accumulate unboundedly. + if len(self._hits) > 4096: + for k in list(self._hits): + fresh = [t for t in self._hits[k] if t > cutoff] + if fresh: + self._hits[k] = fresh + else: + del self._hits[k] + return True From 5936436d26998ece90c3056d4c6d8b049acf3361 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 09:17:21 +0200 Subject: [PATCH 2/4] feat(demo): hidden /auth/demo login + require_human guard Whitelisted emails enter the shared demo user via /auth/demo (lazily created, no OAuth token/scope), rate-limited per IP and answering uniformly so it can't be hammered as an enumeration oracle. Add a require_human dependency that blocks the demo account from quota-spending sync endpoints and the OAuth upgrade flow, and surface is_demo on /api/me. --- backend/app/auth.py | 81 +++++++++++++++++++++++++++++++++++++- backend/app/routes/me.py | 1 + backend/app/routes/sync.py | 12 +++--- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 685a211..b4fe775 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -11,11 +11,21 @@ from sqlalchemy.orm import Session from app import email as email_mod from app.config import settings from app.db import get_db -from app.models import Invite, OAuthToken, User +from app.models import DemoWhitelist, Invite, OAuthToken, User +from app.ratelimit import RateLimiter from app.security import encrypt _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +# The single shared demo account's stable identity. The row is created lazily on first +# demo login (see get_or_create_demo_user); these are just its sentinel keys. +DEMO_GOOGLE_SUB = "__demo__" +DEMO_EMAIL = "demo@siftlode.local" + +# Throttle the hidden demo-login endpoint per client IP: enough for a real paste-and-wait, +# stingy enough that the field can't be hammered as an enumeration oracle or for DoS. +_demo_limiter = RateLimiter(max_events=5, window_seconds=60) + # How many recently-used accounts to keep switchable in one browser session. MAX_SESSION_ACCOUNTS = 6 @@ -77,6 +87,45 @@ def is_allowed(db: Session, email: str) -> bool: return email in settings.allowed_email_set or email in settings.admin_email_set +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 + ) + + +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 _client_ip(request: Request) -> str: + """Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the + real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise.""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + def upsert_pending_invite(db: Session, email: str) -> Invite | None: """Idempotently record an access request. Returns the Invite if a *new* pending row was created (so the caller can notify admins), else None for already-known emails.""" @@ -224,6 +273,24 @@ async def request_access( return {"status": "pending"} +@router.post("/demo") +def demo_login(payload: dict, 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_ip(request)): + return {"authenticated": False} + email = (payload.get("email") or "").strip().lower() + if _EMAIL_RE.match(email) and is_demo_allowed(db, email): + demo = get_or_create_demo_user(db) + request.session["user_id"] = demo.id + log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) + return {"authenticated": True} + return {"authenticated": False} + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id = request.session.get("user_id") if not user_id: @@ -241,9 +308,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: return user +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: + raise HTTPException(status_code=403, detail="Not available in the demo account.") + return user + + @router.get("/upgrade") async def upgrade( - request: Request, access: str = "read", user: User = Depends(current_user) + request: Request, access: str = "read", user: User = Depends(require_human) ): """Incremental consent for the onboarding wizard. `access=read` grants YouTube read-only (enough to build the feed); `access=write` additionally grants management diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 40fc3f3..df9e6a5 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -77,6 +77,7 @@ def get_me( "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, + "is_demo": user.is_demo, "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 2af709e..8791aed 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session from app import quota, state -from app.auth import current_user, has_read_scope +from app.auth import current_user, has_read_scope, require_human from app.db import get_db from app.models import Channel, Subscription, User, Video from app.sync.runner import ( @@ -31,7 +31,7 @@ def _user_channels(db: Session, user: User) -> list[Channel]: @router.post("/subscriptions") def sync_subscriptions( - user: User = Depends(current_user), db: Session = Depends(get_db) + user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: if not has_read_scope(user): # No YouTube read grant yet — the onboarding wizard hasn't been completed. @@ -49,7 +49,7 @@ def sync_subscriptions( @router.post("/rss") def sync_rss( - user: User = Depends(current_user), db: Session = Depends(get_db) + user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: new = run_rss_poll(db, _user_channels(db, user)) return {"new_videos": new} @@ -57,7 +57,7 @@ def sync_rss( @router.post("/backfill") def sync_backfill( - user: User = Depends(current_user), + user: User = Depends(require_human), db: Session = Depends(get_db), max_channels: int = 25, ) -> dict: @@ -70,7 +70,7 @@ def sync_backfill( @router.post("/enrich") def sync_enrich( - user: User = Depends(current_user), db: Session = Depends(get_db) + user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: before = quota.units_used_today(db) with quota.attribute(user.id, "enrich"): @@ -175,7 +175,7 @@ def my_status( @router.post("/deep-all") def deep_all( - user: User = Depends(current_user), + user: User = Depends(require_human), db: Session = Depends(get_db), on: bool = True, ) -> dict: From e0c63c26d4b8fdc8340412f0cda4597c2d2a6d4a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 09:17:27 +0200 Subject: [PATCH 3/4] feat(demo): admin demo whitelist CRUD + state reset Admin endpoints to manage the demo email whitelist (DB-backed, no env) and a manual reset that wipes the demo account's per-user state and re-seeds a few sample playlists from the shared catalog. --- backend/app/routes/admin.py | 134 ++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c544f4b..545a1ce 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1,17 +1,29 @@ -"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.""" +"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add. +Also the demo-account admin surface: the demo email whitelist + a manual state reset.""" +import re from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app import email as email_mod -from app.auth import current_user +from app.auth import current_user, get_or_create_demo_user from app.db import get_db -from app.models import Invite, User +from app.models import ( + DemoWhitelist, + Invite, + Playlist, + PlaylistItem, + User, + Video, + VideoState, +) router = APIRouter(prefix="/api/admin", tags=["admin"]) +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + def admin_user(user: User = Depends(current_user)) -> User: if user.role != "admin": @@ -98,3 +110,117 @@ def add_invite( inv.decided_by = admin.email db.commit() return _serialize(inv) + + +# --- Demo account: email whitelist + state reset ------------------------------------- + +def _serialize_demo(row: DemoWhitelist) -> dict: + return { + "id": row.id, + "email": row.email, + "note": row.note, + "added_by": row.added_by, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +@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] + + +@router.post("/demo/whitelist") +def add_demo_whitelist( + payload: dict, + 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 = (payload.get("email") or "").strip().lower() + if not _EMAIL_RE.match(email): + raise HTTPException(status_code=400, detail="Enter a valid email address.") + row = db.execute( + select(DemoWhitelist).where(DemoWhitelist.email == email) + ).scalar_one_or_none() + if row is None: + row = DemoWhitelist( + email=email, + note=(payload.get("note") or "").strip() or None, + added_by=admin.email, + ) + db.add(row) + db.commit() + return _serialize_demo(row) + + +@router.delete("/demo/whitelist/{entry_id}") +def remove_demo_whitelist( + entry_id: int, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + row = db.get(DemoWhitelist, entry_id) + if row is not None: + db.delete(row) + db.commit() + return {"deleted": entry_id} + + +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 + + +@router.post("/demo/reset") +def reset_demo( + _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + """Wipe the shared demo account's per-user state (watch/save/hide states, playlists, + preferences) back to a clean baseline and re-seed a few sample playlists. There's no + automatic reset — this is the admin's manual 'clean up the sandbox' button.""" + demo = get_or_create_demo_user(db) + 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) + db.commit() + return {"reset": True, "playlists_seeded": seeded} From 64053501048a5f0037f5da441ea412b45076df04 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 09:17:34 +0200 Subject: [PATCH 4/4] feat(demo): login trigger, feature gating + admin UI (HU/EN/DE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login page quietly probes /auth/demo (debounced) as a valid email is typed/pasted and reloads into the app on a match — no visible button. Demo sessions default to the whole library, get a one-time shared-account warning, never see the YouTube-connect onboarding/access UI or sync actions, and admins get a demo whitelist + reset panel in Settings. --- frontend/src/App.tsx | 11 + frontend/src/components/Login.tsx | 26 ++- frontend/src/components/SettingsPanel.tsx | 232 ++++++++++++++++----- frontend/src/i18n/locales/de/common.json | 4 +- frontend/src/i18n/locales/de/settings.json | 21 +- frontend/src/i18n/locales/en/common.json | 4 +- frontend/src/i18n/locales/en/settings.json | 21 +- frontend/src/i18n/locales/hu/common.json | 4 +- frontend/src/i18n/locales/hu/settings.json | 21 +- frontend/src/lib/api.ts | 21 ++ frontend/src/lib/onboarding.ts | 4 +- 11 files changed, 307 insertions(+), 62 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d74470c..91fdfee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -189,6 +189,17 @@ export default function App() { message: t("common.accessRequestsMessage", { count: pending }), }); } + // The demo account is shared: there are no subscriptions, so default it to the whole + // library (the "my" feed would be empty), and warn that its state is communal. + if (meQuery.data?.is_demo) { + setFilters({ ...loadStoredFilters(), scope: "all" }); + notify({ + level: "warning", + title: t("common.demoTitle"), + message: t("common.demoSharedNotice"), + requiresInteraction: true, + }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 71bee93..b139df5 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../lib/api"; import { setLanguage, type LangCode } from "../i18n"; @@ -6,6 +6,8 @@ import LanguageSwitcher from "./LanguageSwitcher"; type Phase = "idle" | "sending" | "requested" | "approved" | "error"; +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + export default function Login() { const { t, i18n } = useTranslation(); @@ -18,6 +20,28 @@ export default function Login() { const [phase, setPhase] = useState(bounced === "requested" ? "requested" : "idle"); const [error, setError] = useState(""); + // Hidden demo entry: a whitelisted email signs straight into the shared demo account, with + // no button to give it away. We watch the field and — once it holds a syntactically valid + // email and typing has settled — quietly probe /auth/demo. A match sets the session, so we + // reload into the app; a non-match does nothing visible (the normal "request access" flow + // stays available). The server is rate-limited and answers uniformly, so this can't be + // hammered as an oracle. Skipped while the request-access form is mid-submit/done. + useEffect(() => { + const candidate = email.trim().toLowerCase(); + const settled = phase === "requested" || phase === "approved"; + if (phase === "sending" || settled || !EMAIL_RE.test(candidate)) return; + const timer = setTimeout(() => { + api + .demoLogin(candidate) + .then((r) => { + if (r.authenticated) window.location.reload(); + }) + .catch(() => {}); + }, 700); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [email, phase]); + async function submit(e: React.FormEvent) { e.preventDefault(); setError(""); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 1867af2..653dc2b 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react"; +import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Invite, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; @@ -14,6 +14,7 @@ import { } from "../lib/notifications"; import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; import Tooltip from "./Tooltip"; +import { useConfirm } from "./ConfirmProvider"; type TabId = "appearance" | "notifications" | "sync" | "account"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ @@ -397,28 +398,30 @@ function Sync({ me }: { me: Me }) { )} -
- - - - - - -
+ {!me.is_demo && ( +
+ + + + + + +
+ )} {me.role === "admin" && s && (
@@ -498,38 +501,47 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
-
-

- {t("settings.account.youtubeAccessIntro")} -

-
- { - window.location.href = "/auth/upgrade?access=read"; - }} - /> - { - window.location.href = "/auth/upgrade?access=write"; - }} - /> -
- -
+ {me.is_demo ? ( +
+

+ {t("settings.account.demoNotice")} +

+
+ ) : ( +
+

+ {t("settings.account.youtubeAccessIntro")} +

+
+ { + window.location.href = "/auth/upgrade?access=read"; + }} + /> + { + window.location.href = "/auth/upgrade?access=write"; + }} + /> +
+ +
+ )} {me.role === "admin" && } + {me.role === "admin" && } ); } @@ -631,6 +643,118 @@ function AdminInvites() { ); } +function AdminDemo() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() }); + const [newEmail, setNewEmail] = useState(""); + + const add = useMutation({ + mutationFn: (email: string) => api.addDemoWhitelist(email), + onSuccess: () => { + setNewEmail(""); + qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); + notify({ level: "success", message: t("settings.demo.added") }); + }, + onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), + }); + const remove = useMutation({ + mutationFn: (id: number) => api.removeDemoWhitelist(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }), + onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), + }); + const reset = useMutation({ + mutationFn: () => api.resetDemo(), + onSuccess: (r: { playlists_seeded: number }) => + notify({ + level: "success", + message: t("settings.demo.resetDone", { count: r.playlists_seeded }), + }), + onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }), + }); + + const rows = list.data ?? []; + + return ( +
+

{t("settings.demo.intro")}

+ + {rows.length > 0 && ( +
+ {rows.map((r) => ( +
+
+
{r.email}
+ {r.added_by && ( +
+ {t("settings.demo.addedBy", { who: r.added_by })} +
+ )} +
+ + + +
+ ))} +
+ )} + +
{ + e.preventDefault(); + if (newEmail.trim()) add.mutate(newEmail.trim()); + }} + className="flex items-center gap-2" + > + setNewEmail(e.target.value)} + placeholder={t("settings.demo.addPlaceholder")} + className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + +
+ +
+ + + +
+
+ ); +} + function InviteRow({ inv, onApprove, diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index 8ae17fd..70a8846 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -12,5 +12,7 @@ "language": "Sprache", "somethingWrong": "Etwas ist schiefgelaufen.", "accessRequestsTitle": "Zugangsanfragen", - "accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto." + "accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto.", + "demoTitle": "Demo-Konto", + "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden." } diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index ab9ce5d..26230d7 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -79,7 +79,26 @@ "granted": "Erteilt", "enable": "Aktivieren", "enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.", - "walkMeThrough": "Führe mich durch die Einrichtung" + "walkMeThrough": "Führe mich durch die Einrichtung", + "demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto." + }, + "demo": { + "title": "Demo-Zugang", + "intro": "E-Mails hier können das gemeinsame Demo-Konto direkt von der Anmeldeseite aus betreten — ohne Google-Anmeldung. Sie tippen die Adresse einfach ins Feld und werden nach einem Moment eingelassen.", + "addPlaceholder": "E-Mail für die Demo auf die Whitelist…", + "add": "Hinzufügen", + "added": "Zur Demo-Whitelist hinzugefügt", + "addFailed": "Diese E-Mail konnte nicht hinzugefügt werden", + "removeFailed": "Diese E-Mail konnte nicht entfernt werden", + "remove": "Entfernen", + "removeHint": "Diese E-Mail von der Demo-Whitelist entfernen.", + "addedBy": "hinzugefügt von {{who}}", + "reset": "Demo-Zustand zurücksetzen", + "resetHint": "Löscht die Playlists, ausgeblendeten/angesehenen/gespeicherten Videos und Einstellungen des Demo-Kontos und legt dann ein paar Beispiel-Playlists neu an.", + "resetTitle": "Demo-Zustand zurücksetzen?", + "resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.", + "resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt", + "resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden" }, "invites": { "title": "Zugriffsanfragen", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 228f33a..e282281 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -12,5 +12,7 @@ "language": "Language", "somethingWrong": "Something went wrong.", "accessRequestsTitle": "Access requests", - "accessRequestsMessage": "{{count}} pending — review in Settings → Account." + "accessRequestsMessage": "{{count}} pending — review in Settings → Account.", + "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." } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 0686d64..0b39c94 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -79,7 +79,26 @@ "granted": "Granted", "enable": "Enable", "enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.", - "walkMeThrough": "Walk me through setup" + "walkMeThrough": "Walk me through setup", + "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." + }, + "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": "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": "Demo reset — {{count}} sample playlist(s) seeded", + "resetFailed": "Couldn't reset the demo account" }, "invites": { "title": "Access requests", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index d010adc..1389955 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -12,5 +12,7 @@ "language": "Nyelv", "somethingWrong": "Hiba történt.", "accessRequestsTitle": "Hozzáférési kérések", - "accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben." + "accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben.", + "demoTitle": "Demo fiók", + "demoSharedNotice": "Egy közös demo 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 demo látogatók egyszerre módosíthatják." } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 54afd23..f58012b 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -79,7 +79,26 @@ "granted": "Megadva", "enable": "Engedélyezés", "enableHint": "Átirányít a Google-höz az engedély megadásához. A Google „nem ellenőrzött alkalmazás” figyelmeztetést mutat — ez normális; kattints a Speciális → Tovább a Siftlode-ra lehetőségre.", - "walkMeThrough": "Vezess végig a beállításon" + "walkMeThrough": "Vezess végig a beállításon", + "demoNotice": "Ez a közös demo 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." + }, + "demo": { + "title": "Demo hozzáférés", + "intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo 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": "Hozzáadva a demo 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 demo fehérlistáról.", + "addedBy": "hozzáadta: {{who}}", + "reset": "Demo állapot visszaállítása", + "resetHint": "Törli a demo 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 demo állapotot?", + "resetConfirm": "Ez törli mindazt, amit a demo 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": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva", + "resetFailed": "Nem sikerült visszaállítani a demo fiókot" }, "invites": { "title": "Hozzáférési kérelmek", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 98d8ed9..647102a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,12 +6,21 @@ export interface Me { display_name: string | null; avatar_url: string | null; role: string; + is_demo: boolean; can_read: boolean; can_write: boolean; pending_invites: number; preferences: Record; } +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; @@ -395,6 +404,18 @@ export const api = { // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }), + // Hidden demo entry: a whitelisted email logs into the shared demo account, no Google + // sign-in. Returns whether a session was established. + demoLogin: (email: string): Promise<{ authenticated: boolean }> => + req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }), + adminDemoWhitelist: (): Promise => + req("/api/admin/demo/whitelist"), + addDemoWhitelist: (email: string): Promise => + 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" }), adminInvites: (status?: string): Promise => req(`/api/admin/invites${status ? `?status=${status}` : ""}`), approveInvite: (id: number) => diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts index b706274..9575b3f 100644 --- a/frontend/src/lib/onboarding.ts +++ b/frontend/src/lib/onboarding.ts @@ -15,7 +15,9 @@ export const ONBOARD_ACTIVE = "subfeed.onboarding.active"; export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed"; -export function shouldAutoOpenOnboarding(me: { can_read: boolean }): boolean { +export function shouldAutoOpenOnboarding(me: { can_read: boolean; is_demo?: boolean }): boolean { + // The shared demo account can never connect YouTube, so never nudge it to. + if (me.is_demo) return false; if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true; return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED); }