Merge feature/demo-account: shared demo account (whitelist login, gating, admin reset)
This commit is contained in:
commit
5b6d898d31
18 changed files with 647 additions and 74 deletions
59
backend/alembic/versions/0014_demo_account.py
Normal file
59
backend/alembic/versions/0014_demo_account.py
Normal 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")
|
||||||
|
|
@ -11,11 +11,21 @@ from sqlalchemy.orm import Session
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
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
|
from app.security import encrypt
|
||||||
|
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
_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.
|
# How many recently-used accounts to keep switchable in one browser session.
|
||||||
MAX_SESSION_ACCOUNTS = 6
|
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
|
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:
|
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
"""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."""
|
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"}
|
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:
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
user_id = request.session.get("user_id")
|
user_id = request.session.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|
@ -241,9 +308,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
return 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")
|
@router.get("/upgrade")
|
||||||
async def 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
|
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||||
read-only (enough to build the feed); `access=write` additionally grants management
|
read-only (enough to build the feed); `access=write` additionally grants management
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ class User(Base):
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
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…).
|
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
||||||
preferences: Mapped[dict | None] = mapped_column(JSON)
|
preferences: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|
@ -81,6 +87,22 @@ class Invite(Base):
|
||||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
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):
|
class Channel(Base):
|
||||||
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
||||||
for everyone), so its expensive metadata is fetched and stored only once."""
|
for everyone), so its expensive metadata is fetched and stored only once."""
|
||||||
|
|
|
||||||
43
backend/app/ratelimit.py
Normal file
43
backend/app/ratelimit.py
Normal 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
|
||||||
|
|
@ -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 datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
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.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"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
def admin_user(user: User = Depends(current_user)) -> User:
|
def admin_user(user: User = Depends(current_user)) -> User:
|
||||||
if user.role != "admin":
|
if user.role != "admin":
|
||||||
|
|
@ -98,3 +110,117 @@ def add_invite(
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize(inv)
|
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}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ def get_me(
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"avatar_url": user.avatar_url,
|
"avatar_url": user.avatar_url,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
|
"is_demo": user.is_demo,
|
||||||
"can_read": has_read_scope(user),
|
"can_read": has_read_scope(user),
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
"pending_invites": pending_invites,
|
"pending_invites": pending_invites,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota, state
|
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.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
from app.sync.runner import (
|
from app.sync.runner import (
|
||||||
|
|
@ -31,7 +31,7 @@ def _user_channels(db: Session, user: User) -> list[Channel]:
|
||||||
|
|
||||||
@router.post("/subscriptions")
|
@router.post("/subscriptions")
|
||||||
def sync_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:
|
) -> dict:
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||||
|
|
@ -49,7 +49,7 @@ def sync_subscriptions(
|
||||||
|
|
||||||
@router.post("/rss")
|
@router.post("/rss")
|
||||||
def sync_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:
|
) -> dict:
|
||||||
new = run_rss_poll(db, _user_channels(db, user))
|
new = run_rss_poll(db, _user_channels(db, user))
|
||||||
return {"new_videos": new}
|
return {"new_videos": new}
|
||||||
|
|
@ -57,7 +57,7 @@ def sync_rss(
|
||||||
|
|
||||||
@router.post("/backfill")
|
@router.post("/backfill")
|
||||||
def sync_backfill(
|
def sync_backfill(
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
max_channels: int = 25,
|
max_channels: int = 25,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -70,7 +70,7 @@ def sync_backfill(
|
||||||
|
|
||||||
@router.post("/enrich")
|
@router.post("/enrich")
|
||||||
def sync_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:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
before = quota.units_used_today(db)
|
||||||
with quota.attribute(user.id, "enrich"):
|
with quota.attribute(user.id, "enrich"):
|
||||||
|
|
@ -175,7 +175,7 @@ def my_status(
|
||||||
|
|
||||||
@router.post("/deep-all")
|
@router.post("/deep-all")
|
||||||
def deep_all(
|
def deep_all(
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
on: bool = True,
|
on: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,17 @@ export default function App() {
|
||||||
message: t("common.accessRequestsMessage", { count: pending }),
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { setLanguage, type LangCode } from "../i18n";
|
import { setLanguage, type LangCode } from "../i18n";
|
||||||
|
|
@ -6,6 +6,8 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
|
|
||||||
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -18,6 +20,28 @@ export default function Login() {
|
||||||
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
||||||
const [error, setError] = useState("");
|
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) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
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 { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Invite, type Me } from "../lib/api";
|
import { api, type Invite, type Me } from "../lib/api";
|
||||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
|
|
@ -397,28 +398,30 @@ function Sync({ me }: { me: Me }) {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("settings.sync.actions")}>
|
{!me.is_demo && (
|
||||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
<Section title={t("settings.sync.actions")}>
|
||||||
<button
|
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||||
onClick={() => syncSubs.mutate()}
|
<button
|
||||||
disabled={syncSubs.isPending}
|
onClick={() => syncSubs.mutate()}
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
disabled={syncSubs.isPending}
|
||||||
>
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
>
|
||||||
{t("settings.sync.syncSubscriptions")}
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
</button>
|
{t("settings.sync.syncSubscriptions")}
|
||||||
</Tooltip>
|
</button>
|
||||||
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
</Tooltip>
|
||||||
<button
|
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
||||||
onClick={() => deepAll.mutate()}
|
<button
|
||||||
disabled={deepAll.isPending}
|
onClick={() => deepAll.mutate()}
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
disabled={deepAll.isPending}
|
||||||
>
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
>
|
||||||
{t("settings.sync.backfillEverything")}
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||||
</button>
|
{t("settings.sync.backfillEverything")}
|
||||||
</Tooltip>
|
</button>
|
||||||
</Section>
|
</Tooltip>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{me.role === "admin" && s && (
|
{me.role === "admin" && s && (
|
||||||
<Section title={t("settings.sync.admin")}>
|
<Section title={t("settings.sync.admin")}>
|
||||||
|
|
@ -498,38 +501,47 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("settings.account.youtubeAccess")}>
|
{me.is_demo ? (
|
||||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
{t("settings.account.youtubeAccessIntro")}
|
<p className="text-xs text-muted leading-relaxed">
|
||||||
</p>
|
{t("settings.account.demoNotice")}
|
||||||
<div className="divide-y divide-border">
|
</p>
|
||||||
<AccessRow
|
</Section>
|
||||||
title={t("settings.account.readTitle")}
|
) : (
|
||||||
granted={me.can_read}
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
grantedHint={t("settings.account.readGrantedHint")}
|
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||||
enableHint={t("settings.account.readEnableHint")}
|
{t("settings.account.youtubeAccessIntro")}
|
||||||
onEnable={() => {
|
</p>
|
||||||
window.location.href = "/auth/upgrade?access=read";
|
<div className="divide-y divide-border">
|
||||||
}}
|
<AccessRow
|
||||||
/>
|
title={t("settings.account.readTitle")}
|
||||||
<AccessRow
|
granted={me.can_read}
|
||||||
title={t("settings.account.writeTitle")}
|
grantedHint={t("settings.account.readGrantedHint")}
|
||||||
granted={me.can_write}
|
enableHint={t("settings.account.readEnableHint")}
|
||||||
grantedHint={t("settings.account.writeGrantedHint")}
|
onEnable={() => {
|
||||||
enableHint={t("settings.account.writeEnableHint")}
|
window.location.href = "/auth/upgrade?access=read";
|
||||||
onEnable={() => {
|
}}
|
||||||
window.location.href = "/auth/upgrade?access=write";
|
/>
|
||||||
}}
|
<AccessRow
|
||||||
/>
|
title={t("settings.account.writeTitle")}
|
||||||
</div>
|
granted={me.can_write}
|
||||||
<button
|
grantedHint={t("settings.account.writeGrantedHint")}
|
||||||
onClick={onOpenWizard}
|
enableHint={t("settings.account.writeEnableHint")}
|
||||||
className="mt-2 text-sm text-accent hover:underline"
|
onEnable={() => {
|
||||||
>
|
window.location.href = "/auth/upgrade?access=write";
|
||||||
{t("settings.account.walkMeThrough")}
|
}}
|
||||||
</button>
|
/>
|
||||||
</Section>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onOpenWizard}
|
||||||
|
className="mt-2 text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t("settings.account.walkMeThrough")}
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
{me.role === "admin" && <AdminInvites />}
|
{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({
|
function InviteRow({
|
||||||
inv,
|
inv,
|
||||||
onApprove,
|
onApprove,
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"somethingWrong": "Etwas ist schiefgelaufen.",
|
"somethingWrong": "Etwas ist schiefgelaufen.",
|
||||||
"accessRequestsTitle": "Zugangsanfragen",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,26 @@
|
||||||
"granted": "Erteilt",
|
"granted": "Erteilt",
|
||||||
"enable": "Aktivieren",
|
"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.",
|
"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": {
|
"invites": {
|
||||||
"title": "Zugriffsanfragen",
|
"title": "Zugriffsanfragen",
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"somethingWrong": "Something went wrong.",
|
"somethingWrong": "Something went wrong.",
|
||||||
"accessRequestsTitle": "Access requests",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,26 @@
|
||||||
"granted": "Granted",
|
"granted": "Granted",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
|
"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": {
|
"invites": {
|
||||||
"title": "Access requests",
|
"title": "Access requests",
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
"somethingWrong": "Hiba történt.",
|
"somethingWrong": "Hiba történt.",
|
||||||
"accessRequestsTitle": "Hozzáférési kérések",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,26 @@
|
||||||
"granted": "Megadva",
|
"granted": "Megadva",
|
||||||
"enable": "Engedélyezés",
|
"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.",
|
"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": {
|
"invites": {
|
||||||
"title": "Hozzáférési kérelmek",
|
"title": "Hozzáférési kérelmek",
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,21 @@ export interface Me {
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
is_demo: boolean;
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
pending_invites: number;
|
pending_invites: number;
|
||||||
preferences: Record<string, any>;
|
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 {
|
export interface Invite {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -395,6 +404,18 @@ export const api = {
|
||||||
// --- onboarding / admin ---
|
// --- onboarding / admin ---
|
||||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
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[]> =>
|
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||||
approveInvite: (id: number) =>
|
approveInvite: (id: number) =>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
||||||
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
|
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;
|
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||||
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue