diff --git a/.env.example b/.env.example index cb17209..3e4dae1 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,16 @@ ADMIN_EMAILS= # Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production. 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). # 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 diff --git a/README.md b/README.md index cd72694..22ef321 100644 --- a/README.md +++ b/README.md @@ -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 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 -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 diff --git a/VERSION b/VERSION index ca75280..4ef2eb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.0 +0.39.0 diff --git a/backend/alembic/versions/0053_user_session_epoch.py b/backend/alembic/versions/0053_user_session_epoch.py new file mode 100644 index 0000000..6f5c838 --- /dev/null +++ b/backend/alembic/versions/0053_user_session_epoch.py @@ -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") diff --git a/backend/app/auth.py b/backend/app/auth.py index 97bb25d..5874b65 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,3 +1,4 @@ +import ipaddress import logging import secrets from datetime import datetime, timedelta, timezone @@ -172,13 +173,35 @@ def get_or_create_demo_user(db: Session) -> 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: - """Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the - real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise.""" - xff = request.headers.get("x-forwarded-for") - if xff: - return xff.split(",")[0].strip() - return request.client.host if request.client else "unknown" + """Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it + ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips + — the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS + the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a + client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the + 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: @@ -341,6 +364,7 @@ async def callback( accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) 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. request.session.clear() request.session["user_id"] = demo.id + _remember_epoch(request, demo) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) return {"authenticated": True} return {"authenticated": False} @@ -505,6 +530,7 @@ def _establish_session(request: Request, user: User) -> None: accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) def _app_base() -> str: @@ -587,8 +613,10 @@ def register( upsert_pending_invite(db, email) # admin-approval gate if email_ok: 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( - 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: background.add_task( @@ -598,9 +626,22 @@ def register( 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") 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") if user is None: 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() 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}") + # 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"} @@ -680,6 +723,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict 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) + bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions. # Burn any other outstanding reset tokens for this user. for row in db.execute( select(AuthToken).where( @@ -722,6 +766,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]: 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: user_id, is_default = resolved_user_id(request) if not user_id: @@ -734,6 +793,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: if is_default: request.session.clear() 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 # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. default_id = request.session.get("user_id") @@ -756,6 +823,24 @@ def optional_current_user( 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: """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 @@ -811,7 +896,10 @@ async def link_google( @router.post("/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: """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 @@ -828,7 +916,12 @@ def set_password( 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) + # 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() + _remember_epoch(request, user) log.info("Password set/changed: uid=%s", user.id) return {"ok": True} diff --git a/backend/app/config.py b/backend/app/config.py index 3f75965..a4d6c10 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -52,6 +52,14 @@ class Settings(BaseSettings): # Origin of a separately served frontend dev server (enables CORS). Empty in production. 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) --- # Gmail SMTP + App Password by default. All optional: if unset, email is skipped # (fail-soft) and onboarding still works via in-app notifications. @@ -226,6 +234,10 @@ class Settings(BaseSettings): def admin_email_set(self) -> set[str]: 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 def app_base(self) -> str: """Public origin of the deployed app, derived from the OAuth redirect URL diff --git a/backend/app/models.py b/backend/app/models.py index d65ee87..9b31e67 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -63,6 +63,11 @@ class User(Base, TimestampMixin): is_suspended: Mapped[bool] = mapped_column( 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)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 53a68e8..420bd58 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -381,7 +381,13 @@ async def messages_ws(ws: WebSocket) -> None: return db = SessionLocal() 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: db.close() if not ok: diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 04ac8af..f7fc271 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -10,4 +10,9 @@ echo "Starting Siftlode API..." # 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, # 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 diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 94d68b6..8997a3a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -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 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) The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 91c7e1e..0399ea3 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -308,11 +308,36 @@ function AccessRow({ function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [err, setErr] = useState(null); 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) => { e.preventDefault(); @@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) { )} + +
+
+
+
{t("settings.account.sessions.title")}
+

+ {t("settings.account.sessions.hint")} +

+
+ +
+
); } diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 56b3ce1..446630a 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowRight, @@ -259,22 +259,42 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose 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). + // Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below + // 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 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 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 + // 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(() => 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(() => { - if (window.location.search) { + if (window.location.search || window.location.hash) { 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(resetToken ? "reset" : "signin"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index c441032..3425b9f 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -97,6 +97,15 @@ "saving": "Speichern…", "saved": "Passwort für {{email}} aktualisiert.", "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": { diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index cd9e2d2..c6c4ac0 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -97,6 +97,15 @@ "saving": "Saving…", "saved": "Password updated for {{email}}.", "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": { diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index c8dea12..1048ae0 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -97,6 +97,15 @@ "saving": "Mentés…", "saved": "Jelszó frissítve ehhez: {{email}}.", "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": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8b4c534..53e0de2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1391,6 +1391,11 @@ export const api = { 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 }), + // 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). setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => req( diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 6d0fbe8..c6cf63c 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,14 @@ export interface 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", date: "2026-07-12",