Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-19 19:54:26 +02:00
commit 1b1d11079e
83 changed files with 4216 additions and 845 deletions

View file

@ -1 +1 @@
0.11.2 0.13.0

View file

@ -0,0 +1,51 @@
"""rename quota_events.action to the canonical <entity>_<action> taxonomy
Revision ID: 0020_rename_quota_actions
Revises: 0019_user_yt_channel_id
Create Date: 2026-06-19
The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names). Rename the
stored historical values to the canonical scheme defined in app.quota.QuotaAction:
``<entity>_<action>``, snake_case, explicit pull/push. Pure data migration on quota_events;
fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
are NOT touched.)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0020_rename_quota_actions"
down_revision: Union[str, None] = "0019_user_yt_channel_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# old action value -> new canonical key (mirrors app.quota.QuotaAction).
RENAMES: list[tuple[str, str]] = [
("sync_subscriptions", "subscriptions_pull"),
("subscription_resync", "subscriptions_resync"),
("playlist_sync", "playlists_pull"),
("playlist_push", "playlists_push"),
("playlist_delete", "playlists_delete"),
("backfill_recent", "videos_backfill_recent"),
("backfill_deep", "videos_backfill_full"),
("enrich", "videos_enrich"),
("video_lookup", "videos_lookup"),
("discovery", "channels_discover"),
("subscribe", "channels_subscribe"),
("unsubscribe", "channels_unsubscribe"),
("maintenance", "maintenance_revalidate"),
("api", "other"),
]
_SQL = sa.text("UPDATE quota_events SET action = :to WHERE action = :frm")
def upgrade() -> None:
for frm, to in RENAMES:
op.execute(_SQL.bindparams(frm=frm, to=to))
def downgrade() -> None:
for frm, to in RENAMES:
op.execute(_SQL.bindparams(frm=to, to=frm))

View file

@ -0,0 +1,38 @@
"""generic admin-editable system_config key/value store
Revision ID: 0021_system_config
Revises: 0020_rename_quota_actions
Create Date: 2026-06-19
Adds the system_config table: admin overrides for operational config that otherwise comes
from env/config defaults (see app.sysconfig for the registry of known keys). Secret values
are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0021_system_config"
down_revision: Union[str, None] = "0020_rename_quota_actions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"system_config",
sa.Column("key", sa.String(length=64), primary_key=True),
sa.Column("value", sa.Text(), nullable=True),
sa.Column("encrypted", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("system_config")

View file

@ -0,0 +1,63 @@
"""email+password auth foundations
Revision ID: 0022_password_auth
Revises: 0021_system_config
Create Date: 2026-06-19
Adds password-auth columns to users (password_hash, email_verified, is_active), makes
google_sub nullable (password-only accounts have no Google sub until they link), and a
single-use auth_tokens table for email verification + password reset. Existing rows are all
Google/demo accounts backfilled email_verified=true (is_active defaults true).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0022_password_auth"
down_revision: Union[str, None] = "0021_system_config"
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("password_hash", sa.String(length=255), nullable=True))
op.add_column(
"users",
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"),
)
op.add_column(
"users",
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
)
# Existing accounts are all Google/demo — their email is already proven.
op.execute("UPDATE users SET email_verified = true")
# Password-only accounts have no Google sub until they link one.
op.alter_column("users", "google_sub", existing_type=sa.String(length=64), nullable=True)
op.create_table(
"auth_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
def downgrade() -> None:
op.drop_table("auth_tokens")
op.alter_column("users", "google_sub", existing_type=sa.String(length=64), nullable=False)
op.drop_column("users", "is_active")
op.drop_column("users", "email_verified")
op.drop_column("users", "password_hash")

View file

@ -0,0 +1,30 @@
"""admin account suspension
Revision ID: 0023_user_suspension
Revises: 0022_password_auth
Create Date: 2026-06-19
Adds users.is_suspended an admin-controlled access block, distinct from is_active (the
approval lifecycle). A suspended account can't sign in (password or Google) and any live
session is rejected; the user is told why and given the operator's contact.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0023_user_suspension"
down_revision: Union[str, None] = "0022_password_auth"
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_suspended", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("users", "is_suspended")

View file

@ -0,0 +1,31 @@
"""backfill email_verified for Google accounts
Revision ID: 0024_google_email_verified
Revises: 0023_user_suspension
Create Date: 2026-06-19
A Google sign-in proves the email, but earlier Google signups were created with
email_verified=false (the callback didn't set it). Backfill those so the flag reflects reality —
matters because password-login gates on email_verified, so a Google user who later adds a password
must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0024_google_email_verified"
down_revision: Union[str, None] = "0023_user_suspension"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE users SET email_verified = true "
"WHERE google_sub IS NOT NULL AND email_verified = false"
)
def downgrade() -> None:
# Not reversible — we can't tell which rows were flipped. No-op.
pass

View file

@ -1,19 +1,47 @@
import hashlib
import logging import logging
import re import re
from datetime import datetime, timezone import secrets
from datetime import datetime, timedelta, timezone
import httpx
from authlib.integrations.starlette_client import OAuth, OAuthError from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
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 import sysconfig
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 DemoWhitelist, Invite, OAuthToken, User from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter from app.ratelimit import RateLimiter
from app.security import encrypt from app.security import decrypt, encrypt, hash_password, verify_password
# Email+password auth tuning.
PASSWORD_MIN_LEN = 10
VERIFY_TTL = timedelta(hours=24)
RESET_TTL = timedelta(hours=1)
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
# At most one "your account is suspended" email per address per hour, so a suspended user who
# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them.
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
def operator_contact() -> str | None:
"""Who a blocked user should contact — the first configured admin email, if any."""
return next(iter(sorted(settings.admin_email_set)), None)
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
triggered by someone who doesn't control the account."""
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@ -179,7 +207,16 @@ async def callback(
if not userinfo or not userinfo.get("sub"): if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google") raise HTTPException(status_code=400, detail="No user info returned by Google")
sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower() email = (userinfo.get("email") or "").lower()
# Linking mode: an already-authenticated account is attaching this Google identity (a
# password account enabling YouTube or connecting SSO). The marker is set by /auth/link and
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
link_uid = request.session.pop("oauth_link_uid", None)
if link_uid is not None:
return _complete_link(request, db, link_uid, sub, userinfo, token)
if not is_allowed(db, email): if not is_allowed(db, email):
log.warning("Login denied (not approved): %s", email or "<no email>") log.warning("Login denied (not approved): %s", email or "<no email>")
# A denied Google login doubles as an access request: record it for the admin and # A denied Google login doubles as an access request: record it for the admin and
@ -195,14 +232,49 @@ async def callback(
return RedirectResponse(url="/?access=requested") return RedirectResponse(url="/?access=requested")
return RedirectResponse(url="/?access=denied") return RedirectResponse(url="/?access=denied")
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none() user = db.query(User).filter(User.google_sub == sub).one_or_none()
if user is None: if user is None:
user = User(google_sub=userinfo["sub"], email=email) # No account carries this Google identity yet. If one already exists for this email —
# e.g. an email+password registration — adopt the Google identity onto it instead of
# inserting a duplicate (which would collide on the unique email and 500). Google has
# verified the address, so this safely unifies password + Google sign-in on one account.
existing = db.query(User).filter(User.email == email).one_or_none()
if existing is not None:
if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied")
user = existing
user.google_sub = sub
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
user.is_active = True
else:
user = User(google_sub=sub, email=email)
db.add(user) db.add(user)
# A suspended account can't sign in by any method. Block before establishing the session and
# tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended")
user.email = email user.email = email
user.display_name = userinfo.get("name") user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture") user.avatar_url = userinfo.get("picture")
user.role = "admin" if email in settings.admin_email_set else "user" # ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
# default for a brand-new account (its role attribute is still unset before flush).
if email in settings.admin_email_set:
user.role = "admin"
elif not user.role:
user.role = "user"
# Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets
# a Google user who later adds a password sign in (password-login gates on email_verified).
if userinfo.get("email_verified", True):
user.email_verified = True
# Default UI language from the Google-reported locale on first login (if the user hasn't # Default UI language from the Google-reported locale on first login (if the user hasn't
# picked one yet); unsupported locales fall back to English. # picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {}) prefs = dict(user.preferences or {})
@ -212,19 +284,7 @@ async def callback(
user.preferences = prefs user.preferences = prefs
db.flush() db.flush()
tok = user.token or OAuthToken(user=user) _store_token(db, user, token)
# Google only returns a refresh_token on (re)consent; keep the previous one otherwise.
if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"])
tok.access_token = token.get("access_token")
expires_at = token.get("expires_at")
tok.expiry = (
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
)
# include_granted_scopes=true means Google returns the union of all scopes the user
# has ever granted this app, so this correctly reflects read/write upgrades too.
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok)
db.commit() db.commit()
# Multi-session: remember every account that has authenticated in this browser so the # Multi-session: remember every account that has authenticated in this browser so the
@ -238,6 +298,94 @@ async def callback(
return RedirectResponse(url="/") return RedirectResponse(url="/")
def _store_token(db: Session, user: User, token: dict) -> None:
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
include_granted_scopes=true means `scope` is the union of everything granted, so this
reflects read/write upgrades correctly."""
tok = user.token or OAuthToken(user=user)
if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"])
tok.access_token = token.get("access_token")
expires_at = token.get("expires_at")
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok)
def revoke_google_token(token: str | None) -> None:
"""Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh
token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes
silently carried over via include_granted_scopes). Fail-soft: never blocks the caller used
from account deletion, where our own data is already gone regardless of Google's response."""
if not token:
return
try:
resp = httpx.post(
"https://oauth2.googleapis.com/revoke",
data={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
log.info("Google token revoke: status=%s", resp.status_code)
except Exception:
log.warning("Google token revoke failed", exc_info=True)
def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
"""Hard-delete a user and ALL their personal data (subscriptions, tags, video states,
playlists, notifications, tokens all cascade on the users row), erase their access-request
row, and revoke their Google grant. Shared by GDPR self-deletion and admin deletion. Does NOT
touch any browser session each caller handles its own."""
email = user.email.lower()
user_id = user.id
tok = user.token
google_token = (decrypt(tok.refresh_token_enc) or tok.access_token) if tok else None
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id))
db.execute(delete(Invite).where(Invite.email == email))
db.commit()
if google_token:
background.add_task(revoke_google_token, google_token)
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
# self-service and admin deletion paths since both funnel through here.
background.add_task(email_mod.send_account_deleted, email, operator_contact())
def _complete_link(
request: Request, db: Session, link_uid: int, sub: str, userinfo: dict, token: dict
):
"""Attach the just-authorized Google identity to the already-signed-in account that started
the link/upgrade. No is_allowed gate they're already an active user. Refuses to hijack a
Google identity owned by another account, or to silently swap a different linked identity."""
# Consume the companion marker up front so it never lingers across the early returns below.
explicit = request.session.pop("oauth_link_explicit", False)
# The marker must match the live session user (guards a stale/forged marker).
if link_uid != request.session.get("user_id"):
return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid)
if user is None or user.is_demo:
return RedirectResponse(url="/?link=error")
other = (
db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
)
if other is not None:
return RedirectResponse(url="/?link=conflict")
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url:
user.avatar_url = userinfo.get("picture")
_store_token(db, user, token)
db.commit()
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
return RedirectResponse(url="/?link=ok" if explicit else "/")
@router.post("/logout") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser, """Sign out the active account. If other accounts have authenticated in this browser,
@ -291,12 +439,206 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
return {"authenticated": False} return {"authenticated": False}
def _establish_session(request: Request, user: User) -> None:
"""Sign `user` in for this browser session, mirroring the Google callback's multi-account
handling so password sign-in joins the same account switcher."""
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
def _app_base() -> str:
"""Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
return settings.app_base
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
(goes only into the emailed link)."""
raw = secrets.token_urlsafe(32)
db.add(
AuthToken(
user_id=user.id,
kind=kind,
token_hash=_hash_token(raw),
expires_at=datetime.now(timezone.utc) + ttl,
)
)
db.commit()
return raw
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
"""Validate + burn a token. Returns its user, or None if missing/expired/used/wrong kind."""
if not raw:
return None
row = db.execute(
select(AuthToken).where(
AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind
)
).scalar_one_or_none()
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
return None
row.used_at = datetime.now(timezone.utc)
db.commit()
return db.get(User, row.user_id)
@router.post("/register")
def register(
payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Email+password registration. Creates a pending (inactive, unverified) account, sends a
verification link, and records an access request for admin approval. Two gates before
sign-in: verified email AND admin approval. Responses are uniform once input is valid, so
the endpoint can't be used to discover which emails are registered."""
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
# Input validation is safe to reveal (independent of whether the email exists).
if not _EMAIL_RE.match(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if not sysconfig.get_bool(db, "allow_registration"):
raise HTTPException(status_code=403, detail="Registration is currently closed.")
if not _register_limiter.allow(_client_ip(request)):
return {"status": "ok"} # silently throttle; uniform response
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if existing is None:
# Without working SMTP we can't deliver a verification link, so email ownership can't
# gate sign-in. Admin approval stays the real gate (is_active=False); mark the account
# verified so the flow still completes on a no-SMTP self-host. See email.email_enabled().
email_ok = email_mod.email_enabled()
user = User(
email=email,
password_hash=hash_password(password),
is_active=False,
email_verified=not email_ok,
)
db.add(user)
db.flush()
upsert_pending_invite(db, email) # admin-approval gate
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
background.add_task(
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}"
)
if settings.admin_email_set:
background.add_task(
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
)
# Existing email → do nothing visible (no enumeration). Uniform success either way.
return {"status": "ok"}
@router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)):
"""Confirm an email-verification link, then bounce to a friendly status on the app."""
user = _consume_token(db, token, "verify")
if user is None:
return RedirectResponse(url="/?verify=invalid")
user.email_verified = True
db.commit()
return RedirectResponse(url="/?verify=ok")
@router.post("/password-login")
def password_login(
payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration).
Once the password is proven correct, the owner gets a specific reason if their account is
still pending or suspended that's not an enumeration leak (they already hold the password)."""
if not _login_limiter.allow(_client_ip(request)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None or user.is_demo or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid email or password.")
if user.is_suspended:
_notify_suspended(background, user.email)
op = operator_contact()
detail = "Your account has been suspended."
if op:
detail += f" If you have questions, contact the operator at {op}."
raise HTTPException(status_code=403, detail=detail)
if not user.email_verified:
raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).")
if not user.is_active:
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
_establish_session(request, user)
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
return {"ok": True}
@router.post("/password-reset/request")
def password_reset_request(
payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Request a password-reset link. Uniform response regardless of whether the email has a
password account, so it can't probe for registered emails."""
if not _reset_limiter.allow(_client_ip(request)):
return {"status": "ok"}
email = (payload.get("email") or "").strip().lower()
if _EMAIL_RE.match(email):
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL)
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}")
return {"status": "ok"}
@router.post("/password-reset/confirm")
def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
token = payload.get("token")
new_password = payload.get("password") or ""
if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
user = _consume_token(db, token, "reset")
if user is None:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
user.password_hash = hash_password(new_password)
# Burn any other outstanding reset tokens for this user.
for row in db.execute(
select(AuthToken).where(
AuthToken.user_id == user.id, AuthToken.kind == "reset", AuthToken.used_at.is_(None)
)
).scalars():
row.used_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
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:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None: if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
request.session.clear() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before # Always keep the active account in the switchable list — covers sessions created before
@ -318,6 +660,49 @@ def require_human(user: User = Depends(current_user)) -> User:
return user return user
@router.get("/link")
async def link_google(request: Request, user: User = Depends(current_user)):
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
request.session["oauth_link_uid"] = user.id
request.session["oauth_link_explicit"] = True
return await oauth.google.authorize_redirect(
request,
settings.oauth_redirect_url,
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
scope=BASE_SCOPES,
)
@router.post("/set-password")
def set_password(
payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password the session already proves identity. Changing an
existing one requires the current password, so a hijacked session can't silently rotate it."""
if user.is_demo:
raise HTTPException(status_code=403, detail="Not available in the demo account.")
new_password = payload.get("password") or ""
if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if user.password_hash:
if not verify_password(payload.get("current_password") or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password)
db.commit()
log.info("Password set/changed: uid=%s", user.id)
return {"ok": True}
@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(current_user)
@ -331,6 +716,9 @@ async def upgrade(
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403. # straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo: if user.is_demo:
return RedirectResponse(url="/") return RedirectResponse(url="/")
# Attach the grant to THIS account in the callback. Without it a password account (no
# google_sub) would get a brand-new, separate Google user created instead of being linked.
request.session["oauth_link_uid"] = user.id
scope = WRITE_SCOPES if access == "write" else READ_SCOPES scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect( return await oauth.google.authorize_redirect(
request, request,

View file

@ -45,6 +45,10 @@ class Settings(BaseSettings):
allowed_emails: str = "" allowed_emails: str = ""
admin_emails: str = "" admin_emails: str = ""
# Whether anyone may submit an email+password registration (still gated by email
# verification + admin approval before they can sign in). Admin-tunable via the DB.
allow_registration: bool = True
# Origin of a separately served frontend dev server (enables CORS). Empty in production. # Origin of a separately served frontend dev server (enables CORS). Empty in production.
frontend_origin: str = "" frontend_origin: str = ""
@ -90,6 +94,8 @@ class Settings(BaseSettings):
autotag_interval_minutes: int = 30 autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360 subscriptions_resync_minutes: int = 360
playlist_sync_minutes: int = 360 playlist_sync_minutes: int = 360
# How often the shared demo account is auto-reset to a clean baseline (communal sandbox).
demo_reset_minutes: int = 720
# --- Maintenance / validation job --- # --- Maintenance / validation job ---
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods # Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
# are in days because that's the granularity that matters; the rolling re-validation # are in days because that's the granularity that matters; the rolling re-validation
@ -113,6 +119,14 @@ class Settings(BaseSettings):
def admin_email_set(self) -> set[str]: def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()} return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
@property
def app_base(self) -> str:
"""Public origin of the deployed app, derived from the OAuth redirect URL
(.../auth/callback). The single source for user-facing links (verification, reset,
approval emails); dev and prod differ, so links must always come from config."""
u = self.oauth_redirect_url
return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/")
@property @property
def session_https_only(self) -> bool: def session_https_only(self) -> bool:
"""Mark the session cookie Secure when we're served over HTTPS. We treat an """Mark the session cookie Secure when we're served over HTTPS. We treat an

View file

@ -10,13 +10,29 @@ import ssl
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formatdate from email.utils import formatdate
from app import sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal
log = logging.getLogger("subfeed.email") log = logging.getLogger("subfeed.email")
def _smtp() -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
session because email is often sent from a BackgroundTask, outside a request's db."""
with SessionLocal() as db:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def email_enabled() -> bool: def email_enabled() -> bool:
return bool(settings.smtp_host and settings.smtp_user and settings.smtp_password) c = _smtp()
return bool(c["host"] and c["user"] and c["password"])
def _admin_contact() -> str | None: def _admin_contact() -> str | None:
@ -27,12 +43,13 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
recipients = [e for e in to if e] recipients = [e for e in to if e]
if not recipients: if not recipients:
return False return False
if not email_enabled(): c = _smtp()
if not (c["host"] and c["user"] and c["password"]):
log.info("SMTP not configured; skipping email %r to %s", subject, recipients) log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
return False return False
msg = EmailMessage() msg = EmailMessage()
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = settings.smtp_from or settings.smtp_user msg["From"] = c["from"] or c["user"]
msg["To"] = ", ".join(recipients) msg["To"] = ", ".join(recipients)
# A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both # A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both
# nudge spam filters the right way; reputation still does most of the work. # nudge spam filters the right way; reputation still does most of the work.
@ -41,9 +58,9 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
msg["Reply-To"] = reply_to msg["Reply-To"] = reply_to
msg.set_content(body) msg.set_content(body)
try: try:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as s: with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
s.starttls(context=ssl.create_default_context()) s.starttls(context=ssl.create_default_context())
s.login(settings.smtp_user, settings.smtp_password) s.login(c["user"], c["password"])
s.send_message(msg) s.send_message(msg)
log.info("Sent email %r to %s", subject, recipients) log.info("Sent email %r to %s", subject, recipients)
return True return True
@ -57,8 +74,8 @@ def send_access_approved(email: str) -> bool:
body = ( body = (
"Hi,\n\n" "Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n" "Your request to use Siftlode has been approved — welcome aboard.\n\n"
"Just open Siftlode and sign in with this Google account, and your YouTube\n" "Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
"subscriptions feed will be ready.\n\n" f"{settings.app_base}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n" "If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode" "— Siftlode"
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
@ -66,6 +83,64 @@ def send_access_approved(email: str) -> bool:
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin) return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
line = (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
if role == "admin"
else "Your admin access on Siftlode has been removed — you're now a regular user."
)
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
def send_account_unsuspended(to: str, operator: str | None) -> bool:
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
"in again."
+ contact
+ "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
def send_account_deleted(to: str, operator: str | None) -> bool:
contact = (
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
if operator
else ""
)
body = (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode."
+ contact
+ "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
def send_account_suspended(to: str, operator: str | None) -> bool:
contact = (
f"If you think this is a mistake or have questions, contact the Siftlode operator "
f"at {operator}.\n\n"
if operator
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
)
body = (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n"
+ contact
+ "— Siftlode"
)
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
def send_admin_new_request(admins: list[str], requester: str) -> bool: def send_admin_new_request(admins: list[str], requester: str) -> bool:
body = ( body = (
f"{requester} just requested access to Siftlode.\n\n" f"{requester} just requested access to Siftlode.\n\n"
@ -73,3 +148,34 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool:
"(Reply to this email to reach the requester directly.)\n" "(Reply to this email to reach the requester directly.)\n"
) )
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester) return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
def send_verify_email(to: str, link: str) -> bool:
body = (
"Hi,\n\n"
"Confirm your email to finish creating your Siftlode account:\n\n"
f"{link}\n\n"
"If you didn't sign up, you can ignore this message.\n\n"
"— Siftlode"
)
return _send([to], "Confirm your Siftlode email", body)
def send_password_reset(to: str, link: str) -> bool:
body = (
"Hi,\n\n"
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
f"{link}\n\n"
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
"— Siftlode"
)
return _send([to], "Reset your Siftlode password", body)
def send_test(to: str) -> bool:
body = (
"This is a test email from Siftlode.\n\n"
"If you're reading this, your SMTP settings work.\n\n"
"— Siftlode"
)
return _send([to], "Siftlode SMTP test", body)

View file

@ -29,6 +29,7 @@ from app.config import settings
from app.routes import ( from app.routes import (
admin, admin,
channels, channels,
config as config_routes,
feed, feed,
health, health,
me, me,
@ -82,6 +83,7 @@ app.include_router(notifications.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(playlists.router) app.include_router(playlists.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(config_routes.router)
app.include_router(scheduler_routes.router) app.include_router(scheduler_routes.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)
@ -105,4 +107,11 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above. # Client-side routes fall back to index.html; real API/asset paths are matched above.
if full_path.startswith(("api/", "auth/", "healthz", "assets/")): if full_path.startswith(("api/", "auth/", "healthz", "assets/")):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
# Serve real files that live at the SPA root (Vite copies public/ there — e.g. the landing
# screenshots under /welcome/, favicon). /assets is already mounted above; everything else
# that isn't a real file is a client-side route → index.html. Guard against path traversal.
if full_path:
candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")

View file

@ -23,8 +23,26 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True) # NULL for an email+password account that hasn't linked Google yet (Postgres allows
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
password_hash: Mapped[str | None] = mapped_column(String(255))
# Email ownership proven (Google sign-in is implicitly verified; password registration
# verifies via a link). Login requires this AND is_active.
email_verified: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# Admin-approved & enabled. A pending password registration starts inactive; admin
# approval activates it. A deactivated account can't log in.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
# account can't sign in by any method and its live sessions are rejected; the user is told
# why and pointed at the operator's contact. The demo account is never suspendable.
is_suspended: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
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")
@ -70,6 +88,26 @@ class OAuthToken(Base):
user: Mapped["User"] = relationship(back_populates="token") user: Mapped["User"] = relationship(back_populates="token")
class AuthToken(Base):
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
lives only in the emailed link. Consumed (used_at set) on first valid use."""
__tablename__ = "auth_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Invite(Base): class Invite(Base):
"""Access-request / whitelist row. Source of truth for who may sign in; the env """Access-request / whitelist row. Source of truth for who may sign in; the env
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed).""" ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
@ -358,6 +396,24 @@ class SchedulerSetting(Base):
interval_minutes: Mapped[int] = mapped_column(Integer) interval_minutes: Mapped[int] = mapped_column(Integer)
class SystemConfig(Base):
"""Admin-set overrides for operational config that otherwise comes from env/config
defaults. Generic key/value store (one row per key); absent = use the env/config
default. DB-backed so it's editable from the admin Configuration page at runtime without
a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted`
flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their
types/groups/defaults lives in app.sysconfig (the single source of truth)."""
__tablename__ = "system_config"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(Text)
encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=func.now(), onupdate=func.now()
)
class Playlist(Base): class Playlist(Base):
"""A per-user named, ordered collection of videos from the shared catalog. """A per-user named, ordered collection of videos from the shared catalog.

View file

@ -12,11 +12,37 @@ from zoneinfo import ZoneInfo
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings from app import sysconfig
from app.models import ApiQuotaUsage, QuotaEvent from app.models import ApiQuotaUsage, QuotaEvent
_PACIFIC = ZoneInfo("America/Los_Angeles") _PACIFIC = ZoneInfo("America/Los_Angeles")
class QuotaAction:
"""Canonical quota-attribution action keys (one source of truth).
Naming scheme: ``<entity>_<action>``, all snake_case, explicit pull/push direction.
The stored value is this machine key; the user-facing label is resolved from i18n
(frontend ``quotaActions.<key>``). Distinct from scheduler job ids and progress phase
labels do not conflate.
"""
SUBSCRIPTIONS_PULL = "subscriptions_pull"
SUBSCRIPTIONS_RESYNC = "subscriptions_resync"
PLAYLISTS_PULL = "playlists_pull"
PLAYLISTS_PUSH = "playlists_push"
PLAYLISTS_DELETE = "playlists_delete"
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
VIDEOS_ENRICH = "videos_enrich"
VIDEOS_LOOKUP = "videos_lookup"
CHANNELS_DISCOVER = "channels_discover"
CHANNELS_SUBSCRIBE = "channels_subscribe"
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
MAINTENANCE_REVALIDATE = "maintenance_revalidate"
OTHER = "other"
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work. # Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by # Set at entry points (route handlers, scheduler jobs) via attribute(); read by
# record_usage. Default = background/system, generic action. # record_usage. Default = background/system, generic action.
@ -24,7 +50,7 @@ _actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"quota_actor_id", default=None "quota_actor_id", default=None
) )
_action: contextvars.ContextVar[str] = contextvars.ContextVar( _action: contextvars.ContextVar[str] = contextvars.ContextVar(
"quota_action", default="api" "quota_action", default=QuotaAction.OTHER
) )
@ -56,7 +82,7 @@ def units_used_today(db: Session) -> int:
def remaining_today(db: Session) -> int: def remaining_today(db: Session) -> int:
return max(0, settings.quota_daily_budget - units_used_today(db)) return max(0, sysconfig.get_int(db, "quota_daily_budget") - units_used_today(db))
def can_spend(db: Session, units: int) -> bool: def can_spend(db: Session, units: int) -> bool:

View file

@ -4,17 +4,18 @@ 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 delete, select from sqlalchemy import delete, func, 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, get_or_create_demo_user from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
DemoWhitelist, DemoWhitelist,
Invite, Invite,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
Subscription,
User, User,
Video, Video,
VideoState, VideoState,
@ -59,6 +60,15 @@ def list_invites(
return [_serialize(i) for i in rows] return [_serialize(i) for i in rows]
def _activate_user_by_email(db: Session, email: str) -> None:
"""Approving access activates a matching (e.g. pending password-registration) account so
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
if user is not None and not user.is_active:
user.is_active = True
db.commit()
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id) inv = db.get(Invite, invite_id)
if inv is None: if inv is None:
@ -78,6 +88,7 @@ def approve_invite(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
inv = _decide(db, invite_id, admin, approved=True) inv = _decide(db, invite_id, admin, approved=True)
_activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email) background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv) return _serialize(inv)
@ -109,9 +120,134 @@ def add_invite(
inv.decided_at = datetime.now(timezone.utc) inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email inv.decided_by = admin.email
db.commit() db.commit()
_activate_user_by_email(db, email)
return _serialize(inv) return _serialize(inv)
# --- Users & roles --------------------------------------------------------------------
def _serialize_user(u: User) -> dict:
return {
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"role": u.role,
"is_active": u.is_active,
"is_suspended": u.is_suspended,
"email_verified": u.email_verified,
"is_demo": u.is_demo,
"has_password": u.password_hash is not None,
"has_google": u.google_sub is not None,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
@router.get("/users")
def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all()
return [_serialize_user(u) for u in rows]
@router.patch("/users/{user_id}/role")
def set_user_role(
user_id: int,
payload: dict,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = payload.get("role")
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.")
if target.role == "admin" and role == "user":
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role
target.role = role
db.commit()
if changed:
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
return _serialize_user(target)
@router.patch("/users/{user_id}/suspend")
def set_user_suspended(
user_id: int,
payload: dict,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
live session is dropped on its next request. Guards: can't suspend the demo account, can't
suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in)."""
suspended = bool(payload.get("suspended"))
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
if suspended and target.role == "admin" and not target.is_suspended:
active_admins = db.scalar(
select(func.count())
.select_from(User)
.where(User.role == "admin", User.is_suspended.is_(False))
)
if (active_admins or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
background.add_task(
email_mod.send_account_unsuspended, target.email, operator_contact()
)
return _serialize_user(target)
@router.delete("/users/{user_id}")
def admin_delete_user(
user_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
not the demo account, not yourself (use Settings Account), and not the last admin."""
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
if target.id == admin.id:
raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account."
)
if target.role == "admin":
admin_count = db.scalar(
select(func.count()).select_from(User).where(User.role == "admin")
)
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
purge_user(db, target, background)
return {"deleted": user_id}
# --- Demo account: email whitelist + state reset ------------------------------------- # --- Demo account: email whitelist + state reset -------------------------------------
def _serialize_demo(row: DemoWhitelist) -> dict: def _serialize_demo(row: DemoWhitelist) -> dict:
@ -208,19 +344,44 @@ def _seed_demo_playlists(db: Session, demo: User) -> int:
return created return created
@router.post("/demo/reset") def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
def reset_demo( """Seed the demo with a handful of catalog channels (the ones with the most videos) so its
_: User = Depends(admin_user), db: Session = Depends(get_db) Channel manager isn't empty — gives visitors something to explore and matches the landing-page
) -> dict: screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
"""Wipe the shared demo account's per-user state (watch/save/hide states, playlists, db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
preferences) back to a clean baseline and re-seed a few sample playlists. There's no top = db.execute(
automatic reset this is the admin's manual 'clean up the sandbox' button.""" select(Video.channel_id)
demo = get_or_create_demo_user(db) .where(Video.channel_id.is_not(None))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(n)
).scalars().all()
for channel_id in top:
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
return len(top)
def reset_demo_state(db: Session, demo: User) -> int:
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns
the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo
reset (see scheduler)."""
db.execute(delete(VideoState).where(VideoState.user_id == demo.id)) db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE"). # Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id)) db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
demo.preferences = {"language": "en"} demo.preferences = {"language": "en"}
db.commit() db.commit()
seeded = _seed_demo_playlists(db, demo) seeded = _seed_demo_playlists(db, demo)
_seed_demo_subscriptions(db, demo)
db.commit() db.commit()
return {"reset": True, "playlists_seeded": seeded} return seeded
@router.post("/demo/reset")
def reset_demo(
_: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}

View file

@ -166,7 +166,7 @@ def discover_channels(
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it. # to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
if user.yt_channel_id is None and user.token is not None and not user.is_demo: if user.yt_channel_id is None and user.token is not None and not user.is_demo:
try: try:
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
own = yt.get_my_channel_id() own = yt.get_my_channel_id()
if own: if own:
user.yt_channel_id = own user.yt_channel_id = own
@ -181,7 +181,7 @@ def discover_channels(
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
if need: if need:
try: try:
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
apply_channel_details(db, yt.get_channels(need)) apply_channel_details(db, yt.get_channels(need))
db.commit() db.commit()
rows = _discovery_rows(db, user) rows = _discovery_rows(db, user)
@ -243,7 +243,7 @@ def update_channel(
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep). # the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
if deep_turned_on and channel is not None and channel.recent_synced_at is None: if deep_turned_on and channel is not None and channel.recent_synced_at is None:
with quota.attribute(user.id, "backfill_recent"): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
return { return {
@ -274,7 +274,7 @@ def reset_backfill(
channel.recent_synced_at = None channel.recent_synced_at = None
sub.deep_requested = True sub.deep_requested = True
db.commit() db.commit()
with quota.attribute(user.id, "backfill_recent"): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
return {"id": channel_id, "reset": True} return {"id": channel_id, "reset": True}
@ -306,7 +306,7 @@ def subscribe(
if existing is not None: if existing is not None:
raise HTTPException(status_code=409, detail="Already subscribed to this channel.") raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
try: try:
with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt:
yt_sub_id = yt.insert_subscription(channel_id) yt_sub_id = yt.insert_subscription(channel_id)
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up
# properly right away; no video pull here. # properly right away; no video pull here.
@ -356,7 +356,7 @@ def unsubscribe(
detail="No YouTube subscription id on record — sync subscriptions first.", detail="No YouTube subscription id on record — sync subscriptions first.",
) )
try: try:
with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), YouTubeClient(db, user) as yt:
yt.delete_subscription(sub.yt_subscription_id) yt.delete_subscription(sub.yt_subscription_id)
except YouTubeError as exc: except YouTubeError as exc:
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as # 422 (a real, explainable error → speaking modal), not 502 which the client treats as

View file

@ -0,0 +1,68 @@
"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in
app.sysconfig. Secret values (e.g. SMTP password) are write-only never returned."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import email as email_mod
from app import sysconfig
from app.db import get_db
from app.models import User
from app.routes.admin import admin_user
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
@router.get("")
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
return sysconfig.describe(db)
@router.patch("/{key}")
def set_config(
key: str,
payload: dict,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
if s is None:
raise HTTPException(status_code=404, detail="Unknown config key")
if "value" not in payload:
raise HTTPException(status_code=400, detail="Missing 'value'")
if s.secret and not sysconfig.secrets_manageable():
raise HTTPException(
status_code=400,
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
)
try:
sysconfig.set_value(db, key, payload["value"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
return sysconfig.describe(db)
@router.delete("/{key}")
def reset_config(
key: str,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
if sysconfig.spec(key) is None:
raise HTTPException(status_code=404, detail="Unknown config key")
sysconfig.reset(db, key)
return sysconfig.describe(db)
@router.post("/test-email")
def send_test_email(
user: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
lets the admin verify the settings actually work."""
if not email_mod.email_enabled():
raise HTTPException(status_code=400, detail="SMTP is not configured.")
sent = email_mod.send_test(user.email)
if not sent:
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
return {"sent": True, "to": user.email}

View file

@ -549,7 +549,7 @@ def get_video_detail(
} }
try: try:
with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt:
items = yt.get_videos([video_id]) items = yt.get_videos([video_id])
except YouTubeError as exc: except YouTubeError as exc:
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}") raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")

View file

@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import current_user, has_read_scope, has_write_scope, is_allowed from app.auth import current_user, has_read_scope, has_write_scope, is_allowed, purge_user
from app.db import get_db from app.db import get_db
from app.models import Invite, User from app.models import Invite, User
@ -78,6 +78,8 @@ def get_me(
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"role": user.role, "role": user.role,
"is_demo": user.is_demo, "is_demo": user.is_demo,
"has_google": user.google_sub is not None,
"has_password": user.password_hash is not None,
"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,
@ -85,6 +87,41 @@ def get_me(
} }
@router.delete("/account")
def delete_my_account(
request: Request,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""GDPR self-service erasure: permanently delete the signed-in account and ALL its personal
data OAuth tokens, subscriptions, tags, watch/save/hide states, playlists, notifications
which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL).
The shared demo account can't be deleted, and the last remaining admin can't delete itself
(that would lock everyone out of the admin surfaces)."""
if user.is_demo:
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
if user.role == "admin":
admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
if admins <= 1:
raise HTTPException(
status_code=400,
detail="You're the only admin — promote another admin before deleting your account.",
)
user_id = user.id
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
purge_user(db, user, background)
# Drop this account from the browser session; switch to another signed-in account if any.
remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
if remaining:
request.session["account_ids"] = remaining
request.session["user_id"] = remaining[-1]
else:
request.session.clear()
return {"deleted": True}
@router.put("/preferences") @router.put("/preferences")
def update_preferences( def update_preferences(
preferences: dict, preferences: dict,

View file

@ -245,7 +245,7 @@ def sync_youtube(
status_code=403, status_code=403,
detail="Connect YouTube (read access) to sync your playlists.", detail="Connect YouTube (read access) to sync your playlists.",
) )
with quota.attribute(user.id, "playlist_sync"): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return sync_user_playlists(db, user) return sync_user_playlists(db, user)
@ -266,7 +266,7 @@ def revert_youtube(
status_code=403, detail="Connect YouTube (read access) to sync your playlists." status_code=403, detail="Connect YouTube (read access) to sync your playlists."
) )
try: try:
with quota.attribute(user.id, "playlist_sync"): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl) return repull_playlist(db, user, pl)
except YouTubeError: except YouTubeError:
db.rollback() db.rollback()
@ -298,7 +298,7 @@ def push_plan(
status_code=403, detail="Enable YouTube editing in Settings first." status_code=403, detail="Enable YouTube editing in Settings first."
) )
try: try:
with quota.attribute(user.id, "playlist_push"): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_push(db, user, pl) plan = plan_push(db, user, pl)
except YouTubeError: except YouTubeError:
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.") raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
@ -322,7 +322,7 @@ def push(
status_code=403, detail="Enable YouTube editing in Settings first." status_code=403, detail="Enable YouTube editing in Settings first."
) )
try: try:
with quota.attribute(user.id, "playlist_push"): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_push(db, user, pl) plan = plan_push(db, user, pl)
if plan["units_estimate"] > quota.remaining_today(db): if plan["units_estimate"] > quota.remaining_today(db):
raise HTTPException( raise HTTPException(
@ -418,7 +418,7 @@ def delete_playlist(
status_code=403, detail="Enable YouTube editing in Settings first." status_code=403, detail="Enable YouTube editing in Settings first."
) )
try: try:
with quota.attribute(user.id, "playlist_delete"): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id) yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError: except YouTubeError:

View file

@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota, state from app import quota, state, sysconfig
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 Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
@ -152,8 +152,8 @@ def get_scheduler(
quota_info = { quota_info = {
"used_today": used, "used_today": used,
"remaining_today": quota.remaining_today(db), "remaining_today": quota.remaining_today(db),
"daily_budget": settings.quota_daily_budget, "daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
"backfill_reserve": settings.backfill_quota_reserve, "backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
} }
return { return {

View file

@ -41,7 +41,7 @@ def sync_subscriptions(
detail="Connect your YouTube account (read access) to import subscriptions.", detail="Connect your YouTube account (read access) to import subscriptions.",
) )
before = quota.units_used_today(db) before = quota.units_used_today(db)
with quota.attribute(user.id, "sync_subscriptions"): with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
result = import_subscriptions(db, user) result = import_subscriptions(db, user)
result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_used_estimate"] = quota.units_used_today(db) - before
result["quota_remaining_today"] = quota.remaining_today(db) result["quota_remaining_today"] = quota.remaining_today(db)
@ -63,7 +63,7 @@ def sync_backfill(
max_channels: int = 25, max_channels: int = 25,
) -> dict: ) -> dict:
before = quota.units_used_today(db) before = quota.units_used_today(db)
with quota.attribute(user.id, "backfill_recent"): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels) result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_used_estimate"] = quota.units_used_today(db) - before
return result return result
@ -74,7 +74,7 @@ def sync_enrich(
user: User = Depends(require_human), 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, quota.QuotaAction.VIDEOS_ENRICH):
enriched = run_enrich(db) enriched = run_enrich(db)
return { return {
"enriched": enriched, "enriched": enriched,

View file

@ -56,6 +56,7 @@ JOB_INTERVALS: dict[str, int] = {
"subscriptions": settings.subscriptions_resync_minutes, "subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes, "playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes, "maintenance": settings.maintenance_interval_minutes,
"demo_reset": settings.demo_reset_minutes,
} }
# Sane bounds for an admin-set interval (minutes). # Sane bounds for an admin-set interval (minutes).
@ -204,7 +205,7 @@ def _rss_job() -> None:
def _enrich_job() -> None: def _enrich_job() -> None:
def work(db): def work(db):
with quota.attribute(None, "enrich"): with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH):
return run_enrich(db) return run_enrich(db)
_job("enrich", work) _job("enrich", work)
@ -214,9 +215,9 @@ def _backfill_job() -> None:
# Recent-first for not-yet-synced channels, then deep backfill for the rest. All # Recent-first for not-yet-synced channels, then deep backfill for the rest. All
# background spend is attributed to the system (no actor), split by action. # background spend is attributed to the system (no actor), split by action.
def work(db): def work(db):
with quota.attribute(None, "backfill_recent"): with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
recent = run_recent_backfill(db, max_channels=25) recent = run_recent_backfill(db, max_channels=25)
with quota.attribute(None, "backfill_deep"): with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
deep = run_deep_backfill(db, max_channels=10) deep = run_deep_backfill(db, max_channels=10)
return {"recent": recent, "deep": deep} return {"recent": recent, "deep": deep}
@ -233,7 +234,7 @@ def _shorts_job() -> None:
def _subscriptions_job() -> None: def _subscriptions_job() -> None:
def work(db): def work(db):
with quota.attribute(None, "subscription_resync"): with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC):
return run_subscription_resync(db) return run_subscription_resync(db)
_job("subscriptions", work) _job("subscriptions", work)
@ -247,12 +248,26 @@ def _playlist_sync_job() -> None:
def _maintenance_job() -> None: def _maintenance_job() -> None:
def work(db): def work(db):
with quota.attribute(None, "maintenance"): with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE):
return run_maintenance(db) return run_maintenance(db)
_job("maintenance", work) _job("maintenance", work)
def _demo_reset_job() -> None:
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo
# account. Lazy import avoids a routes<->scheduler import cycle.
def work(db):
from app.models import User
demo = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if demo is None:
return {"skipped": "no demo account"}
from app.routes.admin import reset_demo_state
return {"playlists_seeded": reset_demo_state(db, demo)}
_job("demo_reset", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one, # job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). # shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = { JOB_FUNCS: dict[str, Callable[[], None]] = {
@ -264,6 +279,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"subscriptions": _subscriptions_job, "subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job, "playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job, "maintenance": _maintenance_job,
"demo_reset": _demo_reset_job,
} }

View file

@ -1,7 +1,25 @@
from argon2 import PasswordHasher
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from app.config import settings from app.config import settings
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
_ph = PasswordHasher()
def hash_password(password: str) -> str:
return _ph.hash(password)
def verify_password(password: str, hashed: str | None) -> bool:
if not hashed:
return False
try:
return _ph.verify(hashed, password)
except Exception:
# Any failure (mismatch, malformed hash) is a non-match — never raise to the caller.
return False
_fernet: Fernet | None = ( _fernet: Fernet | None = (
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
) )

View file

@ -13,8 +13,7 @@ from sqlalchemy.orm import Session
log = logging.getLogger("subfeed.autotag") log = logging.getLogger("subfeed.autotag")
from app import progress from app import progress, sysconfig
from app.config import settings
from app.models import Channel, ChannelTag, Tag, Video from app.models import Channel, ChannelTag, Tag, Video
# --- language id (offline, deterministic, small) --- # --- language id (offline, deterministic, small) ---
@ -166,7 +165,7 @@ def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None,
select(Video.title) select(Video.title)
.where(Video.channel_id == channel.id, Video.title.is_not(None)) .where(Video.channel_id == channel.id, Video.title.is_not(None))
.order_by(Video.published_at.desc()) .order_by(Video.published_at.desc())
.limit(settings.autotag_title_sample) .limit(sysconfig.get_int(db, "autotag_title_sample"))
) )
.scalars() .scalars()
.all() .all()

View file

@ -25,7 +25,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress, quota from app import progress, quota, sysconfig
from app.config import settings from app.config import settings
from app.models import Playlist, PlaylistItem, Video, VideoState from app.models import Playlist, PlaylistItem, Video, VideoState
from app.notifications import create_notification from app.notifications import create_notification
@ -196,7 +196,7 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
now = _now() now = _now()
# Process in videos.list-sized pages so we commit incrementally and stop on low quota. # Process in videos.list-sized pages so we commit incrementally and stop on low quota.
while checked < batch: while checked < batch:
if quota.remaining_today(db) <= settings.backfill_quota_reserve: if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
break break
page = ( page = (
db.execute( db.execute(
@ -236,7 +236,7 @@ def run_maintenance(db: Session) -> dict:
phase1 = _recheck_flagged(db, yt) phase1 = _recheck_flagged(db, yt)
phase2 = ( phase2 = (
_revalidate_rolling(db, yt) _revalidate_rolling(db, yt)
if quota.remaining_today(db) > settings.backfill_quota_reserve if quota.remaining_today(db) > sysconfig.get_int(db, "backfill_quota_reserve")
else {"checked": 0, "flagged": 0, "skipped": "low quota"} else {"checked": 0, "flagged": 0, "skipped": "low quota"}
) )
return { return {

View file

@ -387,7 +387,7 @@ def sync_all_playlists(db: Session) -> dict:
progress.report(i, len(users), "playlist_sync") progress.report(i, len(users), "playlist_sync")
continue continue
try: try:
with quota.attribute(user.id, "playlist_sync"): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
total += sync_user_playlists(db, user).get("synced", 0) total += sync_user_playlists(db, user).get("synced", 0)
except YouTubeError: except YouTubeError:
db.rollback() db.rollback()

View file

@ -10,8 +10,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress, quota from app import progress, quota, sysconfig
from app.config import settings
log = logging.getLogger("subfeed.sync") log = logging.getLogger("subfeed.sync")
from app.models import Channel, OAuthToken, Subscription, User from app.models import Channel, OAuthToken, Subscription, User
@ -140,7 +139,7 @@ def run_recent_backfill(
videos_added = 0 videos_added = 0
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
for channel in channels: for channel in channels:
if quota.remaining_today(db) <= settings.backfill_quota_reserve: if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
break break
try: try:
videos_added += backfill_channel_recent(db, yt, channel) videos_added += backfill_channel_recent(db, yt, channel)
@ -186,7 +185,7 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
videos_added = 0 videos_added = 0
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
for channel in channels: for channel in channels:
if quota.remaining_today(db) <= settings.backfill_quota_reserve: if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
break break
try: try:
videos_added += backfill_channel_deep(db, yt, channel, max_pages) videos_added += backfill_channel_deep(db, yt, channel, max_pages)
@ -215,7 +214,9 @@ def estimate_deep_backfill(db: Session, channels: list[Channel]) -> dict:
return 2 * pages return 2 * pages
total_units = sum(units(c) for c in pending) total_units = sum(units(c) for c in pending)
daily_budget = max(1, settings.quota_daily_budget - settings.backfill_quota_reserve) daily_budget = max(
1, sysconfig.get_int(db, "quota_daily_budget") - sysconfig.get_int(db, "backfill_quota_reserve")
)
eta_seconds = int(total_units / daily_budget * 86_400) if pending else 0 eta_seconds = int(total_units / daily_budget * 86_400) if pending else 0
return { return {
"channels_pending": len(pending), "channels_pending": len(pending),

View file

@ -9,8 +9,7 @@ from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress from app import progress, sysconfig
from app.config import settings
from app.models import Channel, Video from app.models import Channel, Video
from app.youtube.client import YouTubeClient, best_thumbnail from app.youtube.client import YouTubeClient, best_thumbnail
from app.youtube.rss import fetch_channel_feed from app.youtube.rss import fetch_channel_feed
@ -108,7 +107,7 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
db.commit() db.commit()
return 0 return 0
cutoff = _now() - timedelta(days=settings.backfill_recent_max_days) cutoff = _now() - timedelta(days=sysconfig.get_int(db, "backfill_recent_max_days"))
collected: list[dict] = [] collected: list[dict] = []
page_token: str | None = None page_token: str | None = None
next_cursor: str | None = None next_cursor: str | None = None
@ -126,7 +125,7 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
stopped_mid_page = True stopped_mid_page = True
break break
collected.append(stub) collected.append(stub)
if len(collected) >= settings.backfill_recent_max_videos: if len(collected) >= sysconfig.get_int(db, "backfill_recent_max_videos"):
stopped_mid_page = True stopped_mid_page = True
break break
page_token = data.get("nextPageToken") page_token = data.get("nextPageToken")
@ -249,7 +248,7 @@ def apply_video_details(video: Video, item: dict) -> None:
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
limit = limit or settings.enrich_batch_size limit = limit or sysconfig.get_int(db, "enrich_batch_size")
videos = ( videos = (
db.execute(select(Video).where(Video.enriched_at.is_(None)).limit(limit)) db.execute(select(Video).where(Video.enriched_at.is_(None)).limit(limit))
.scalars() .scalars()
@ -282,7 +281,7 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
ends. So we revisit anything still live/upcoming, plus a just-ended stream that hasn't got ends. So we revisit anything still live/upcoming, plus a just-ended stream that hasn't got
its duration yet (YouTube can lag a bit after the broadcast), until it settles. Cheap: its duration yet (YouTube can lag a bit after the broadcast), until it settles. Cheap:
videos.list is 1 unit per 50 ids, and the live/upcoming set is normally tiny.""" videos.list is 1 unit per 50 ids, and the live/upcoming set is normally tiny."""
limit = limit or settings.enrich_batch_size limit = limit or sysconfig.get_int(db, "enrich_batch_size")
videos = ( videos = (
db.execute( db.execute(
select(Video) select(Video)
@ -322,8 +321,8 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
def run_shorts_classification(db: Session, limit: int | None = None) -> dict: def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
"""Finalize Shorts classification: cheaply mark non-candidates, then probe the """Finalize Shorts classification: cheaply mark non-candidates, then probe the
youtube.com/shorts URL for short-enough, non-live, enriched videos.""" youtube.com/shorts URL for short-enough, non-live, enriched videos."""
limit = limit or settings.shorts_probe_batch limit = limit or sysconfig.get_int(db, "shorts_probe_batch")
probe_max = settings.shorts_probe_max_seconds probe_max = sysconfig.get_int(db, "shorts_probe_max_seconds")
# 1) Anything enriched that can't be a Short -> finalize without a probe. # 1) Anything enriched that can't be a Short -> finalize without a probe.
db.execute( db.execute(

173
backend/app/sysconfig.py Normal file
View file

@ -0,0 +1,173 @@
"""Admin-editable system configuration: a DB-override layer over the env/config defaults.
The registry (SPECS) is the single source of truth for which config keys can be overridden
from the admin Configuration page, plus each key's type, admin-UI group, env/config default,
bounds, and whether it's a secret. The resolver returns the DB override if one is set, else
``settings.<default_attr>`` (same DB-override-or-default pattern as app.state). Secrets are
stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be
stored in the DB and stay env-only).
"""
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app import security
from app.config import settings
from app.models import SystemConfig
@dataclass(frozen=True)
class ConfigSpec:
key: str
type: str # "int" | "str" | "bool"
group: str # admin-UI grouping (logically related keys stay together)
default_attr: str # attribute on `settings` holding the env/config default
secret: bool = False
min: int | None = None
max: int | None = None
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
# off env into the admin UI; the admin page renders it automatically from this list.
SPECS: tuple[ConfigSpec, ...] = (
# --- Email / SMTP (one logical group; the password is the only secret) ---
ConfigSpec("smtp_host", "str", "email", "smtp_host"),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
ConfigSpec("smtp_user", "str", "email", "smtp_user"),
ConfigSpec("smtp_from", "str", "email", "smtp_from"),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
# --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
ConfigSpec("backfill_quota_reserve", "int", "quota", "backfill_quota_reserve", min=0, max=1_000_000),
# --- Backfill (recent-first first pass per channel) ---
ConfigSpec("backfill_recent_max_videos", "int", "backfill", "backfill_recent_max_videos", min=1, max=100_000),
ConfigSpec("backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500),
# --- Shorts probe ---
ConfigSpec("shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600),
ConfigSpec("shorts_probe_batch", "int", "shorts", "shorts_probe_batch", min=1, max=10_000),
# --- Batch sizes ---
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
)
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
def spec(key: str) -> ConfigSpec | None:
return _BY_KEY.get(key)
def secrets_manageable() -> bool:
"""Secret values can only be stored when Fernet is configured (TOKEN_ENCRYPTION_KEY)."""
return bool(settings.token_encryption_key)
def _default(s: ConfigSpec):
return getattr(settings, s.default_attr)
def _coerce(s: ConfigSpec, raw: str):
if s.type == "int":
return int(raw)
if s.type == "bool":
return raw == "true"
return raw
def _raw_override(db: Session, key: str) -> str | None:
"""Stored (decrypted) override string for `key`, or None if no override is set."""
row = db.get(SystemConfig, key)
if row is None or row.value is None:
return None
return security.decrypt(row.value) if row.encrypted else row.value
def get(db: Session, key: str):
"""Effective value: the DB override (typed) if set, else the env/config default."""
s = _BY_KEY[key]
raw = _raw_override(db, key)
if raw is None or raw == "":
return _default(s)
try:
return _coerce(s, raw)
except (TypeError, ValueError):
return _default(s)
def get_str(db: Session, key: str) -> str:
v = get(db, key)
return "" if v is None else str(v)
def get_int(db: Session, key: str) -> int:
return int(get(db, key))
def get_bool(db: Session, key: str) -> bool:
return bool(get(db, key))
def set_value(db: Session, key: str, value) -> None:
"""Validate, coerce, and persist a DB override for `key`. Secrets are encrypted."""
s = _BY_KEY[key]
if s.type == "int":
v = int(value)
if s.min is not None and v < s.min:
raise ValueError(f"{key} must be >= {s.min}")
if s.max is not None and v > s.max:
raise ValueError(f"{key} must be <= {s.max}")
raw = str(v)
elif s.type == "bool":
raw = "true" if value else "false"
else:
raw = "" if value is None else str(value)
stored, encrypted = raw, False
if s.secret:
if not secrets_manageable():
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
stored, encrypted = security.encrypt(raw) or "", True
row = db.get(SystemConfig, key)
if row is None:
row = SystemConfig(key=key)
db.add(row)
row.value = stored
row.encrypted = encrypted
db.commit()
def reset(db: Session, key: str) -> None:
"""Remove the DB override for `key` (fall back to the env/config default)."""
row = db.get(SystemConfig, key)
if row is not None:
db.delete(row)
db.commit()
def describe(db: Session) -> dict:
"""Admin-UI metadata + current values, grouped. Secret values are never echoed —
only whether an override is set and whether an env default exists."""
groups: dict[str, list[dict]] = {}
for s in SPECS:
is_set = db.get(SystemConfig, s.key) is not None
item: dict = {
"key": s.key,
"type": s.type,
"group": s.group,
"secret": s.secret,
"min": s.min,
"max": s.max,
"is_set": is_set, # an override row exists
}
if s.secret:
item["default_is_set"] = bool(_default(s))
else:
item["value"] = get(db, s.key)
item["default"] = _default(s)
groups.setdefault(s.group, []).append(item)
return {"groups": groups, "secrets_manageable": secrets_manageable()}

View file

@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
import httpx import httpx
from app import quota from app import quota, sysconfig
from app.config import settings from app.config import settings
from app.models import User from app.models import User
from app.security import decrypt from app.security import decrypt
@ -86,8 +86,9 @@ class YouTubeClient:
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict: def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict:
p = dict(params) p = dict(params)
headers = {} headers = {}
if allow_key and settings.youtube_api_key: api_key = sysconfig.get_str(self.db, "youtube_api_key")
p["key"] = settings.youtube_api_key if allow_key and api_key:
p["key"] = api_key
else: else:
headers["Authorization"] = f"Bearer {self._access_token()}" headers["Authorization"] = f"Bearer {self._access_token()}"
resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers) resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers)

View file

@ -12,3 +12,4 @@ apscheduler>=3.10,<4.0
py3langid>=0.3,<1.0 py3langid>=0.3,<1.0
itsdangerous>=2.1,<3.0 itsdangerous>=2.1,<3.0
cryptography>=46.0.7,<47 cryptography>=46.0.7,<47
argon2-cffi>=23.1,<26

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, HttpError, type FeedFilters } from "./lib/api"; import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n"; import { setLanguage, isSupported, type LangCode } from "./i18n";
import { import {
applyTheme, applyTheme,
@ -17,11 +17,11 @@ import {
saveLayoutLocal, saveLayoutLocal,
type SidebarLayout, type SidebarLayout,
} from "./lib/sidebarLayout"; } from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, type NotifSettings } from "./lib/notifications"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { useConfirm } from "./components/ConfirmProvider"; import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel"; import type { PrefsController } from "./components/SettingsPanel";
import Login from "./components/Login"; import Welcome from "./components/Welcome";
import Header from "./components/Header"; import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
@ -30,6 +30,8 @@ import Channels, { type ChannelStatusFilter, type ChannelsView } from "./compone
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler"; import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel";
import AdminUsers from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel"; import NotificationsPanel from "./components/NotificationsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
@ -262,7 +264,47 @@ export default function App() {
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); const queryClient = useQueryClient();
// Poll the session so an account suspended/deleted by an admin is noticed even with no
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
// Only poll while signed in (data present) — polling on the public login page is pointless and
// its periodic refetch caused a visible flicker there.
const meQuery = useQuery({
queryKey: ["me"],
queryFn: api.me,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` exists), reload to the login page at once (option 2 — also covers any click that hits
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop.
useEffect(() => {
setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) {
queryClient.clear();
window.location.reload();
}
});
return () => setUnauthorizedHandler(null);
}, [queryClient]);
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
// Settings → Account so the new state is visible, then strip the param for a clean URL.
useEffect(() => {
const result = new URLSearchParams(window.location.search).get("link");
if (!result) return;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch();
localStorage.setItem("siftlode.settingsTab", "account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
stripUrlParams();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after // First-login onboarding: prompt the user to connect YouTube (and resume the flow after
// each consent redirect). Derived from granted scopes + storage flags so it's stable // each consent redirect). Derived from granted scopes + storage flags so it's stable
@ -305,13 +347,22 @@ export default function App() {
if (isSupported(prefs.language)) setLanguage(prefs.language); if (isSupported(prefs.language)) setLanguage(prefs.language);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). // Nudge admins when access requests are waiting (in lieu of a server-push bell).
const pending = meQuery.data?.pending_invites ?? 0; const pending = meQuery.data?.pending_invites ?? 0;
if (meQuery.data?.role === "admin" && pending > 0) { if (meQuery.data?.role === "admin") {
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
if (pending > 0) {
notify({ notify({
level: "warning", level: "warning",
title: t("common.accessRequestsTitle"), title: t("common.accessRequestsTitle"),
message: t("common.accessRequestsMessage", { count: pending }), message: t("common.accessRequestsMessage", { count: pending }),
// Durable inbox link (survives reload, unlike a live action callback); the toast also
// gets a click via the action.
meta: { kind: "access-requests" },
action: { label: t("common.accessRequestsReview"), onClick: () => setPage("users") },
}); });
} }
}
// The demo account is shared: there are no subscriptions, so default it to the whole // The demo account is shared: there are no subscriptions, so default it to the whole
// library (the "my" feed would be empty). The communal-state warning is a permanent // library (the "my" feed would be empty). The communal-state warning is a permanent
// banner (see DemoBanner below), not a toast that re-pops on every reload. // banner (see DemoBanner below), not a toast that re-pops on every reload.
@ -399,7 +450,7 @@ export default function App() {
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div> <div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
); );
if (meQuery.error instanceof HttpError && meQuery.error.status === 401) if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
return <Login />; return <Welcome />;
if (meQuery.error) if (meQuery.error)
return ( return (
<div className="min-h-screen grid place-items-center text-muted"> <div className="min-h-screen grid place-items-center text-muted">
@ -465,10 +516,14 @@ export default function App() {
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}} }}
/> />
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" ? (
<Stats /> <Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler /> <Scheduler />
) : page === "config" && meQuery.data!.role === "admin" ? (
<ConfigPanel />
) : page === "users" && meQuery.data!.role === "admin" ? (
<AdminUsers me={meQuery.data!} />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? ( ) : page === "notifications" ? (

View file

@ -0,0 +1,476 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
// carries a badge with the count of pending requests so it's visible without switching to it.
export default function AdminUsers({ me }: { me: Me }) {
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
{ id: "demo", label: t("users.tabs.demo") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
{children}
</div>
);
}
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
const cls =
tone === "accent"
? "border-accent/40 text-accent"
: tone === "warning"
? "border-amber-500/40 text-amber-500"
: "border-border text-muted";
return (
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
);
}
function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
api.setUserRole(id, role),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
},
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
});
const suspend = useMutation({
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
api.setUserSuspended(id, suspended),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({
level: "success",
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
email: vars.email,
}),
});
},
// Guards (demo / self / last admin) surface via the global error dialog.
});
const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
},
});
const onToggle = async (u: AdminUserRow) => {
const makeAdmin = u.role !== "admin";
const ok = await confirm({
title: makeAdmin ? t("users.roles.promoteTitle") : t("users.roles.demoteTitle"),
message: t(makeAdmin ? "users.roles.promoteConfirm" : "users.roles.demoteConfirm", {
email: u.email,
}),
confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
danger: !makeAdmin,
});
if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user", email: u.email });
};
const onSuspend = async (u: AdminUserRow) => {
const willSuspend = !u.is_suspended;
const ok = await confirm({
title: t(willSuspend ? "users.suspend.title" : "users.suspend.undoTitle"),
message: t(willSuspend ? "users.suspend.confirm" : "users.suspend.undoConfirm", {
email: u.email,
}),
confirmLabel: t(willSuspend ? "users.suspend.action" : "users.suspend.undoAction"),
danger: willSuspend,
});
if (ok) suspend.mutate({ id: u.id, suspended: willSuspend, email: u.email });
};
const onDelete = async (u: AdminUserRow) => {
const ok = await confirm({
title: t("users.delete.title"),
message: t("users.delete.confirm", { email: u.email }),
confirmLabel: t("users.delete.action"),
danger: true,
});
if (ok) del.mutate({ id: u.id, email: u.email });
};
const rows = q.data ?? [];
return (
<Section title={t("users.roles.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
) : (
<div className="space-y-1.5">
{rows.map((u) => {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">
{u.display_name ?? u.email.split("@")[0]}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-[11px] text-muted truncate">{u.email}</div>
</div>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{/* A Google sign-in proves the email, so "unverified" only applies to a
password-only account that hasn't clicked its verification link. */}
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
hint={
u.is_demo
? t("users.roles.demoLocked")
: self
? t("users.roles.selfLocked")
: u.role === "admin"
? t("users.roles.makeUser")
: t("users.roles.makeAdmin")
}
>
<button
onClick={() => onToggle(u)}
disabled={u.is_demo || self || setRole.isPending}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
>
<Shield className="w-3.5 h-3.5" />
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</button>
</Tooltip>
<Tooltip
hint={
u.is_demo
? t("users.suspend.demoLocked")
: self
? t("users.suspend.selfLocked")
: u.is_suspended
? t("users.suspend.undoAction")
: t("users.suspend.action")
}
>
<button
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
? "border-amber-500/40 text-amber-500 hover:bg-amber-500/10"
: "border-border text-muted hover:text-amber-500"
}`}
>
{u.is_suspended ? <RotateCcw className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
</button>
</Tooltip>
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
})}
</div>
)}
</Section>
);
}
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin-invites"] });
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
};
const approve = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
onSuccess: (_d, vars) => {
refresh();
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
},
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: () => {
setNewEmail("");
refresh();
notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
},
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
});
const list = invites.data ?? [];
const pending = list.filter((i) => i.status === "pending");
const decided = list.filter((i) => i.status !== "pending");
return (
<Section title={t("settings.invites.title")}>
{pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
{pending.map((i) => (
<InviteRow
key={i.id}
inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2 mt-3"
>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.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.invites.add")}
</button>
</form>
{decided.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-muted cursor-pointer">
{t("settings.invites.decided", { count: decided.length })}
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
{i.status}
</span>
</div>
))}
</div>
</details>
)}
</Section>
);
}
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,
onDeny,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
return (
<div 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">{inv.email}</div>
{inv.requested_at && (
<div className="text-[11px] text-muted">
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
})}
</div>
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<button
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<button
onClick={onDeny}
disabled={busy}
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.invites.deny")}
>
<X className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
}

View file

@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
type BannerTone = "accent" | "warning";
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
accent: { bar: "bg-accent/15 border-accent/30", icon: "text-accent", action: "bg-accent text-accent-fg" },
warning: { bar: "bg-amber-500/15 border-amber-500/30", icon: "text-amber-500", action: "bg-amber-500 text-black" },
};
// Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this;
// a future news-ticker variant can build on the same shell (rotating content as children).
export default function Banner({
tone,
icon: Icon,
children,
action,
onDismiss,
dismissTitle,
}: {
tone: BannerTone;
icon: LucideIcon;
children: ReactNode;
action?: { label: string; onClick: () => void };
onDismiss?: () => void;
dismissTitle?: string;
}) {
const c = TONES[tone];
return (
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span>
{action && (
<button
onClick={action.onClick}
className={`ml-1 px-2.5 py-1 rounded-lg font-semibold hover:opacity-90 transition ${c.action}`}
>
{action.label}
</button>
)}
{onDismiss && (
<button
onClick={onDismiss}
title={dismissTitle}
className="ml-auto p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}

View file

@ -7,6 +7,7 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import { useConfirm } from "./ConfirmProvider";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but // The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches // that they don't subscribe to. Subscribing here only creates the subscription + enriches
@ -20,6 +21,7 @@ export default function ChannelDiscovery({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
const query = useQuery({ const query = useQuery({
queryKey: ["discovered-channels"], queryKey: ["discovered-channels"],
@ -60,6 +62,17 @@ export default function ChannelDiscovery({
}, },
}); });
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
// first (mirrors the unsubscribe guard in the Subscriptions tab).
const onSubscribe = async (c: DiscoveredChannel) => {
const ok = await confirm({
title: t("channels.discovery.subscribe"),
message: t("channels.discovery.confirmSubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.discovery.subscribe"),
});
if (ok) subscribe.mutate(c);
};
const rows = query.data ?? []; const rows = query.data ?? [];
const columns: Column<DiscoveredChannel>[] = [ const columns: Column<DiscoveredChannel>[] = [
@ -87,6 +100,19 @@ export default function ChannelDiscovery({
</span> </span>
), ),
}, },
{
key: "videos",
header: t("channels.discovery.cols.totalVideos"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.video_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.video_count != null ? formatViews(c.video_count) : "—"}
</span>
),
},
{ {
key: "inPlaylists", key: "inPlaylists",
header: t("channels.discovery.cols.inPlaylists"), header: t("channels.discovery.cols.inPlaylists"),
@ -123,7 +149,7 @@ export default function ChannelDiscovery({
} }
> >
<button <button
onClick={() => subscribe.mutate(c)} onClick={() => onSubscribe(c)}
disabled={!canWrite || subscribe.isPending} disabled={!canWrite || subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition" className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
> >

View file

@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, Save, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "quota", "backfill", "shorts", "batch"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig });
const data = q.data;
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]);
// Baseline draft from the server: non-secrets show their effective value; secrets start empty
// (write-only — we never load them back).
const baseline = useMemo(() => {
const m: Record<string, string> = {};
for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? "");
return m;
}, [items]);
const [draft, setDraft] = useState<Record<string, string>>({});
// Secrets the admin marked to reset-to-default on the next Save (can't be expressed by
// clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab("siftlode.configTab");
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
useEffect(() => {
setDraft(baseline);
setSecretReset({});
}, [baseline]);
const isDirty = (it: ConfigItem): boolean => {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
return (draft[it.key] ?? "") !== (baseline[it.key] ?? "");
};
const dirtyKeys = items.filter(isDirty).map((i) => i.key);
const dirty = dirtyKeys.length > 0;
async function save() {
setSaveState("saving");
try {
for (const key of dirtyKeys) {
const it = byKey[key];
const raw = draft[key] ?? "";
if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
else if (raw.trim()) await api.setConfig(key, raw);
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
await qc.invalidateQueries({ queryKey: ["admin-config"] });
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch {
setSaveState("error");
notify({ level: "error", message: t("config.saveFailed") });
}
}
function discard() {
setDraft(baseline);
setSecretReset({});
setSaveState("idle");
}
const testEmail = useMutation({
mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
onError: () => notify({ level: "error", message: t("config.testFailed") }),
});
if (q.isLoading || !data) {
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
}
const groupKeys = Object.keys(data.groups).sort(
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
const groupTabs = groupKeys.map((g) => ({
id: g,
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
}));
return (
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
{data.groups[activeGroup].map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.testEmailHint")}>
<button
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</button>
</Tooltip>
</div>
)}
</div>
)}
{(dirty || saveState !== "idle") && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">
<span className="text-sm text-muted">
{saveState === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5">
<Check className="w-4 h-4" />
{t("config.saved")}
</span>
) : saveState === "error" ? (
<span className="text-red-500">{t("config.saveFailed")}</span>
) : (
t("config.unsaved")
)}
</span>
<div className="flex items-center gap-2">
<button
onClick={discard}
disabled={saveState === "saving" || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{t("config.discard")}
</button>
<button
onClick={save}
disabled={saveState === "saving" || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saveState === "saving" ? t("config.saving") : t("config.save")}
</button>
</div>
</div>
)}
</div>
);
}
function Field({
item,
value,
secretsManageable,
pendingSecretReset,
onChange,
onResetField,
}: {
item: ConfigItem;
value: string;
secretsManageable: boolean;
pendingSecretReset: boolean;
onChange: (v: string) => void;
onResetField: () => void;
}) {
const { t } = useTranslation();
const isNum = item.type === "int";
const isBool = item.type === "bool";
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
let status: string;
if (item.secret) {
status = secretDisabled
? t("config.secretsDisabled")
: pendingSecretReset
? t("config.willReset")
: item.is_set
? t("config.secretSet")
: item.default_is_set
? t("config.secretEnv")
: t("config.secretUnset");
} else {
status = item.is_set ? t("config.overridden") : t("config.usingDefault");
}
// Reset affordance shows when a DB override exists: clears a non-secret to its default, or
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
// its own control — storing its value is equivalent to the default, so no reset link.
const showReset = item.is_set && !isBool;
return (
<div className="flex items-start justify-between gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")}
</p>
<p className="text-[11px] text-muted mt-1">{status}</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? (
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
) : (
<input
type={item.secret ? "password" : isNum ? "number" : "text"}
value={value}
disabled={secretDisabled || (item.secret && pendingSecretReset)}
onChange={(e) => onChange(e.target.value)}
min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
/>
)}
{showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}>
<button
onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${
item.secret && pendingSecretReset
? "text-accent"
: "text-muted hover:text-fg"
}`}
>
<RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")}
</button>
</Tooltip>
)}
</div>
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}

View file

@ -1,17 +1,15 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import Banner from "./Banner";
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the // Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.) // state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
export default function DemoBanner() { export default function DemoBanner() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="shrink-0 flex items-center gap-2 px-4 py-2 text-sm bg-amber-500/15 border-b border-amber-500/30 text-fg"> <Banner tone="warning" icon={AlertTriangle}>
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
<span className="min-w-0">
<span className="font-semibold">{t("common.demoTitle")}</span> <span className="font-semibold">{t("common.demoTitle")}</span>
<span className="text-muted"> {t("common.demoSharedNotice")}</span> <span className="text-muted"> {t("common.demoSharedNotice")}</span>
</span> </Banner>
</div>
); );
} }

View file

@ -75,6 +75,10 @@ export default function Header({
? t("settings.title") ? t("settings.title")
: page === "scheduler" : page === "scheduler"
? t("header.scheduler") ? t("header.scheduler")
: page === "config"
? t("header.configuration")
: page === "users"
? t("header.users")
: t("header.channelManager")} : t("header.channelManager")}
</div> </div>
)} )}

View file

@ -1,122 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
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();
// A denied Google login bounces back here with ?access=requested (we recorded it) or
// ?access=denied (no usable email). Surface that so the user isn't left on a raw error.
const params = new URLSearchParams(window.location.search);
const bounced = params.get("access"); // "requested" | "denied" | null
const [email, setEmail] = useState("");
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("");
setPhase("sending");
try {
const r = await api.requestAccess(email.trim());
setPhase(r.status === "approved" ? "approved" : "requested");
} catch {
setError(t("login.submitError"));
setPhase("error");
}
}
const done = phase === "requested" || phase === "approved";
return (
<div className="min-h-screen grid place-items-center bg-bg text-fg">
<div className="absolute top-4 right-4">
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</div>
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
<div className="text-3xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<p className="text-muted my-5 leading-relaxed">{t("login.tagline")}</p>
<a
href="/auth/login"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("login.signIn")}
</a>
<div className="mt-7 pt-6 border-t border-border text-left">
{phase === "approved" ? (
<p className="text-sm text-fg/80">{t("login.approved")}</p>
) : done ? (
<p className="text-sm text-fg/80">{t("login.requested")}</p>
) : (
<>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("login.noAccessYet")}
</div>
{bounced === "denied" && (
<p className="text-xs text-muted mb-2">{t("login.denied")}</p>
)}
<form onSubmit={submit} className="flex items-center gap-2">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("login.emailPlaceholder")}
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"
disabled={phase === "sending"}
className="shrink-0 px-3 py-2 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
>
{phase === "sending" ? t("login.sending") : t("login.requestAccess")}
</button>
</form>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
</>
)}
</div>
</div>
<footer className="mt-5 flex gap-4 text-xs text-muted">
<a href="/privacy" className="hover:text-fg transition">
{t("common.privacyPolicy")}
</a>
<a href="/terms" className="hover:text-fg transition">
{t("common.termsOfService")}
</a>
</footer>
</div>
);
}

View file

@ -14,6 +14,8 @@ import {
LogOut, LogOut,
Settings, Settings,
Shield, Shield,
SlidersHorizontal,
Users,
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
@ -133,12 +135,15 @@ export default function NavSidebar({
{ page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread }, { page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
]; ];
const systemItems: NavItem[] = const systemItems: NavItem[] =
me.role === "admin" me.role === "admin"
? [ ? [
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, { page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
{ page: "users", icon: Users, label: t("header.account.users") },
] ]
: []; : [];

View file

@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, X } from "lucide-react"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { import {
@ -190,6 +190,7 @@ export default function NotificationsPanel({
onFind={locate} onFind={locate}
onRevert={revertState} onRevert={revertState}
onFocusChannel={onFocusChannel} onFocusChannel={onFocusChannel}
onReviewAccess={() => setPage("users")}
onRemove={() => removeClient(n.id)} onRemove={() => removeClient(n.id)}
/> />
))} ))}
@ -306,6 +307,7 @@ function ClientActivityRow({
onFind, onFind,
onRevert, onRevert,
onFocusChannel, onFocusChannel,
onReviewAccess,
onRemove, onRemove,
}: { }: {
n: ClientNotif; n: ClientNotif;
@ -313,6 +315,7 @@ function ClientActivityRow({
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
onReviewAccess: () => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { icon: Icon, color } = LEVEL_STYLE[n.level]; const { icon: Icon, color } = LEVEL_STYLE[n.level];
@ -321,6 +324,7 @@ function ClientActivityRow({
const watched = n.meta?.kind === "video-watched" ? n.meta : null; const watched = n.meta?.kind === "video-watched" ? n.meta : null;
const subscribed: ChannelSubscribedMeta | null = const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null; n.meta?.kind === "channel-subscribed" ? n.meta : null;
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return ( return (
<div <div
className={`glass rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`} className={`glass rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
@ -390,6 +394,14 @@ function ClientActivityRow({
{t("notifications.openOnYouTube")} {t("notifications.openOnYouTube")}
</a> </a>
</div> </div>
) : access ? (
<button
onClick={() => onReviewAccess()}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
>
<UserPlus className="w-3.5 h-3.5" />
{t("common.accessRequestsReview")}
</button>
) : ( ) : (
n.action && n.action &&

View file

@ -1,10 +1,9 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react"; import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } 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 Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -32,11 +31,10 @@ export interface PrefsController {
saveState: PrefsSaveState; saveState: PrefsSaveState;
} }
type TabId = "appearance" | "notifications" | "sync" | "account"; type TabId = "appearance" | "notifications" | "account";
const TABS: { id: TabId; icon: typeof Monitor }[] = [ const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor }, { id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell }, { id: "notifications", icon: Bell },
{ id: "sync", icon: RefreshCw },
{ id: "account", icon: User }, { id: "account", icon: User },
]; ];
@ -98,7 +96,6 @@ export default function SettingsPanel({
<div className="flex-1 min-w-0 p-4"> <div className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />} {tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />} {tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "sync" && <Sync me={me} />}
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />} {tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div> </div>
</div> </div>
@ -317,158 +314,6 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
); );
} }
function Sync({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({
level: "success",
message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }),
});
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({
level: "success",
message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }),
});
return (
<>
<Section title={t("settings.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row
label={t("settings.sync.recentSynced")}
hint={t("settings.sync.recentSyncedHint")}
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row
label={t("settings.sync.fullHistory")}
hint={t("settings.sync.fullHistoryHint")}
>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label={t("settings.sync.fullHistoryEta")}
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row
label={t("settings.sync.quotaLeft")}
hint={t("settings.sync.quotaLeftHint")}
>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
)}
</Section>
<Section title={t("settings.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">{t("settings.sync.byAction")}</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
)}
</Section>
{!me.is_demo && (
<Section title={t("settings.sync.actions")}>
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
<button
onClick={() => syncSubs.mutate()}
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")}
</button>
</Tooltip>
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
<button
onClick={() => deepAll.mutate()}
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")}
</button>
</Tooltip>
</Section>
)}
{me.role === "admin" && s && (
<Section title={t("settings.sync.admin")}>
<Tooltip hint={t("settings.sync.adminPauseHint")}>
<button
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused
? t("settings.sync.resumeBackgroundSync")
: t("settings.sync.pauseBackgroundSync")}
</button>
</Tooltip>
</Section>
)}
</>
);
}
function AccessRow({ function AccessRow({
title, title,
granted, granted,
@ -509,8 +354,144 @@ function AccessRow({
); );
} }
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
// directly with inline errors. Demo accounts never see this (handled by the caller).
function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setBusy(true);
try {
await api.setPassword(next, me.has_password ? current : undefined);
setOpen(false);
setCurrent("");
setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password".
await qc.invalidateQueries({ queryKey: ["me"] });
notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }) });
} catch (e: any) {
setErr(e?.detail ?? t("settings.account.password.failed"));
} finally {
setBusy(false);
}
};
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
return (
<Section title={t("settings.account.signInMethods")}>
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_google
? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")}
</p>
</div>
{me.has_google ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
{t("settings.account.googleLink.connected")}
</span>
) : (
<button
onClick={() => {
window.location.href = "/auth/link";
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.googleLink.connect")}
</button>
)}
</div>
<div className="border-t border-border mt-1 pt-1">
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_password
? t("settings.account.password.setHint")
: t("settings.account.password.unsetHint")}
</p>
</div>
<button
onClick={() => {
setOpen((o) => !o);
setErr(null);
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
</button>
</div>
{open && (
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
{me.has_password && (
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder={t("settings.account.password.current")}
autoComplete="current-password"
className={inputCls}
/>
)}
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder={t("settings.account.password.new")}
autoComplete="new-password"
className={inputCls}
/>
{err && <p className="text-xs text-red-400">{err}</p>}
<button
type="submit"
disabled={busy || !next}
className="px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</button>
</form>
)}
</div>
</Section>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm();
const onDeleteAccount = async () => {
const ok = await confirm({
title: t("settings.account.deleteTitle"),
message: t("settings.account.deleteConfirm"),
confirmLabel: t("settings.account.deleteConfirmButton"),
danger: true,
});
if (!ok) return;
try {
await api.deleteAccount();
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
}
};
return ( return (
<> <>
<Section title={t("settings.account.title")}> <Section title={t("settings.account.title")}>
@ -528,6 +509,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div> </div>
</Section> </Section>
{!me.is_demo && <SignInMethods me={me} />}
{me.is_demo ? ( {me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}> <Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed"> <p className="text-xs text-muted leading-relaxed">
@ -567,265 +550,19 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</button> </button>
</Section> </Section>
)} )}
{me.role === "admin" && <AdminInvites />}
{me.role === "admin" && <AdminDemo />} {!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.account.deleteHint")}</p>
<button
onClick={onDeleteAccount}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-red-500/40 text-red-400 hover:bg-red-500/10 transition"
>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Section>
)}
</> </>
); );
} }
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin-invites"] });
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
};
const approve = useMutation({
mutationFn: (id: number) => api.approveInvite(id),
onSuccess: () => {
refresh();
notify({ level: "success", message: t("settings.invites.approved") });
},
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: () => {
setNewEmail("");
refresh();
notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
},
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
});
const list = invites.data ?? [];
const pending = list.filter((i) => i.status === "pending");
const decided = list.filter((i) => i.status !== "pending");
return (
<Section title={t("settings.invites.title")}>
{pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
{pending.map((i) => (
<InviteRow
key={i.id}
inv={i}
onApprove={() => approve.mutate(i.id)}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2 mt-3"
>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.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.invites.add")}
</button>
</form>
{decided.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-muted cursor-pointer">
{t("settings.invites.decided", { count: decided.length })}
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
{i.status}
</span>
</div>
))}
</div>
</details>
)}
</Section>
);
}
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,
onDeny,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
return (
<div 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">{inv.email}</div>
{inv.requested_at && (
<div className="text-[11px] text-muted">
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
})}
</div>
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<button
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<button
onClick={onDeny}
disabled={busy}
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.invites.deny")}
>
<X className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
}

View file

@ -1,24 +1,236 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api"; import { History, Pause, Play, RefreshCw } from "lucide-react";
import { quotaActionLabel } from "../lib/format"; import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
const RANGES = [7, 30, 90] as const; const RANGES = [7, 30, 90] as const;
const STATS_TAB_KEY = "siftlode.statsTab";
type StatsTab = "overview" | "system";
export default function Stats() { // Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
export default function Stats({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tab, setTabState] = useState<StatsTab>(() => {
const v = localStorage.getItem(STATS_TAB_KEY);
return v === "system" && isAdmin ? "system" : "overview";
});
const setTab = (id: StatsTab) => {
setTabState(id);
localStorage.setItem(STATS_TAB_KEY, id);
};
return (
<div className="p-4 max-w-4xl mx-auto">
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
{isAdmin && (
<div className="flex items-center gap-1 mb-4">
{(["overview", "system"] as StatsTab[]).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
tab === id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(`stats.tabs.${id}`)}
</button>
))}
</div>
)}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }) });
},
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({
level: "success",
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }),
});
return (
<>
<Section title={t("stats.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
)}
</Section>
<Section title={t("stats.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">{t("stats.sync.byAction")}</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="text-muted text-sm">{t("stats.sync.noUsage")}</div>
)}
</Section>
{!me.is_demo && (
<Section title={t("stats.sync.actions")}>
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<button
onClick={() => syncSubs.mutate()}
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("stats.sync.syncSubscriptions")}
</button>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<button
onClick={() => deepAll.mutate()}
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("stats.sync.backfillEverything")}
</button>
</Tooltip>
</Section>
)}
</>
);
}
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
function SystemStats() {
const { t } = useTranslation();
const qc = useQueryClient();
const [days, setDays] = useState<number>(30); const [days, setDays] = useState<number>(30);
const q = useQuery({ const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) });
queryKey: ["admin-quota", days], const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
queryFn: () => api.adminQuota(days), const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
}); });
const data = q.data; const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0); const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
const s = status.data;
return ( return (
<div className="p-4 max-w-4xl mx-auto"> <>
{s && (
<Section title={t("stats.sync.admin")}>
<Tooltip hint={t("stats.sync.adminPauseHint")}>
<button
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? t("stats.sync.resumeBackgroundSync") : t("stats.sync.pauseBackgroundSync")}
</button>
</Tooltip>
</Section>
)}
<div className="flex items-center justify-between gap-3 mb-4"> <div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">{t("stats.title")}</h2> <h2 className="text-lg font-semibold">{t("stats.title")}</h2>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -53,25 +265,17 @@ export default function Stats() {
{/* Daily totals (instance-wide, Pacific days) */} {/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4"> <div className="glass-card rounded-xl p-3 mb-4">
<div className="text-xs text-muted mb-2"> <div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", { {t("stats.dailyTotal", { count: data.range_days, units: grandTotal.toLocaleString() })}
count: data.range_days,
units: grandTotal.toLocaleString(),
})}
</div> </div>
{data.daily.length === 0 ? ( {data.daily.length === 0 ? (
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div> <div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : ( ) : (
<div className="flex items-end gap-0.5 h-24"> <div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => ( {data.daily.map((d) => (
// Each day gets a full-height track so the column is always visible
// (even on near-zero days); the accent fill renders the value on top.
<div <div
key={d.day} key={d.day}
className="flex-1 h-full flex items-end bg-card/50 rounded-t" className="flex-1 h-full flex items-end bg-card/50 rounded-t"
title={t("stats.dailyTooltip", { title={t("stats.dailyTooltip", { day: d.day, units: d.total.toLocaleString() })}
day: d.day,
units: d.total.toLocaleString(),
})}
> >
<div <div
className="w-full bg-accent hover:opacity-80 rounded-t transition" className="w-full bg-accent hover:opacity-80 rounded-t transition"
@ -95,7 +299,7 @@ export default function Stats() {
</div> </div>
</> </>
)} )}
</div> </>
); );
} }
@ -106,16 +310,11 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const isSystem = row.user_id === null; const isSystem = row.user_id === null;
return ( return (
<div className="glass-card rounded-xl p-3"> <div className="glass-card rounded-xl p-3">
<button <button onClick={() => setOpen((o) => !o)} className="w-full flex items-center gap-3 text-left">
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 text-left"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}> <span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? t("stats.system") : row.email} {isSystem ? t("stats.system") : row.email}
</span> </span>
<span className="text-sm font-semibold tabular-nums shrink-0"> <span className="text-sm font-semibold tabular-nums shrink-0">{row.total.toLocaleString()}</span>
{row.total.toLocaleString()}
</span>
</button> </button>
{/* Proportion bar */} {/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden"> <div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">

View file

@ -0,0 +1,62 @@
import { useState } from "react";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
// per the app's "persist UI state across reload" convention.
export interface TabDef {
id: string;
label: string;
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
}
/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
const setTab = (id: string) => {
setTabState(id);
localStorage.setItem(storageKey, id);
};
return [tab, setTab];
}
export default function Tabs({
tabs,
active,
onChange,
}: {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1 mb-4">
{tabs.map((tb) => {
const on = tb.id === active;
return (
<button
key={tb.id}
onClick={() => onChange(tb.id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${
on
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
{tb.label}
{tb.badge != null && tb.badge > 0 && (
<span
className={`text-[11px] leading-none px-1.5 py-0.5 rounded-full ${
on ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{tb.badge}
</span>
)}
</button>
);
})}
</div>
);
}

View file

@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Sparkles, X } from "lucide-react"; import { Sparkles } from "lucide-react";
import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version"; import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version";
import Banner from "./Banner";
// Eye-catching, dismissible bar shown once after the running build's version changes // Eye-catching, dismissible bar shown once after the running build's version changes
// (compares the baked frontend version to the last one the user acknowledged). // (compares the baked frontend version to the last one the user acknowledged).
@ -19,25 +20,20 @@ export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
} }
return ( return (
<div className="shrink-0 flex items-center gap-2 px-4 py-2 text-sm bg-accent/15 border-b border-accent/30 text-fg"> <Banner
<Sparkles className="w-4 h-4 text-accent shrink-0" /> tone="accent"
<span className="min-w-0">{t("header.banner.updated", { version: FRONTEND_VERSION })}</span> icon={Sparkles}
<button action={{
onClick={() => { label: t("header.banner.releaseNotes"),
onClick: () => {
onOpen(); onOpen();
markSeen(); markSeen();
},
}} }}
className="ml-1 px-2.5 py-1 rounded-lg font-semibold bg-accent text-accent-fg hover:opacity-90 transition" onDismiss={markSeen}
dismissTitle={t("header.banner.dismiss")}
> >
{t("header.banner.releaseNotes")} {t("header.banner.updated", { version: FRONTEND_VERSION })}
</button> </Banner>
<button
onClick={markSeen}
title={t("header.banner.dismiss")}
className="ml-auto p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
); );
} }

View file

@ -0,0 +1,472 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Expand,
Filter,
Inbox,
ListVideo,
Lock,
PlayCircle,
Tags,
X,
type LucideIcon,
} from "lucide-react";
import { api } from "../lib/api";
import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
type Mode = "signin" | "register" | "forgot" | "reset";
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
// backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome() {
const { t, i18n } = useTranslation();
// Which screenshot (if any) is open in the full-size lightbox.
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
const open = (src: string, label: string) => setZoomed({ src, label });
return (
<div className="min-h-screen bg-bg text-fg overflow-y-auto">
<header className="flex items-center justify-between px-5 sm:px-8 py-4">
<div className="text-xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</header>
<main className="max-w-6xl mx-auto px-5 sm:px-8 pb-16">
{/* Hero: pitch + auth card */}
<section className="grid lg:grid-cols-2 gap-10 items-center py-8 lg:py-14">
<div className="max-w-xl">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-tight">
{t("welcome.hero.title")}
</h1>
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
</div>
<AuthCard />
</section>
{/* App preview */}
<Preview
src="/welcome/feed.png"
label={t("welcome.preview.feed")}
className="aspect-video"
onOpen={open}
/>
{/* Features */}
<section className="mt-14">
<h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{FEATURES.map((f) => (
<FeatureCard key={f.key} icon={f.icon} k={f.key} />
))}
</div>
</section>
{/* Secondary showcase — smaller thumbnails; click to view full size. */}
<section className="mt-12 flex flex-wrap justify-center gap-4">
<Preview
src="/welcome/channels.png"
label={t("welcome.preview.channels")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
<Preview
src="/welcome/playlists.png"
label={t("welcome.preview.playlists")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
</section>
</main>
{zoomed && (
<Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />
)}
<footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center">
<span>
Sift<span className="text-accent">lode</span>
</span>
<span className="hidden sm:inline">·</span>
<a href="/privacy" className="hover:text-fg transition">
{t("common.privacyPolicy")}
</a>
<a href="/terms" className="hover:text-fg transition">
{t("common.termsOfService")}
</a>
</footer>
</div>
);
}
const FEATURES: { key: string; icon: LucideIcon }[] = [
{ key: "readable", icon: Filter },
{ key: "neverMiss", icon: Inbox },
{ key: "playlists", icon: ListVideo },
{ key: "player", icon: PlayCircle },
{ key: "channels", icon: Tags },
{ key: "private", icon: Lock },
];
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
const { t } = useTranslation();
return (
<div className="glass-card rounded-2xl p-5">
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center mb-3">
<Icon className="w-5 h-5" />
</div>
<h3 className="font-semibold">{t(`welcome.features.${k}.title`)}</h3>
<p className="text-sm text-muted leading-relaxed mt-1">{t(`welcome.features.${k}.body`)}</p>
</div>
);
}
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
// frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/).
// When loaded it's a button that opens the full-size lightbox (onOpen).
function Preview({
src,
label,
className,
onOpen,
}: {
src: string;
label: string;
className?: string;
onOpen?: (src: string, label: string) => void;
}) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label}
</div>
</div>
);
}
return (
<button
type="button"
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
>
<img
src={src}
alt={label}
loading="lazy"
onError={() => setFailed(true)}
className="w-full h-full object-cover object-top"
/>
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
</span>
</button>
);
}
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; // no background scroll while open
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
return (
<div
className="fixed inset-0 z-[60] grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={label}
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
>
<X className="w-5 h-5" />
</button>
<figure
className="flex max-h-full max-w-full flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
>
<img
src={src}
alt={label}
className="max-h-[82vh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
/>
<figcaption className="text-sm text-muted">{label}</figcaption>
</figure>
</div>
);
}
function AuthCard() {
const { t } = useTranslation();
// Snapshot the redirect params ONCE so we can clean them from the address bar below without
// dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL).
const [params] = useState(() => new URLSearchParams(window.location.search));
const resetToken = params.get("reset");
const bounced = params.get("access"); // Google denied → "requested" | "denied"
const verify = params.get("verify"); // "ok" | "invalid"
const login = params.get("login"); // Google login blocked → "suspended"
const deleted = params.get("deleted"); // self-service account deletion just completed
// Strip the query string so the address bar stays clean (http://host/) after we've captured it.
useEffect(() => {
if (window.location.search) {
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState<string>(""); // success/info message replacing the form
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
const [showDemo, setShowDemo] = useState(false);
const [demoEmail, setDemoEmail] = useState("");
const [demoBusy, setDemoBusy] = useState(false);
const [demoError, setDemoError] = useState("");
const reset = () => {
setError("");
setDone("");
};
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
reset();
const mail = email.trim().toLowerCase();
if ((mode === "signin" || mode === "register" || mode === "forgot") && !EMAIL_RE.test(mail)) {
setError(t("welcome.auth.invalidEmail"));
return;
}
if ((mode === "register" || mode === "reset") && password.length < PASSWORD_MIN) {
setError(t("welcome.auth.weakPassword", { min: PASSWORD_MIN }));
return;
}
setBusy(true);
try {
if (mode === "signin") {
await api.passwordLogin(mail, password);
window.location.reload();
return;
}
if (mode === "register") {
await api.register(mail, password);
setDone(t("welcome.auth.registerDone"));
} else if (mode === "forgot") {
await api.requestPasswordReset(mail);
setDone(t("welcome.auth.forgotDone"));
} else if (mode === "reset") {
await api.confirmPasswordReset(resetToken ?? "", password);
setDone(t("welcome.auth.resetDone"));
}
} catch (err) {
setError(err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"));
} finally {
setBusy(false);
}
}
async function onDemo(e: React.FormEvent) {
e.preventDefault();
setDemoError("");
const mail = demoEmail.trim().toLowerCase();
if (!EMAIL_RE.test(mail)) {
setDemoError(t("welcome.auth.invalidEmail"));
return;
}
setDemoBusy(true);
try {
const r = await api.demoLogin(mail);
if (r.authenticated) window.location.reload();
else setDemoError(t("welcome.auth.demoDenied"));
} catch {
setDemoError(t("welcome.auth.demoDenied"));
} finally {
setDemoBusy(false);
}
}
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
const titleKey =
mode === "register"
? "welcome.auth.createTitle"
: mode === "forgot"
? "welcome.auth.forgotTitle"
: mode === "reset"
? "welcome.auth.resetTitle"
: "welcome.auth.signinTitle";
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? (
<div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
<button
onClick={() => {
setMode("signin");
setDone("");
}}
className="mt-4 text-sm text-accent hover:underline"
>
{t("welcome.auth.backToSignin")}
</button>
</div>
) : (
<>
<form onSubmit={onSubmit} className="flex flex-col gap-3 mt-3">
{mode !== "reset" && (
<input
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("welcome.auth.email")}
className={inputCls}
/>
)}
{mode !== "forgot" && (
<input
type="password"
autoComplete={mode === "signin" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={
mode === "reset" || mode === "register"
? t("welcome.auth.newPassword", { min: PASSWORD_MIN })
: t("welcome.auth.password")
}
className={inputCls}
/>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
<button
type="submit"
disabled={busy}
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{busy
? t("welcome.auth.working")
: t(
mode === "register"
? "welcome.auth.createButton"
: mode === "forgot"
? "welcome.auth.forgotButton"
: mode === "reset"
? "welcome.auth.resetButton"
: "welcome.auth.signinButton"
)}
</button>
</form>
{/* Mode switches */}
<div className="flex items-center justify-between mt-3 text-xs">
{mode === "signin" ? (
<>
<button onClick={() => { reset(); setMode("register"); }} className="text-accent hover:underline">
{t("welcome.auth.createLink")}
</button>
<button onClick={() => { reset(); setMode("forgot"); }} className="text-muted hover:text-fg">
{t("welcome.auth.forgotLink")}
</button>
</>
) : (
<button onClick={() => { reset(); setMode("signin"); }} className="text-accent hover:underline">
{t("welcome.auth.backToSignin")}
</button>
)}
</div>
{mode === "signin" && (
<>
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{t("welcome.auth.or")}
<span className="h-px flex-1 bg-border" />
</div>
<a
href="/auth/login"
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
>
{t("welcome.auth.google")}
</a>
{!showDemo ? (
<button
onClick={() => setShowDemo(true)}
className="w-full mt-2 px-4 py-2.5 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
{t("welcome.auth.tryDemo")}
</button>
) : (
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
<p className="text-[11px] text-muted">{t("welcome.auth.demoHint")}</p>
<div className="flex gap-2">
<input
type="email"
value={demoEmail}
onChange={(e) => setDemoEmail(e.target.value)}
placeholder={t("welcome.auth.demoEmail")}
className={inputCls}
/>
<button
type="submit"
disabled={demoBusy}
className="shrink-0 px-3 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
>
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
</button>
</div>
{demoError && <p className="text-xs text-red-400">{demoError}</p>}
</form>
)}
</>
)}
</>
)}
</div>
);
}
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-amber-500/15 text-fg";
return <div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>;
}

View file

@ -19,7 +19,9 @@
"subscribedTitle": "Auf YouTube abonniert", "subscribedTitle": "Auf YouTube abonniert",
"subscribedBody": "Du folgst jetzt {{name}} — neue Uploads erscheinen in deinem Feed.", "subscribedBody": "Du folgst jetzt {{name}} — neue Uploads erscheinen in deinem Feed.",
"subscribeFailed": "Abonnieren fehlgeschlagen", "subscribeFailed": "Abonnieren fehlgeschlagen",
"confirmSubscribe": "„{{name}}“ auf YouTube abonnieren? Das ändert dein echtes YouTube-Konto und verbraucht etwas API-Kontingent.",
"cols": { "cols": {
"totalVideos": "Videos",
"inPlaylists": "In Playlists", "inPlaylists": "In Playlists",
"inPlaylistsHint": "{{videos}} Video(s) von diesem Kanal in {{playlists}} deiner Playlist(s)." "inPlaylistsHint": "{{videos}} Video(s) von diesem Kanal in {{playlists}} deiner Playlist(s)."
} }

View file

@ -12,7 +12,8 @@
"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 auf der Benutzer-Seite.",
"accessRequestsReview": "Überprüfen",
"demoTitle": "Demo-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." "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

@ -0,0 +1,50 @@
{
"intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.",
"loading": "Konfiguration wird geladen…",
"groups": {
"access": "Zugang",
"email": "E-Mail / SMTP",
"youtube": "YouTube-API",
"quota": "Kontingent",
"backfill": "Nachladen (Backfill)",
"shorts": "Shorts-Prüfung",
"batch": "Stapelgrößen"
},
"fields": {
"smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" },
"smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." },
"smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." },
"smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername." },
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." },
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." },
"backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." },
"backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." },
"backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." },
"shorts_probe_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." },
"shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." },
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
},
"save": "Speichern",
"saving": "Speichern…",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"unsaved": "Nicht gespeicherte Änderungen",
"discard": "Verwerfen",
"willReset": "wird beim Speichern auf den Standard zurückgesetzt",
"reset": "Zurücksetzen",
"resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.",
"resetDone": "Auf Standard zurückgesetzt",
"usingDefault": "Standard wird verwendet",
"overridden": "in der DB überschrieben",
"secretSet": "•••••••• gespeichert",
"secretEnv": "Env-Wert wird verwendet",
"secretUnset": "nicht gesetzt",
"secretsDisabled": "Setze TOKEN_ENCRYPTION_KEY, um Secrets in der Datenbank zu speichern.",
"testEmail": "Test-E-Mail senden",
"testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.",
"testSent": "Test-E-Mail gesendet an {{to}}",
"testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen"
}

View file

@ -4,6 +4,8 @@
"channelManager": "Kanalverwaltung", "channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer", "scheduler": "Planer",
"configuration": "Konfiguration",
"users": "Benutzer",
"scope": { "scope": {
"label": "Feed-Quelle", "label": "Feed-Quelle",
"my": "Meine", "my": "Meine",
@ -18,6 +20,8 @@
"playlists": "Wiedergabelisten", "playlists": "Wiedergabelisten",
"stats": "Statistik", "stats": "Statistik",
"scheduler": "Planer", "scheduler": "Planer",
"config": "Konfiguration",
"users": "Benutzer",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",
"signOut": "Abmelden", "signOut": "Abmelden",

View file

@ -0,0 +1,16 @@
{
"subscriptions_pull": "Abonnements einlesen",
"subscriptions_resync": "Automatische Abo-Resynchronisierung",
"playlists_pull": "Playlists einlesen",
"playlists_push": "Playlists hochladen",
"playlists_delete": "Playlist löschen",
"videos_backfill_recent": "Aktuelle Uploads laden",
"videos_backfill_full": "Vollständigen Verlauf laden",
"videos_enrich": "Video-Anreicherung",
"videos_lookup": "Video-Abfrage",
"channels_discover": "Kanal-Entdeckung",
"channels_subscribe": "Abonnieren",
"channels_unsubscribe": "Abo beenden",
"maintenance_revalidate": "Wartungs-Neuvalidierung",
"other": "Sonstiges"
}

View file

@ -76,7 +76,8 @@
"shorts": "Shorts-Klassifizierung", "shorts": "Shorts-Klassifizierung",
"subscriptions": "Abo-Resync", "subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync", "playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung" "maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",

View file

@ -3,7 +3,6 @@
"tabs": { "tabs": {
"appearance": "Darstellung", "appearance": "Darstellung",
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"sync": "Synchronisierung",
"account": "Konto" "account": "Konto"
}, },
"save": { "save": {
@ -42,43 +41,6 @@
"testTitle": "Testbenachrichtigung", "testTitle": "Testbenachrichtigung",
"testMessage": "So sieht eine Benachrichtigung aus." "testMessage": "So sieht eine Benachrichtigung aus."
}, },
"sync": {
"myStatus": "Mein Synchronisierungsstatus",
"channels": "Kanäle",
"channelsHint": "Wie viele Kanäle du abonniert hast.",
"recentSynced": "Neueste synchronisiert",
"recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.",
"fullHistory": "Vollständiger Verlauf",
"fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).",
"fullHistoryEta": "Vollständiger Verlauf Restzeit",
"fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.",
"myVideos": "Meine Videos",
"myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.",
"quotaLeft": "Heute verbleibendes Kontingent",
"quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.",
"loading": "Wird geladen…",
"apiUsage": "Deine API-Nutzung",
"today": "Heute",
"todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.",
"last7d": "Letzte 7 Tage",
"last30d": "Letzte 30 Tage",
"allTime": "Gesamt",
"byAction": "Nach Aktion (30 Tage)",
"noUsage": "Noch keine Nutzung.",
"actions": "Aktionen",
"syncSubscriptions": "Abos synchronisieren",
"syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.",
"backfillEverything": "Alles nachladen",
"backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.",
"synced": "{{count}} Abos synchronisiert",
"syncFailed": "Synchronisierung fehlgeschlagen",
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"admin": "Admin",
"adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).",
"resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen",
"pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren"
},
"account": { "account": {
"title": "Konto", "title": "Konto",
"youtubeAccess": "YouTube-Zugriff", "youtubeAccess": "YouTube-Zugriff",
@ -93,7 +55,38 @@
"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." "dangerZone": "Gefahrenzone",
"deleteHint": "Löscht dein Konto und alle deine Daten dauerhaft — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen. Kann nicht rückgängig gemacht werden.",
"deleteAccount": "Mein Konto löschen",
"deleteTitle": "Konto löschen?",
"deleteConfirm": "Dies löscht dein Konto und alle deine Daten (Abos, Tags, angesehen/gespeichert/ausgeblendet, Playlists, Einstellungen) dauerhaft. Es kann nicht rückgängig gemacht werden.",
"deleteConfirmButton": "Alles löschen",
"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.",
"signInMethods": "Anmeldemethoden",
"googleLink": {
"title": "Google-Konto",
"connected": "Verbunden",
"connectedHint": "Dein Google-Konto ist verknüpft. Du kannst dich mit Google anmelden und unten den YouTube-Zugriff erteilen.",
"connectHint": "Verknüpfe ein Google-Konto, um dich mit Google anzumelden und den YouTube-Zugriff für deinen Feed zu aktivieren.",
"connect": "Google verknüpfen",
"linked": "Google-Konto verknüpft.",
"conflict": "Dieses Google-Konto ist bereits mit einem anderen Siftlode-Konto verknüpft.",
"mismatch": "Das ist ein anderes Google-Konto als das bereits hier verknüpfte. Melde dich mit dem verknüpften an.",
"error": "Das Google-Konto konnte nicht verknüpft werden. Bitte versuche es erneut."
},
"password": {
"title": "Passwort",
"setHint": "Ein Passwort ist gesetzt. Du kannst dich mit E-Mail und Passwort anmelden.",
"unsetHint": "Noch kein Passwort — lege eines fest, um dich mit deiner E-Mail anzumelden (zusätzlich zu oder anstelle von Google).",
"set": "Passwort festlegen",
"change": "Passwort ändern",
"current": "Aktuelles Passwort",
"new": "Neues Passwort (min. 10 Zeichen)",
"save": "Passwort speichern",
"saving": "Speichern…",
"saved": "Passwort für {{email}} aktualisiert.",
"failed": "Das Passwort konnte nicht aktualisiert werden."
}
}, },
"demo": { "demo": {
"title": "Demo-Zugang", "title": "Demo-Zugang",
@ -119,7 +112,7 @@
"addPlaceholder": "E-Mail direkt hinzufügen…", "addPlaceholder": "E-Mail direkt hinzufügen…",
"add": "Hinzufügen", "add": "Hinzufügen",
"decided": "{{count}} entschieden", "decided": "{{count}} entschieden",
"approved": "Genehmigt — sie können sich jetzt anmelden", "approved": "{{email}} genehmigt — sie können sich jetzt anmelden",
"approveFailed": "Genehmigung fehlgeschlagen", "approveFailed": "Genehmigung fehlgeschlagen",
"denyFailed": "Ablehnung fehlgeschlagen", "denyFailed": "Ablehnung fehlgeschlagen",
"addedToWhitelist": "Zur Whitelist hinzugefügt", "addedToWhitelist": "Zur Whitelist hinzugefügt",

View file

@ -1,4 +1,8 @@
{ {
"tabs": {
"overview": "Übersicht",
"system": "System"
},
"title": "API-Kontingentnutzung", "title": "API-Kontingentnutzung",
"rangeDays": "{{count}} T", "rangeDays": "{{count}} T",
"introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ", "introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ",
@ -9,5 +13,42 @@
"dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)", "dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)",
"noUsageInRange": "Keine Nutzung in diesem Zeitraum.", "noUsageInRange": "Keine Nutzung in diesem Zeitraum.",
"dailyTooltip": "{{day}}: {{units}} Einheiten", "dailyTooltip": "{{day}}: {{units}} Einheiten",
"system": "System (Hintergrund)" "system": "System (Hintergrund)",
"sync": {
"myStatus": "Mein Synchronisierungsstatus",
"channels": "Kanäle",
"channelsHint": "Wie viele Kanäle du abonniert hast.",
"recentSynced": "Neueste synchronisiert",
"recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.",
"fullHistory": "Vollständiger Verlauf",
"fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).",
"fullHistoryEta": "Vollständiger Verlauf Restzeit",
"fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.",
"myVideos": "Meine Videos",
"myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.",
"quotaLeft": "Heute verbleibendes Kontingent",
"quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.",
"loading": "Wird geladen…",
"apiUsage": "Deine API-Nutzung",
"today": "Heute",
"todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.",
"last7d": "Letzte 7 Tage",
"last30d": "Letzte 30 Tage",
"allTime": "Gesamt",
"byAction": "Nach Aktion (30 Tage)",
"noUsage": "Noch keine Nutzung.",
"actions": "Aktionen",
"syncSubscriptions": "Abos synchronisieren",
"syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.",
"backfillEverything": "Alles nachladen",
"backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.",
"synced": "{{count}} Abos synchronisiert",
"syncFailed": "Synchronisierung fehlgeschlagen",
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"admin": "Admin",
"adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).",
"resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen",
"pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren"
}
} }

View file

@ -0,0 +1,46 @@
{
"tabs": {
"roles": "Benutzer & Rollen",
"access": "Zugriffsanfragen",
"demo": "Demo"
},
"roles": {
"title": "Benutzer & Rollen",
"intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.",
"empty": "Noch keine Benutzer.",
"you": "du",
"admin": "Admin",
"demo": "Demo",
"suspended": "Gesperrt",
"pending": "Wartet auf Freigabe",
"unverified": "E-Mail nicht bestätigt",
"demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.",
"selfLocked": "Du kannst deine eigene Rolle nicht ändern.",
"makeAdmin": "Zum Admin machen",
"makeUser": "Zum Benutzer machen",
"updated": "Rolle aktualisiert für {{email}}",
"promoteTitle": "Zum Admin machen?",
"demoteTitle": "Admin entfernen?",
"promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?",
"demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer."
},
"suspend": {
"action": "Sperren",
"undoAction": "Entsperren",
"title": "Konto sperren?",
"undoTitle": "Konto entsperren?",
"confirm": "{{email}} sperren? Eine Anmeldung (per Passwort oder Google) ist erst nach dem Entsperren wieder möglich, und jede aktive Sitzung endet.",
"undoConfirm": "{{email}} entsperren? Eine Anmeldung ist dann wieder möglich.",
"done": "{{email}} gesperrt",
"undone": "{{email}} entsperrt",
"demoLocked": "Das Demo-Konto kann nicht gesperrt werden.",
"selfLocked": "Du kannst dein eigenes Konto nicht sperren."
},
"delete": {
"action": "Löschen",
"locked": "Dieses Konto kann hier nicht gelöscht werden.",
"title": "Konto löschen?",
"confirm": "{{email}} und alle zugehörigen Daten endgültig löschen — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen? Das kann nicht rückgängig gemacht werden.",
"done": "{{email}} gelöscht"
}
}

View file

@ -0,0 +1,75 @@
{
"hero": {
"title": "Deine YouTube-Abos, so wie ein Feed funktionieren sollte.",
"subtitle": "Siftlode sammelt jeden Upload der Kanäle, denen du folgst, in einem aufgeräumten, filterbaren Feed — kein Algorithmus entscheidet, was du siehst, und kein Shorts- oder Live-Lärm, außer du willst es.",
"trust": "Selbst gehostet und privat. Melde dich per E-Mail an oder fahre mit Google fort."
},
"preview": {
"feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.",
"channels": "Kanal-Verwaltung",
"playlists": "Wiedergabelisten"
},
"features": {
"heading": "Warum Siftlode",
"readable": {
"title": "Deine Abos, endlich lesbar",
"body": "Sortiere und filtere nach Kanal, Sprache, Thema, Länge oder Datum — und blende Kanäle aus, ohne sie zu deabonnieren. Du entscheidest, was erscheint, nicht ein Algorithmus."
},
"neverMiss": {
"title": "Verpasse keinen Upload",
"body": "Jedes Video jedes Kanals, dem du folgst, landet in einem Feed. Shorts und Livestreams sind standardmäßig ausgeblendet — so sind es nur die Videos, für die du gekommen bist."
},
"playlists": {
"title": "Playlists, die in beide Richtungen synchronisieren",
"body": "Erstelle Playlists lokal und halte sie mit YouTube synchron — Änderungen fließen in beide Richtungen."
},
"player": {
"title": "Schau in der App, mach da weiter, wo du aufgehört hast",
"body": "Ein In-App-Player setzt jedes Video dort fort, wo du gestoppt hast — kein Empfehlungs-Strudel, der dich ablenkt."
},
"channels": {
"title": "Eine echte Kanal-Verwaltung",
"body": "Priorisiere die Kanäle, die dir wichtig sind, tagge sie nach deiner Art und filtere den Feed nach deinen eigenen Tags."
},
"private": {
"title": "Selbst gehostet, privat, mehrsprachig",
"body": "Betreibe es selbst — deine Daten bleiben deine. Mehrbenutzerfähig, mit der Oberfläche auf Englisch, Ungarisch und Deutsch."
}
},
"auth": {
"signinTitle": "Anmelden",
"createTitle": "Konto erstellen",
"forgotTitle": "Passwort zurücksetzen",
"resetTitle": "Neues Passwort festlegen",
"email": "E-Mail",
"password": "Passwort",
"newPassword": "Neues Passwort (min. {{min}} Zeichen)",
"invalidEmail": "Gib eine gültige E-Mail-Adresse ein.",
"weakPassword": "Das Passwort muss mindestens {{min}} Zeichen lang sein.",
"genericError": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"working": "Einen Moment…",
"signinButton": "Anmelden",
"createButton": "Konto erstellen",
"forgotButton": "Reset-Link senden",
"resetButton": "Passwort festlegen",
"createLink": "Konto erstellen",
"forgotLink": "Passwort vergessen?",
"backToSignin": "Zurück zur Anmeldung",
"or": "oder",
"google": "Mit Google fortfahren",
"tryDemo": "Demo ausprobieren",
"demoHint": "Gib eine E-Mail ein, die für das Demo-Konto freigeschaltet ist.",
"demoEmail": "Demo-E-Mail",
"demoEnter": "Eintreten",
"demoDenied": "Diese E-Mail ist nicht für die Demo freigeschaltet. Bitte den Admin, sie hinzuzufügen.",
"registerDone": "Fast geschafft — prüfe deine E-Mails auf den Bestätigungslink. Nach der Bestätigung gibt ein Admin dein Konto frei, bevor du dich anmelden kannst.",
"forgotDone": "Falls zu dieser E-Mail ein Konto gehört, haben wir einen Reset-Link gesendet. Prüfe dein Postfach.",
"resetDone": "Dein Passwort wurde festgelegt — du kannst dich jetzt anmelden.",
"verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.",
"verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.",
"accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.",
"accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang.",
"suspended": "Dieses Konto wurde gesperrt und kann sich nicht anmelden. Falls das ein Irrtum ist, wende dich an den Betreiber (Details in deiner E-Mail).",
"deleted": "Dein Konto und alle Daten wurden endgültig gelöscht. Eine Bestätigung haben wir dir per E-Mail geschickt."
}
}

View file

@ -19,7 +19,9 @@
"subscribedTitle": "Subscribed on YouTube", "subscribedTitle": "Subscribed on YouTube",
"subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your feed.", "subscribedBody": "You're now following {{name}} — its new uploads will start arriving in your feed.",
"subscribeFailed": "Subscribe failed", "subscribeFailed": "Subscribe failed",
"confirmSubscribe": "Subscribe to \"{{name}}\" on YouTube? This changes your real YouTube account and spends a little API quota.",
"cols": { "cols": {
"totalVideos": "Videos",
"inPlaylists": "In playlists", "inPlaylists": "In playlists",
"inPlaylistsHint": "{{videos}} video(s) from this channel across {{playlists}} of your playlist(s)." "inPlaylistsHint": "{{videos}} video(s) from this channel across {{playlists}} of your playlist(s)."
} }

View file

@ -12,7 +12,8 @@
"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 them on the Users page.",
"accessRequestsReview": "Review",
"demoTitle": "Demo 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." "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

@ -0,0 +1,50 @@
{
"intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.",
"loading": "Loading configuration…",
"groups": {
"access": "Access",
"email": "Email / SMTP",
"youtube": "YouTube API",
"quota": "Quota",
"backfill": "Backfill",
"shorts": "Shorts probe",
"batch": "Batch sizes"
},
"fields": {
"smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" },
"smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." },
"smtp_user": { "label": "SMTP username", "hint": "The sending account / login." },
"smtp_from": { "label": "From address", "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username." },
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." },
"quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." },
"backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." },
"backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." },
"backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." },
"shorts_probe_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." },
"shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." },
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
},
"save": "Save",
"saving": "Saving…",
"saved": "Saved",
"saveFailed": "Couldn't save",
"unsaved": "Unsaved changes",
"discard": "Discard",
"willReset": "will reset to default on save",
"reset": "Reset",
"resetHint": "Remove this override and fall back to the file/env default.",
"resetDone": "Reset to default",
"usingDefault": "using default",
"overridden": "overridden in DB",
"secretSet": "•••••••• stored",
"secretEnv": "using env value",
"secretUnset": "not set",
"secretsDisabled": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
"testEmail": "Send test email",
"testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.",
"testSent": "Test email sent to {{to}}",
"testFailed": "Test email failed — check the settings"
}

View file

@ -4,6 +4,8 @@
"channelManager": "Channel manager", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"configuration": "Configuration",
"users": "Users",
"scope": { "scope": {
"label": "Feed source", "label": "Feed source",
"my": "Mine", "my": "Mine",
@ -18,6 +20,8 @@
"playlists": "Playlists", "playlists": "Playlists",
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"config": "Configuration",
"users": "Users",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",
"signOut": "Sign out", "signOut": "Sign out",

View file

@ -0,0 +1,16 @@
{
"subscriptions_pull": "Read subscriptions",
"subscriptions_resync": "Auto subscription resync",
"playlists_pull": "Read playlists",
"playlists_push": "Push playlists",
"playlists_delete": "Delete playlist",
"videos_backfill_recent": "Recent backfill",
"videos_backfill_full": "Full-history backfill",
"videos_enrich": "Video enrichment",
"videos_lookup": "Video lookup",
"channels_discover": "Channel discovery",
"channels_subscribe": "Subscribe",
"channels_unsubscribe": "Unsubscribe",
"maintenance_revalidate": "Maintenance re-validation",
"other": "Other"
}

View file

@ -76,7 +76,8 @@
"shorts": "Shorts classification", "shorts": "Shorts classification",
"subscriptions": "Subscription resync", "subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync", "playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation" "maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",

View file

@ -3,7 +3,6 @@
"tabs": { "tabs": {
"appearance": "Appearance", "appearance": "Appearance",
"notifications": "Notifications", "notifications": "Notifications",
"sync": "Sync",
"account": "Account" "account": "Account"
}, },
"save": { "save": {
@ -42,43 +41,6 @@
"testTitle": "Test notification", "testTitle": "Test notification",
"testMessage": "This is what a notification looks like." "testMessage": "This is what a notification looks like."
}, },
"sync": {
"myStatus": "My sync status",
"channels": "Channels",
"channelsHint": "How many channels you're subscribed to.",
"recentSynced": "Recent synced",
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
"fullHistory": "Full history",
"fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).",
"fullHistoryEta": "Full history ETA",
"fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
"myVideos": "My videos",
"myVideosHint": "Total videos available to you across your subscribed channels.",
"quotaLeft": "Quota left today",
"quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.",
"loading": "Loading…",
"apiUsage": "Your API usage",
"today": "Today",
"todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.",
"last7d": "Last 7 days",
"last30d": "Last 30 days",
"allTime": "All time",
"byAction": "By action (30d)",
"noUsage": "No usage yet.",
"actions": "Actions",
"syncSubscriptions": "Sync subscriptions",
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
"synced": "Synced {{count}} subscriptions",
"syncFailed": "Sync failed",
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"admin": "Admin",
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
"resumeBackgroundSync": "Resume background sync",
"pauseBackgroundSync": "Pause background sync"
},
"account": { "account": {
"title": "Account", "title": "Account",
"youtubeAccess": "YouTube access", "youtubeAccess": "YouTube access",
@ -93,7 +55,38 @@
"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." "dangerZone": "Danger zone",
"deleteHint": "Permanently delete your account and all your data — subscriptions, tags, watch history, playlists and settings. This can't be undone.",
"deleteAccount": "Delete my account",
"deleteTitle": "Delete your account?",
"deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.",
"deleteConfirmButton": "Delete everything",
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
"signInMethods": "Sign-in methods",
"googleLink": {
"title": "Google account",
"connected": "Connected",
"connectedHint": "Your Google account is linked. You can sign in with Google and grant YouTube access below.",
"connectHint": "Link a Google account to sign in with Google and to enable YouTube access for your feed.",
"connect": "Connect Google",
"linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.",
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
"error": "Couldn't link the Google account. Please try again."
},
"password": {
"title": "Password",
"setHint": "A password is set. You can sign in with your email and password.",
"unsetHint": "No password yet — set one to sign in with your email instead of (or as well as) Google.",
"set": "Set password",
"change": "Change password",
"current": "Current password",
"new": "New password (min. 10 characters)",
"save": "Save password",
"saving": "Saving…",
"saved": "Password updated for {{email}}.",
"failed": "Couldn't update the password."
}
}, },
"demo": { "demo": {
"title": "Demo access", "title": "Demo access",
@ -119,7 +112,7 @@
"addPlaceholder": "Add an email directly…", "addPlaceholder": "Add an email directly…",
"add": "Add", "add": "Add",
"decided": "{{count}} decided", "decided": "{{count}} decided",
"approved": "Approved — they can sign in now", "approved": "Approved {{email}} — they can sign in now",
"approveFailed": "Approve failed", "approveFailed": "Approve failed",
"denyFailed": "Deny failed", "denyFailed": "Deny failed",
"addedToWhitelist": "Added to the whitelist", "addedToWhitelist": "Added to the whitelist",

View file

@ -1,4 +1,8 @@
{ {
"tabs": {
"overview": "Overview",
"system": "System"
},
"title": "API quota usage", "title": "API quota usage",
"rangeDays": "{{count}}d", "rangeDays": "{{count}}d",
"introBefore": "YouTube Data API units attributed by who triggered the spend. ", "introBefore": "YouTube Data API units attributed by who triggered the spend. ",
@ -9,5 +13,42 @@
"dailyTotal": "Daily total ({{count}}d · {{units}} units)", "dailyTotal": "Daily total ({{count}}d · {{units}} units)",
"noUsageInRange": "No usage in this range.", "noUsageInRange": "No usage in this range.",
"dailyTooltip": "{{day}}: {{units}} units", "dailyTooltip": "{{day}}: {{units}} units",
"system": "System (background)" "system": "System (background)",
"sync": {
"myStatus": "My sync status",
"channels": "Channels",
"channelsHint": "How many channels you're subscribed to.",
"recentSynced": "Recent synced",
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
"fullHistory": "Full history",
"fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).",
"fullHistoryEta": "Full history ETA",
"fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
"myVideos": "My videos",
"myVideosHint": "Total videos available to you across your subscribed channels.",
"quotaLeft": "Quota left today",
"quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.",
"loading": "Loading…",
"apiUsage": "Your API usage",
"today": "Today",
"todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.",
"last7d": "Last 7 days",
"last30d": "Last 30 days",
"allTime": "All time",
"byAction": "By action (30d)",
"noUsage": "No usage yet.",
"actions": "Actions",
"syncSubscriptions": "Sync subscriptions",
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
"synced": "Synced {{count}} subscriptions",
"syncFailed": "Sync failed",
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"admin": "Admin",
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
"resumeBackgroundSync": "Resume background sync",
"pauseBackgroundSync": "Pause background sync"
}
} }

View file

@ -0,0 +1,46 @@
{
"tabs": {
"roles": "Users & roles",
"access": "Access requests",
"demo": "Demo"
},
"roles": {
"title": "Users & roles",
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
"empty": "No users yet.",
"you": "you",
"admin": "Admin",
"demo": "Demo",
"suspended": "Suspended",
"pending": "Pending approval",
"unverified": "Unverified email",
"demoLocked": "The demo account's role can't be changed.",
"selfLocked": "You can't change your own role.",
"makeAdmin": "Make admin",
"makeUser": "Make user",
"updated": "Role updated for {{email}}",
"promoteTitle": "Make admin?",
"demoteTitle": "Remove admin?",
"promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
"demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user."
},
"suspend": {
"action": "Suspend",
"undoAction": "Unsuspend",
"title": "Suspend account?",
"undoTitle": "Unsuspend account?",
"confirm": "Suspend {{email}}? They won't be able to sign in (by password or Google) until you unsuspend them, and any active session ends.",
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
"done": "{{email}} suspended",
"undone": "{{email}} unsuspended",
"demoLocked": "The demo account can't be suspended.",
"selfLocked": "You can't suspend your own account."
},
"delete": {
"action": "Delete",
"locked": "This account can't be deleted here.",
"title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted"
}
}

View file

@ -0,0 +1,75 @@
{
"hero": {
"title": "Your YouTube subscriptions, the way a feed should work.",
"subtitle": "Siftlode pulls every upload from the channels you follow into one clean, filterable feed — no algorithm deciding what you see, and no Shorts or live noise unless you want it.",
"trust": "Self-hosted and private. Sign in with email, or continue with Google."
},
"preview": {
"feed": "The feed — filter by channel, language, topic, length and date.",
"channels": "Channel manager",
"playlists": "Playlists"
},
"features": {
"heading": "Why Siftlode",
"readable": {
"title": "Your subscriptions, actually readable",
"body": "Sort and filter by channel, language, topic, length or date — and hide channels without unsubscribing. You decide what surfaces, not an algorithm."
},
"neverMiss": {
"title": "Never miss an upload",
"body": "Every video from every channel you follow lands in one feed. Shorts and live streams are tucked away by default, so it's just the videos you came for."
},
"playlists": {
"title": "Playlists that sync both ways",
"body": "Build playlists locally and keep them in sync with YouTube — changes flow in both directions."
},
"player": {
"title": "Watch in-app, pick up where you left off",
"body": "An in-app player resumes each video where you stopped — no recommendation rabbit-hole pulling you off course."
},
"channels": {
"title": "A real channel manager",
"body": "Prioritise the channels you care about, tag them your way, and filter the feed by your own tags."
},
"private": {
"title": "Self-hosted, private, multilingual",
"body": "Run it yourself — your data stays yours. Multi-user, with the interface in English, Hungarian and German."
}
},
"auth": {
"signinTitle": "Sign in",
"createTitle": "Create your account",
"forgotTitle": "Reset your password",
"resetTitle": "Set a new password",
"email": "Email",
"password": "Password",
"newPassword": "New password (min {{min}} characters)",
"invalidEmail": "Enter a valid email address.",
"weakPassword": "Password must be at least {{min}} characters.",
"genericError": "Something went wrong. Please try again.",
"working": "Please wait…",
"signinButton": "Sign in",
"createButton": "Create account",
"forgotButton": "Send reset link",
"resetButton": "Set password",
"createLink": "Create an account",
"forgotLink": "Forgot password?",
"backToSignin": "Back to sign in",
"or": "or",
"google": "Continue with Google",
"tryDemo": "Try the demo",
"demoHint": "Enter an email that's been enabled for the demo account.",
"demoEmail": "Demo email",
"demoEnter": "Enter",
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
"registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.",
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
"resetDone": "Your password has been set — you can sign in now.",
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
"verifyInvalid": "That verification link is invalid or has expired.",
"accessRequested": "Thanks — we recorded your request. An admin will review it.",
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
}
}

View file

@ -19,7 +19,9 @@
"subscribedTitle": "Feliratkozva a YouTube-on", "subscribedTitle": "Feliratkozva a YouTube-on",
"subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a hírfolyamodban.", "subscribedBody": "Mostantól követed: {{name}} — az új feltöltései megjelennek a hírfolyamodban.",
"subscribeFailed": "A feliratkozás sikertelen", "subscribeFailed": "A feliratkozás sikertelen",
"confirmSubscribe": "Feliratkozol erre a YouTube-on: „{{name}}”? Ez módosítja a valódi YouTube-fiókodat és kevés API-kvótát használ.",
"cols": { "cols": {
"totalVideos": "Videók",
"inPlaylists": "Listákban", "inPlaylists": "Listákban",
"inPlaylistsHint": "{{videos}} videó ettől a csatornától, {{playlists}} lejátszási listádban." "inPlaylistsHint": "{{videos}} videó ettől a csatornától, {{playlists}} lejátszási listádban."
} }

View file

@ -12,7 +12,8 @@
"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 Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés",
"demoTitle": "Demo fiók", "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." "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

@ -0,0 +1,50 @@
{
"intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.",
"loading": "Konfiguráció betöltése…",
"groups": {
"access": "Hozzáférés",
"email": "E-mail / SMTP",
"youtube": "YouTube API",
"quota": "Kvóta",
"backfill": "Letöltés (backfill)",
"shorts": "Shorts-vizsgálat",
"batch": "Kötegméretek"
},
"fields": {
"smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" },
"smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." },
"smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." },
"smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév." },
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." },
"quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." },
"backfill_quota_reserve": { "label": "Letöltési kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." },
"backfill_recent_max_videos": { "label": "Friss letöltés — max videó", "hint": "Csatornánként az első körben letöltött legújabb videók száma." },
"backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." },
"shorts_probe_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." },
"shorts_probe_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." },
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
},
"save": "Mentés",
"saving": "Mentés…",
"saved": "Elmentve",
"saveFailed": "Nem sikerült menteni",
"unsaved": "Nem mentett változások",
"discard": "Elvetés",
"willReset": "mentéskor visszaáll alapértékre",
"reset": "Visszaállítás",
"resetHint": "Eltávolítja ezt a felülírást, és visszatér a fájl/env alapértékhez.",
"resetDone": "Visszaállítva az alapértékre",
"usingDefault": "alapérték használatban",
"overridden": "felülírva az adatbázisban",
"secretSet": "•••••••• tárolva",
"secretEnv": "env-érték használatban",
"secretUnset": "nincs beállítva",
"secretsDisabled": "Állítsd be a TOKEN_ENCRYPTION_KEY-t a titkok adatbázisban tárolásához.",
"testEmail": "Teszt-email küldése",
"testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.",
"testSent": "Teszt-email elküldve ide: {{to}}",
"testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat"
}

View file

@ -4,6 +4,8 @@
"channelManager": "Csatornakezelő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"configuration": "Konfiguráció",
"users": "Felhasználók",
"scope": { "scope": {
"label": "Hírfolyam forrása", "label": "Hírfolyam forrása",
"my": "Sajátom", "my": "Sajátom",
@ -18,6 +20,8 @@
"playlists": "Lejátszási listák", "playlists": "Lejátszási listák",
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"config": "Konfiguráció",
"users": "Felhasználók",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",

View file

@ -0,0 +1,16 @@
{
"subscriptions_pull": "Feliratkozások beolvasása",
"subscriptions_resync": "Automatikus feliratkozás-újraszinkron",
"playlists_pull": "Lejátszási listák beolvasása",
"playlists_push": "Lejátszási listák feltöltése",
"playlists_delete": "Lejátszási lista törlése",
"videos_backfill_recent": "Friss feltöltések betöltése",
"videos_backfill_full": "Teljes előzmény betöltése",
"videos_enrich": "Videó-gazdagítás",
"videos_lookup": "Videó-lekérdezés",
"channels_discover": "Csatorna-felfedezés",
"channels_subscribe": "Feliratkozás",
"channels_unsubscribe": "Leiratkozás",
"maintenance_revalidate": "Karbantartó újraellenőrzés",
"other": "Egyéb"
}

View file

@ -76,7 +76,8 @@
"shorts": "Shorts-besorolás", "shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja", "subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja", "playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés" "maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása"
}, },
"queue": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",

View file

@ -3,7 +3,6 @@
"tabs": { "tabs": {
"appearance": "Megjelenés", "appearance": "Megjelenés",
"notifications": "Értesítések", "notifications": "Értesítések",
"sync": "Szinkronizálás",
"account": "Fiók" "account": "Fiók"
}, },
"save": { "save": {
@ -42,43 +41,6 @@
"testTitle": "Tesztértesítés", "testTitle": "Tesztértesítés",
"testMessage": "Így néz ki egy értesítés." "testMessage": "Így néz ki egy értesítés."
}, },
"sync": {
"myStatus": "Saját szinkronizálási állapot",
"channels": "Csatornák",
"channelsHint": "Hány csatornára vagy feliratkozva.",
"recentSynced": "Friss szinkronizálva",
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.",
"fullHistory": "Teljes előzmény",
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
"fullHistoryEta": "Teljes előzmény becsült ideje",
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.",
"myVideos": "Saját videók",
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
"quotaLeft": "Ma maradt kvóta",
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.",
"loading": "Betöltés…",
"apiUsage": "Saját API-használat",
"today": "Ma",
"todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.",
"last7d": "Utolsó 7 nap",
"last30d": "Utolsó 30 nap",
"allTime": "Mindenkori",
"byAction": "Művelet szerint (30 nap)",
"noUsage": "Még nincs használat.",
"actions": "Műveletek",
"syncSubscriptions": "Feliratkozások szinkronizálása",
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
"backfillEverything": "Minden letöltése",
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
"synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A szinkronizálás sikertelen",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"admin": "Adminisztrátor",
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
},
"account": { "account": {
"title": "Fiók", "title": "Fiók",
"youtubeAccess": "YouTube-hozzáférés", "youtubeAccess": "YouTube-hozzáférés",
@ -93,7 +55,38 @@
"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." "dangerZone": "Veszélyzóna",
"deleteHint": "Véglegesen törli a fiókodat és minden adatodat — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások. Nem vonható vissza.",
"deleteAccount": "Fiókom törlése",
"deleteTitle": "Törlöd a fiókodat?",
"deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.",
"deleteConfirmButton": "Minden törlése",
"demoNotice": "Ez a közös 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.",
"signInMethods": "Bejelentkezési módok",
"googleLink": {
"title": "Google-fiók",
"connected": "Csatlakoztatva",
"connectedHint": "A Google-fiókod össze van kapcsolva. Bejelentkezhetsz Google-lel, és alább engedélyezheted a YouTube-hozzáférést.",
"connectHint": "Kapcsolj össze egy Google-fiókot, hogy Google-lel jelentkezhess be, és engedélyezhesd a YouTube-hozzáférést a feededhez.",
"connect": "Google összekapcsolása",
"linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
},
"password": {
"title": "Jelszó",
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
"unsetHint": "Még nincs jelszó — állíts be egyet, hogy e-mail-címmel is bejelentkezhess (a Google mellett vagy helyett).",
"set": "Jelszó beállítása",
"change": "Jelszó módosítása",
"current": "Jelenlegi jelszó",
"new": "Új jelszó (min. 10 karakter)",
"save": "Jelszó mentése",
"saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}.",
"failed": "Nem sikerült frissíteni a jelszót."
}
}, },
"demo": { "demo": {
"title": "Demo hozzáférés", "title": "Demo hozzáférés",
@ -119,7 +112,7 @@
"addPlaceholder": "E-mail közvetlen hozzáadása…", "addPlaceholder": "E-mail közvetlen hozzáadása…",
"add": "Hozzáadás", "add": "Hozzáadás",
"decided": "{{count}} eldöntve", "decided": "{{count}} eldöntve",
"approved": "Jóváhagyva — most már be tud jelentkezni", "approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni",
"approveFailed": "A jóváhagyás sikertelen", "approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen", "denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "Hozzáadva a fehérlistához", "addedToWhitelist": "Hozzáadva a fehérlistához",

View file

@ -1,4 +1,8 @@
{ {
"tabs": {
"overview": "Áttekintés",
"system": "Rendszer"
},
"title": "API-kvóta használat", "title": "API-kvóta használat",
"rangeDays": "{{count}} nap", "rangeDays": "{{count}} nap",
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ", "introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
@ -9,5 +13,42 @@
"dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)", "dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)",
"noUsageInRange": "Nincs használat ebben az időszakban.", "noUsageInRange": "Nincs használat ebben az időszakban.",
"dailyTooltip": "{{day}}: {{units}} egység", "dailyTooltip": "{{day}}: {{units}} egység",
"system": "Rendszer (háttér)" "system": "Rendszer (háttér)",
"sync": {
"myStatus": "Saját szinkronizálási állapot",
"channels": "Csatornák",
"channelsHint": "Hány csatornára vagy feliratkozva.",
"recentSynced": "Friss szinkronizálva",
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.",
"fullHistory": "Teljes előzmény",
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
"fullHistoryEta": "Teljes előzmény becsült ideje",
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.",
"myVideos": "Saját videók",
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
"quotaLeft": "Ma maradt kvóta",
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.",
"loading": "Betöltés…",
"apiUsage": "Saját API-használat",
"today": "Ma",
"todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.",
"last7d": "Utolsó 7 nap",
"last30d": "Utolsó 30 nap",
"allTime": "Mindenkori",
"byAction": "Művelet szerint (30 nap)",
"noUsage": "Még nincs használat.",
"actions": "Műveletek",
"syncSubscriptions": "Feliratkozások szinkronizálása",
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
"backfillEverything": "Minden letöltése",
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
"synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A szinkronizálás sikertelen",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"admin": "Adminisztrátor",
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
}
} }

View file

@ -0,0 +1,46 @@
{
"tabs": {
"roles": "Felhasználók és szerepek",
"access": "Hozzáférési kérések",
"demo": "Demo"
},
"roles": {
"title": "Felhasználók és szerepek",
"intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
"empty": "Még nincs felhasználó.",
"you": "te",
"admin": "Admin",
"demo": "Demo",
"suspended": "Felfüggesztve",
"pending": "Jóváhagyásra vár",
"unverified": "Nem igazolt e-mail",
"demoLocked": "A demo fiók szerepe nem módosítható.",
"selfLocked": "A saját szerepedet nem módosíthatod.",
"makeAdmin": "Adminná tesz",
"makeUser": "Felhasználóvá tesz",
"updated": "Szerep frissítve: {{email}}",
"promoteTitle": "Adminná teszed?",
"demoteTitle": "Elveszed az admin jogot?",
"promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?",
"demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz."
},
"suspend": {
"action": "Felfüggeszt",
"undoAction": "Feloldás",
"title": "Felfüggeszted a fiókot?",
"undoTitle": "Feloldod a felfüggesztést?",
"confirm": "Felfüggeszted: {{email}}? Nem fog tudni belépni (sem jelszóval, sem Google-lel), amíg fel nem oldod, és az aktív munkamenete megszűnik.",
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
"done": "{{email}} felfüggesztve",
"undone": "{{email}} felfüggesztése feloldva",
"demoLocked": "A demo fiók nem függeszthető fel.",
"selfLocked": "A saját fiókodat nem függesztheted fel."
},
"delete": {
"action": "Törlés",
"locked": "Ez a fiók itt nem törölhető.",
"title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve"
}
}

View file

@ -0,0 +1,75 @@
{
"hero": {
"title": "A YouTube-feliratkozásaid úgy, ahogy egy hírfolyamnak működnie kéne.",
"subtitle": "A Siftlode minden feltöltést egyetlen tiszta, szűrhető hírfolyamba gyűjt a követett csatornáidról — nincs algoritmus, ami eldönti, mit látsz, és nincs Shorts- vagy élő-zaj, hacsak nem kéred.",
"trust": "Self-hosted és privát. Lépj be e-maillel, vagy folytasd Google-fiókkal."
},
"preview": {
"feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"channels": "Csatorna-kezelő",
"playlists": "Lejátszási listák"
},
"features": {
"heading": "Miért a Siftlode",
"readable": {
"title": "A feliratkozásaid végre olvashatóan",
"body": "Rendezz és szűrj csatorna, nyelv, téma, hossz vagy dátum szerint — és rejts el csatornákat leiratkozás nélkül. Te döntöd el, mi kerül elő, nem egy algoritmus."
},
"neverMiss": {
"title": "Egy feltöltés se vesszen el",
"body": "Minden videó minden követett csatornádról egyetlen hírfolyamba kerül. A Shorts és az élő adások alapból félrerakva — így tényleg csak azt látod, amiért jöttél."
},
"playlists": {
"title": "Kétirányban szinkronizált lejátszási listák",
"body": "Építs helyi lejátszási listákat, és tartsd őket szinkronban a YouTube-bal — a változások mindkét irányba áramlanak."
},
"player": {
"title": "Nézd appon belül, onnan folytatva, ahol abbahagytad",
"body": "Az appon belüli lejátszó ott folytatja a videót, ahol megálltál — nincs ajánló-örvény, ami eltérítene."
},
"channels": {
"title": "Igazi csatorna-kezelő",
"body": "Priorizáld a számodra fontos csatornákat, címkézd a saját módodon, és szűrd a hírfolyamot a saját címkéid szerint."
},
"private": {
"title": "Self-hosted, privát, többnyelvű",
"body": "Futtasd magad — az adataid a tieid maradnak. Többfelhasználós, a felület angolul, magyarul és németül."
}
},
"auth": {
"signinTitle": "Belépés",
"createTitle": "Fiók létrehozása",
"forgotTitle": "Jelszó visszaállítása",
"resetTitle": "Új jelszó beállítása",
"email": "E-mail",
"password": "Jelszó",
"newPassword": "Új jelszó (min. {{min}} karakter)",
"invalidEmail": "Adj meg egy érvényes e-mail-címet.",
"weakPassword": "A jelszónak legalább {{min}} karakteresnek kell lennie.",
"genericError": "Valami hiba történt. Próbáld újra.",
"working": "Egy pillanat…",
"signinButton": "Belépés",
"createButton": "Fiók létrehozása",
"forgotButton": "Visszaállító link küldése",
"resetButton": "Jelszó beállítása",
"createLink": "Hozz létre egy fiókot",
"forgotLink": "Elfelejtetted a jelszót?",
"backToSignin": "Vissza a belépéshez",
"or": "vagy",
"google": "Folytatás Google-lel",
"tryDemo": "Demó kipróbálása",
"demoHint": "Adj meg egy e-mailt, amely engedélyezve van a demó fiókhoz.",
"demoEmail": "Demó e-mail",
"demoEnter": "Belépés",
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
}
}

View file

@ -9,6 +9,8 @@ export interface Me {
avatar_url: string | null; avatar_url: string | null;
role: string; role: string;
is_demo: boolean; is_demo: boolean;
has_google: boolean;
has_password: boolean;
can_read: boolean; can_read: boolean;
can_write: boolean; can_write: boolean;
pending_invites: number; pending_invites: number;
@ -194,6 +196,18 @@ const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502. // are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
interface ReqConfig { interface ReqConfig {
idempotent?: boolean; idempotent?: boolean;
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
quiet?: boolean;
}
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages
// legitimately 401 on /api/me), so it's safe to call for every 401.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
} }
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> { async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
@ -248,6 +262,12 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
if (RETRIABLE_GATEWAY.has(r.status)) { if (RETRIABLE_GATEWAY.has(r.status)) {
markConnectivityLost(); markConnectivityLost();
} else if (r.status === 401) {
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
// app shell decide what to do (drop to the login page if we were signed in). Not a modal.
onUnauthorized?.();
} else if (cfg.quiet) {
/* caller handles the error inline — no global modal */
} else if (r.status >= 500) { } else if (r.status >= 500) {
reportError(detail || `${i18n.t("errors.server")} (${r.status})`); reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
} else if (r.status === 400 || r.status === 409 || r.status === 422) { } else if (r.status === 400 || r.status === 409 || r.status === 422) {
@ -440,9 +460,44 @@ export interface AppNotification {
created_at: string | null; created_at: string | null;
} }
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
key: string;
type: "int" | "str" | "bool";
group: string;
secret: boolean;
min: number | null;
max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default)
value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists
}
export interface SystemConfigData {
groups: Record<string, ConfigItem[]>;
secrets_manageable: boolean;
}
// Admin Users & roles page.
export interface AdminUserRow {
id: number;
email: string;
display_name: string | null;
role: string; // "user" | "admin"
is_active: boolean;
is_suspended: boolean;
email_verified: boolean;
is_demo: boolean;
has_password: boolean;
has_google: boolean;
created_at: string | null;
}
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> =>
req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
version: (): Promise<VersionInfo> => req("/api/version"), version: (): Promise<VersionInfo> => req("/api/version"),
@ -548,6 +603,22 @@ export const api = {
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`), adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
// --- admin: system configuration ---
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
resetConfig: (key: string): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
// --- admin: users & roles ---
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"), schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> => updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, { req(`/api/admin/scheduler/jobs/${jobId}`, {
@ -564,6 +635,22 @@ export const api = {
body: JSON.stringify({ revalidate_batch: revalidateBatch }), body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}), }),
// --- auth: email + password (errors handled inline via quiet) ---
register: (email: string, password: string): Promise<{ status: string }> =>
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
requestPasswordReset: (email: string): Promise<{ status: string }> =>
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true }
),
// --- 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 }) }),

View file

@ -44,18 +44,11 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
} }
const QUOTA_ACTION_LABELS: Record<string, string> = { // Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
sync_subscriptions: "Sync subscriptions", // from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
backfill_recent: "Recent backfill", // key for any unmapped/legacy value.
backfill_deep: "Full-history backfill",
enrich: "Enrichment",
unsubscribe: "Unsubscribe",
subscription_resync: "Auto subscription resync",
api: "Other",
};
export function quotaActionLabel(action: string): string { export function quotaActionLabel(action: string): string {
return QUOTA_ACTION_LABELS[action] ?? action; return i18n.t(`quotaActions.${action}`, { defaultValue: action });
} }
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */ /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */

View file

@ -31,7 +31,16 @@ export type ChannelSubscribedMeta = {
channelId: string; channelId: string;
channelName: string; channelName: string;
}; };
export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta | ChannelSubscribedMeta; // Admin nudge that pending access requests are waiting — carries a durable inbox link to the
// Users page (where Access requests live). No payload; the panel provides the navigation.
export type AccessRequestsMeta = {
kind: "access-requests";
};
export type NotifMeta =
| VideoHiddenMeta
| VideoWatchedMeta
| ChannelSubscribedMeta
| AccessRequestsMeta;
export interface Notification { export interface Notification {
id: number; id: number;
@ -173,6 +182,15 @@ export function subscribe(listener: () => void): () => void {
}; };
} }
/** Drop every notification (active or dismissed history) carrying this meta kind. Used to keep
* a singleton nudge e.g. re-issuing the "access requests pending" notice without piling up a
* fresh copy (plus a stale history entry) on every reload. */
export function removeByMetaKind(kind: NotifMeta["kind"]): void {
const before = items.length;
items = items.filter((n) => n.meta?.kind !== kind);
if (items.length !== before) emit();
}
export function notify(input: NotifyInput): number { export function notify(input: NotifyInput): number {
const id = counter++; const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;

View file

@ -14,6 +14,40 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.13.0",
date: "2026-06-19",
summary:
"Sign in with email & password (not just Google), a redesigned welcome page, account management for admins, and self-service account deletion.",
features: [
"Sign in with an email and password, alongside Google. Register with email+password (with email verification and admin approval), set or change a password under Settings → Account, and link a Google account to your existing account — or set a password on a Google-only account — so either method gets you in.",
"A redesigned welcome page: a short product tour with screenshots you can click to enlarge, and a one-click way to try the shared demo.",
"Admin account management: a Users page with tabs for roles, access requests and the demo whitelist. Promote or demote users, suspend or unsuspend an account (a suspended user can't sign in by any method and is signed out at once), and delete an account. The affected person is emailed on approval, role change, suspension, reinstatement and deletion.",
"Delete your account yourself: Settings → Account now has a permanent “delete my account and all data” option that erases your subscriptions, tags, watch history and playlists, revokes the app's access to your Google account, and emails you a confirmation.",
],
fixes: [
"Landing-page screenshots now appear in the real app, not only the dev preview; a suspended or deleted account is taken to the login page right away with a clear explanation; and the login page no longer occasionally flickers.",
"Signing in with Google now correctly counts your email as verified, and an admin-assigned role is kept across logins instead of being reset.",
],
chores: [
"Tab-ified the admin Configuration and Users pages, and added a reusable tab and image-lightbox component.",
"Self-hosting without email configured still works: registrations are auto-verified and gated by admin approval. The demo account now shows sample channels in its Channel manager.",
],
},
{
version: "0.12.0",
date: "2026-06-19",
summary:
"A dedicated Usage & stats page, a live Configuration page for admins, and polish on the channel-discovery list.",
features: [
"Usage & stats is now its own page (moved out of Settings): see your sync status and your personal API usage at a glance, with a clear, translated breakdown by action. Admins get an extra System tab with the instance-wide quota dashboard and the background-sync pause control.",
"“Discover from playlists” now shows each channel's total video count, so you can size it up before following — and subscribing asks for confirmation first, since it changes your real YouTube account and uses a little quota.",
],
chores: [
"Admins can edit operational settings live from a new Configuration page — email/SMTP, the YouTube API key, the daily quota budget, and backfill/Shorts-probe limits — stored in the database and applied without a redeploy. Secrets (SMTP password, API key) are encrypted and write-only.",
"Standardized the names of API-usage actions with full English/Hungarian/German labels, and unified the version/demo banners into one reusable component.",
],
},
{ {
version: "0.11.2", version: "0.11.2",
date: "2026-06-19", date: "2026-06-19",

View file

@ -88,6 +88,8 @@ export const PAGES = [
"playlists", "playlists",
"settings", "settings",
"scheduler", "scheduler",
"config",
"users",
"notifications", "notifications",
] as const; ] as const;