Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-12 03:51:36 +02:00
commit 05180afa80
17 changed files with 297 additions and 20 deletions

View file

@ -31,6 +31,16 @@ ADMIN_EMAILS=
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production. # Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
FRONTEND_ORIGIN= FRONTEND_ORIGIN=
# Reverse-proxy trust for rate limiting. If you run the app behind a reverse proxy (Caddy/Nginx/
# Traefik…) that terminates TLS, set this to the IP the proxy CONNECTS FROM as the app sees it — the
# proxy's address on the app's network (e.g. the Docker/host/VPN IP), NOT the public client IP. Then
# the login/registration/password-reset rate limiters key on the real client (read from the proxy's
# X-Forwarded-For) instead of the proxy's own IP. Requests arriving from any OTHER peer (e.g. someone
# hitting the container port directly) are not trusted and are rate-limited by their real socket IP,
# so X-Forwarded-For can't be forged to dodge the limits. Comma-separated. Leave EMPTY if the app is
# directly exposed with no proxy (then the direct socket IP is used). Example: TRUSTED_PROXY_IPS=10.10.0.1
TRUSTED_PROXY_IPS=
# Optional YouTube Data API key (Google Cloud Console -> Credentials -> Create API key). # Optional YouTube Data API key (Google Cloud Console -> Credentials -> Create API key).
# When set, all public reads (channels/videos/playlist backfill + enrichment) use the key # When set, all public reads (channels/videos/playlist backfill + enrichment) use the key
# instead of a user's OAuth token, so 24/7 backfill never depends on a refresh token that # instead of a user's OAuth token, so 24/7 backfill never depends on a refresh token that

View file

@ -82,7 +82,8 @@ Port `8080` over plain HTTP is fine for a LAN or a quick trial. For public acces
proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer proxy (Caddy, Nginx, Traefik…) in front to terminate TLS, and set the **public URL** (the installer
prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session prompt, or `OAUTH_REDIRECT_URL` in `.env`) to your `https://…` address — this also marks the session
cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect cookie secure. Add that same `…/auth/callback` URL to your Google OAuth client's authorized redirect
URIs. URIs. Behind a proxy, also set `TRUSTED_PROXY_IPS` so the rate limiters see the real client IP and
can't be bypassed via a forged `X-Forwarded-For` — see [docs/self-hosting.md](docs/self-hosting.md#https--public-access).
## Updating & backups ## Updating & backups

View file

@ -1 +1 @@
0.38.0 0.39.0

View file

@ -0,0 +1,27 @@
"""User.session_epoch for server-side session revocation (SA4)
A monotonic per-user counter. A signed session cookie records the epoch it was minted under;
current_user rejects any cookie whose epoch is stale. Bumped on password reset, password change,
or "log out everywhere" so a stolen/copied client-side cookie dies on those events instead of
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0053_user_session_epoch"
down_revision: Union[str, None] = "0052_plex_show_meta"
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("session_epoch", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("users", "session_epoch")

View file

@ -1,3 +1,4 @@
import ipaddress
import logging import logging
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -172,13 +173,35 @@ def get_or_create_demo_user(db: Session) -> User:
return user return user
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
try:
ip = ipaddress.ip_address(s.strip())
except ValueError:
return None
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
"""Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the """Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise.""" ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
xff = request.headers.get("x-forwarded-for") the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
if xff: the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
return xff.split(",")[0].strip() client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
return request.client.host if request.client else "unknown" proxy) is untrusted we use its real socket IP, so it can't spoof its rate-limit identity.
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF
do NOT add that flag, or the trust check below would compare against a forgeable value."""
peer = request.client.host if request.client else "unknown"
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
if _norm_ip(peer) in trusted:
xff = request.headers.get("x-forwarded-for")
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
if parts:
return parts[-1]
return peer
def upsert_pending_invite(db: Session, email: str) -> Invite | None: def upsert_pending_invite(db: Session, email: str) -> Invite | None:
@ -341,6 +364,7 @@ async def callback(
accounts.append(user.id) accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id request.session["user_id"] = user.id
_remember_epoch(request, user)
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/") return RedirectResponse(url="/")
@ -493,6 +517,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
# can't switch to / act as a previously signed-in account via the multi-account header. # can't switch to / act as a previously signed-in account via the multi-account header.
request.session.clear() request.session.clear()
request.session["user_id"] = demo.id request.session["user_id"] = demo.id
_remember_epoch(request, demo)
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
return {"authenticated": True} return {"authenticated": True}
return {"authenticated": False} return {"authenticated": False}
@ -505,6 +530,7 @@ def _establish_session(request: Request, user: User) -> None:
accounts.append(user.id) accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id request.session["user_id"] = user.id
_remember_epoch(request, user)
def _app_base() -> str: def _app_base() -> str:
@ -587,8 +613,10 @@ def register(
upsert_pending_invite(db, email) # admin-approval gate upsert_pending_invite(db, email) # admin-approval gate
if email_ok: if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL) raw = _issue_token(db, user, "verify", VERIFY_TTL)
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer.
# The SPA reads the fragment and POSTs it to /auth/verify (SB3).
background.add_task( background.add_task(
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}" email_mod.send_verify_email, email, f"{_app_base()}/#verify={raw}"
) )
if settings.admin_email_set: if settings.admin_email_set:
background.add_task( background.add_task(
@ -598,9 +626,22 @@ def register(
return {"status": "ok"} return {"status": "ok"}
@router.post("/verify")
def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
on any invalid/expired/used token."""
user = _consume_token(db, payload.get("token"), "verify")
if user is None:
raise HTTPException(status_code=400, detail="This verification link is invalid or has expired.")
user.email_verified = True
db.commit()
return {"ok": True}
@router.get("/verify") @router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)): def verify_email(token: str, db: Session = Depends(get_db)):
"""Confirm an email-verification link, then bounce to a friendly status on the app.""" """Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
user = _consume_token(db, token, "verify") user = _consume_token(db, token, "verify")
if user is None: if user is None:
return RedirectResponse(url="/?verify=invalid") return RedirectResponse(url="/?verify=invalid")
@ -662,7 +703,9 @@ def password_reset_request(
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() 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: if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL) raw = _issue_token(db, user, "reset", RESET_TTL)
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}") # Token in the URL FRAGMENT (#), not the query (?): a fragment is never sent to the
# server, so it can't leak into proxy/access logs or a Referer header (SB3).
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/#reset={raw}")
return {"status": "ok"} return {"status": "ok"}
@ -680,6 +723,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
if user is None: if user is None:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions.
# Burn any other outstanding reset tokens for this user. # Burn any other outstanding reset tokens for this user.
for row in db.execute( for row in db.execute(
select(AuthToken).where( select(AuthToken).where(
@ -722,6 +766,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]:
return default_id, True return default_id, True
def _remember_epoch(request: Request, user: User) -> None:
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
can later reject the cookie if the account's epoch was bumped (password reset/change, or
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
epochs = dict(request.session.get("epochs") or {})
epochs[str(user.id)] = user.session_epoch
request.session["epochs"] = epochs
def bump_session_epoch(user: User) -> None:
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
minted before the bump carry a now-stale epoch and are rejected by `current_user`."""
user.session_epoch = (user.session_epoch or 0) + 1
def current_user(request: Request, db: Session = Depends(get_db)) -> User: def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id, is_default = resolved_user_id(request) user_id, is_default = resolved_user_id(request)
if not user_id: if not user_id:
@ -734,6 +793,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
if is_default: if is_default:
request.session.clear() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
epochs = request.session.get("epochs") or {}
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
if is_default:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the session default in the switchable list — covers sessions created before # Always keep the session default in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty. # multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
default_id = request.session.get("user_id") default_id = request.session.get("user_id")
@ -756,6 +823,24 @@ def optional_current_user(
return None return None
@router.post("/logout-others")
def logout_others(
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
if user.is_demo:
raise HTTPException(status_code=403, detail="Not available in the demo account.")
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
# session at a newer epoch than the DB (see set_password).
bump_session_epoch(user)
db.commit()
_remember_epoch(request, user)
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
return {"ok": True}
def require_human(user: User = Depends(current_user)) -> User: def require_human(user: User = Depends(current_user)) -> User:
"""Reject the shared demo account from actions that need a real Google/YouTube identity """Reject the shared demo account from actions that need a real Google/YouTube identity
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
@ -811,7 +896,10 @@ async def link_google(
@router.post("/set-password") @router.post("/set-password")
def set_password( def set_password(
payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db) payload: dict,
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Set or change the signed-in account's password. Setting a first password (Google-only """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 account) needs no current password the session already proves identity. Changing an
@ -828,7 +916,12 @@ def set_password(
if not verify_password(payload.get("current_password") or "", 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.") raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
bump_session_epoch(user)
db.commit() db.commit()
_remember_epoch(request, user)
log.info("Password set/changed: uid=%s", user.id) log.info("Password set/changed: uid=%s", user.id)
return {"ok": True} return {"ok": True}

View file

@ -52,6 +52,14 @@ class Settings(BaseSettings):
# 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 = ""
# Reverse-proxy IPs whose X-Forwarded-For we trust for the real client IP (rate limiting). This
# is the address the proxy CONNECTS FROM as seen by the app — e.g. the WireGuard peer IP of the
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
# deploy. Comma-separated. See auth._client_ip + the deploy docs.
trusted_proxy_ips: str = ""
# --- Outbound email (onboarding: access-request + approval notices) --- # --- Outbound email (onboarding: access-request + approval notices) ---
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped # Gmail SMTP + App Password by default. All optional: if unset, email is skipped
# (fail-soft) and onboarding still works via in-app notifications. # (fail-soft) and onboarding still works via in-app notifications.
@ -226,6 +234,10 @@ 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 trusted_proxy_ip_set(self) -> frozenset[str]:
return frozenset(p.strip() for p in self.trusted_proxy_ips.split(",") if p.strip())
@property @property
def app_base(self) -> str: def app_base(self) -> str:
"""Public origin of the deployed app, derived from the OAuth redirect URL """Public origin of the deployed app, derived from the OAuth redirect URL

View file

@ -63,6 +63,11 @@ class User(Base, TimestampMixin):
is_suspended: Mapped[bool] = mapped_column( is_suspended: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
# events (client-side signed cookies otherwise stay valid until they expire).
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
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")

View file

@ -381,7 +381,13 @@ async def messages_ws(ws: WebSocket) -> None:
return return
db = SessionLocal() db = SessionLocal()
try: try:
ok = is_messageable_user(db.get(User, uid)) user = db.get(User, uid)
# SA4: honour server-side session revocation on the live channel too — reject a cookie whose
# recorded epoch is behind the account's current one (mirrors current_user). Without this a
# copied cookie could keep receiving pushes after a password reset / "log out everywhere".
epochs = sess.get("epochs") or {}
epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0)
ok = epoch_ok and is_messageable_user(user)
finally: finally:
db.close() db.close()
if not ok: if not ok:

View file

@ -10,4 +10,9 @@ echo "Starting Siftlode API..."
# timeout, so Caddy would reuse a connection uvicorn had just closed -> broken pipe / reset # timeout, so Caddy would reuse a connection uvicorn had just closed -> broken pipe / reset
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes, # on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user. # fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
#
# SECURITY: do NOT add --proxy-headers / --forwarded-allow-ips here. auth._client_ip trusts
# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those
# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and
# reopening the rate-limit bypass (SA3).
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75 exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75

View file

@ -79,6 +79,21 @@ public access, put a reverse proxy (Caddy, Nginx, Traefik…) in front to termin
secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https
callback URL and `docker compose -f docker-compose.selfhost.yml up -d`. callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
**Behind a proxy, set `TRUSTED_PROXY_IPS`.** The login / registration / password-reset rate limiters
identify callers by IP. Behind a proxy every request arrives from the proxy, so without this the
limits would apply to everyone together. Set `TRUSTED_PROXY_IPS` in `.env` to the address your proxy
connects to the app *from* (as the app sees it — e.g. its Docker/host/LAN IP, **not** the public
client address), and the app will read the real client IP from the proxy's `X-Forwarded-For` header:
```bash
TRUSTED_PROXY_IPS=172.18.0.1 # comma-separate multiple proxies
```
Only that peer is trusted, so a request sent straight to port `8080` (bypassing the proxy) can't forge
`X-Forwarded-For` to dodge the limits — it's rate-limited by its real address. Leave it empty if the
app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's
`reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do).
## Download Center (media storage) ## Download Center (media storage)
The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server

View file

@ -308,11 +308,36 @@ function AccessRow({
function SignInMethods({ me }: { me: Me }) { function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [sessBusy, setSessBusy] = useState(false);
// SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind
// device or a suspected compromise.
const logoutOthers = async () => {
if (
!(await confirm({
title: t("settings.account.sessions.confirmTitle"),
message: t("settings.account.sessions.confirmBody"),
confirmLabel: t("settings.account.sessions.action"),
danger: true,
}))
)
return;
setSessBusy(true);
try {
await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") });
} catch {
notify({ level: "error", message: t("settings.account.sessions.failed") });
} finally {
setSessBusy(false);
}
};
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) {
</form> </form>
)} )}
</div> </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.sessions.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{t("settings.account.sessions.hint")}
</p>
</div>
<button
onClick={logoutOthers}
disabled={sessBusy}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
>
{t("settings.account.sessions.action")}
</button>
</div>
</div>
</Section> </Section>
); );
} }

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ArrowRight, ArrowRight,
@ -259,22 +259,42 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
function AuthCard() { function AuthCard() {
const { t } = useTranslation(); const { t } = useTranslation();
// Snapshot the redirect params ONCE so we can clean them from the address bar below without // Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
// dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL). // without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
// stay in the query string.
const [params] = useState(() => new URLSearchParams(window.location.search)); const [params] = useState(() => new URLSearchParams(window.location.search));
const resetToken = params.get("reset"); const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
const resetToken = hashParams.get("reset");
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
const bounced = params.get("access"); // Google denied → "requested" | "denied" 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 login = params.get("login"); // Google login blocked → "suspended"
const deleted = params.get("deleted"); // self-service account deletion just completed const deleted = params.get("deleted"); // self-service account deletion just completed
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
// Strip the query string so the address bar stays clean (http://host/) after we've captured it. // Strip BOTH the query string and the fragment so the address bar stays clean and the token
// doesn't linger in history.
useEffect(() => { useEffect(() => {
if (window.location.search) { if (window.location.search || window.location.hash) {
window.history.replaceState(null, "", window.location.pathname); window.history.replaceState(null, "", window.location.pathname);
} }
}, []); }, []);
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
// and wrongly flip the banner to "invalid".
const verifyFired = useRef(false);
useEffect(() => {
if (!verifyToken || verifyFired.current) return;
verifyFired.current = true;
api
.verifyEmail(verifyToken)
.then(() => setVerify("ok"))
.catch(() => setVerify("invalid"));
}, [verifyToken]);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin"); const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");

View file

@ -97,6 +97,15 @@
"saving": "Speichern…", "saving": "Speichern…",
"saved": "Passwort für {{email}} aktualisiert.", "saved": "Passwort für {{email}} aktualisiert.",
"failed": "Das Passwort konnte nicht aktualisiert werden." "failed": "Das Passwort konnte nicht aktualisiert werden."
},
"sessions": {
"title": "Aktive Sitzungen",
"hint": "Diese Kontoanmeldung überall sonst abmelden — praktisch, wenn du auf einem geteilten oder verlorenen Gerät angemeldet geblieben bist. Hier bleibst du angemeldet.",
"action": "Andere Sitzungen abmelden",
"confirmTitle": "Andere Sitzungen abmelden?",
"confirmBody": "Damit wirst du in allen anderen Browsern und auf allen anderen Geräten abgemeldet. Hier bleibst du angemeldet.",
"done": "Von allen anderen Sitzungen abgemeldet.",
"failed": "Andere Sitzungen konnten nicht abgemeldet werden. Bitte versuche es erneut."
} }
}, },
"demo": { "demo": {

View file

@ -97,6 +97,15 @@
"saving": "Saving…", "saving": "Saving…",
"saved": "Password updated for {{email}}.", "saved": "Password updated for {{email}}.",
"failed": "Couldn't update the password." "failed": "Couldn't update the password."
},
"sessions": {
"title": "Active sessions",
"hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.",
"action": "Log out other sessions",
"confirmTitle": "Log out other sessions?",
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.",
"done": "Signed out of all other sessions.",
"failed": "Couldn't sign out other sessions. Please try again."
} }
}, },
"demo": { "demo": {

View file

@ -97,6 +97,15 @@
"saving": "Mentés…", "saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}.", "saved": "Jelszó frissítve ehhez: {{email}}.",
"failed": "Nem sikerült frissíteni a jelszót." "failed": "Nem sikerült frissíteni a jelszót."
},
"sessions": {
"title": "Aktív munkamenetek",
"hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.",
"action": "Kijelentkezés máshonnan",
"confirmTitle": "Kijelentkezés a többi munkamenetből?",
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.",
"done": "Kijelentkeztetve minden más munkamenetből.",
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
} }
}, },
"demo": { "demo": {

View file

@ -1391,6 +1391,11 @@ export const api = {
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// SA4: sign every OTHER session for this account out (keeps the current one).
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
// Set or change the signed-in account's password (errors shown inline via quiet). // Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req( req(

View file

@ -14,6 +14,14 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.39.0",
date: "2026-07-12",
summary: "Sign-in security hardening.",
features: [
"New “Log out other sessions” button in Settings → Account, to sign out everywhere else.",
],
},
{ {
version: "0.38.0", version: "0.38.0",
date: "2026-07-12", date: "2026-07-12",