Merge feature/auth-security: SB3 + SA4 (SA3 deferred)
SB3: reset/verify email tokens moved from URL query strings to the fragment (never sent to the server / proxy logs / Referer); verify GET→POST with a legacy-GET fallback. SA4: server-side session revocation via per-user session_epoch (migration 0053) — a signed cookie records its epoch, current_user + the messages WS reject a stale-epoch cookie. Bumped on password reset (all sessions), password change + a new 'Log out other sessions' action (both keep the current session). E2E-verified; UAT passed. SA3 (trusted-proxy for X-Forwarded-For) intentionally deferred to a prod-tested follow-up.
This commit is contained in:
commit
2eca56d68a
10 changed files with 215 additions and 12 deletions
27
backend/alembic/versions/0053_user_session_epoch.py
Normal file
27
backend/alembic/versions/0053_user_session_epoch.py
Normal 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")
|
||||||
|
|
@ -341,6 +341,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 +494,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 +507,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 +590,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 +603,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 +680,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 +700,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 +743,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 +770,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 +800,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 +873,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 +893,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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue