Merge feature/demo-account: shared demo account (whitelist login, gating, admin reset)

This commit is contained in:
npeter83 2026-06-16 09:17:53 +02:00
commit 5b6d898d31
18 changed files with 647 additions and 74 deletions

View file

@ -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")

View file

@ -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

View file

@ -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."""

43
backend/app/ratelimit.py Normal file
View file

@ -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

View file

@ -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}

View file

@ -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,

View file

@ -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:

View file

@ -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]);

View file

@ -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<Phase>(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("");

View file

@ -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,6 +398,7 @@ function Sync({ me }: { me: Me }) {
)}
</Section>
{!me.is_demo && (
<Section title={t("settings.sync.actions")}>
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
<button
@ -419,6 +421,7 @@ function Sync({ me }: { me: Me }) {
</button>
</Tooltip>
</Section>
)}
{me.role === "admin" && s && (
<Section title={t("settings.sync.admin")}>
@ -498,6 +501,13 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div>
</Section>
{me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">
{t("settings.account.demoNotice")}
</p>
</Section>
) : (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
{t("settings.account.youtubeAccessIntro")}
@ -529,7 +539,9 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
{t("settings.account.walkMeThrough")}
</button>
</Section>
)}
{me.role === "admin" && <AdminInvites />}
{me.role === "admin" && <AdminDemo />}
</>
);
}
@ -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 (
<Section 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="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
<div className="text-[11px] text-muted truncate">
{t("settings.demo.addedBy", { who: r.added_by })}
</div>
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
onClick={() => remove.mutate(r.id)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
</div>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2"
>
<input
type="email"
value={newEmail}
onChange={(e) => 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"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<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
onClick={async () => {
if (
await confirm({
title: t("settings.demo.resetTitle"),
message: t("settings.demo.resetConfirm"),
confirmLabel: t("settings.demo.reset"),
danger: true,
})
)
reset.mutate();
}}
disabled={reset.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
{t("settings.demo.reset")}
</button>
</Tooltip>
</div>
</Section>
);
}
function InviteRow({
inv,
onApprove,

View file

@ -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."
}

View file

@ -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",

View file

@ -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."
}

View file

@ -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",

View file

@ -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."
}

View file

@ -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",

View file

@ -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<string, any>;
}
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<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" }),
adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) =>

View file

@ -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);
}