Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7339a0b84b | ||
|
|
c960c07a6a | ||
|
|
3cfdb36f06 | ||
|
|
ada95d04a4 | ||
|
|
1b004e6ccf | ||
|
|
95c33e0b0f | ||
|
|
4e6bcbc078 | ||
|
|
e17fb5bbf5 | ||
|
|
a9044ece50 | ||
|
|
473aafd1c8 | ||
|
|
7019e3afdf | ||
|
|
c81b110637 | ||
|
|
2ecc70ca6d | ||
|
|
f8c63e80c2 | ||
|
|
3ac3d382e7 |
@@ -0,0 +1,58 @@
|
|||||||
|
"""users.password_revoked_at — remember that SEC-10 dropped a preset password
|
||||||
|
|
||||||
|
Revision ID: 0078_password_revoked_at
|
||||||
|
Revises: 0077_plex_playback_class
|
||||||
|
Create Date: 2026-09-02
|
||||||
|
|
||||||
|
SEC-10 (`auth.revoke_preset_password_if_unverified`) nulls `password_hash` when an account whose email
|
||||||
|
was never verified is attested (Google adoption, or admin approval). That left NO trace, and
|
||||||
|
password-reset eligibility keyed off `password_hash IS NOT NULL` — so such an account could neither
|
||||||
|
sign in (no password) nor request a reset link (not "eligible"): a permanent lockout with a silent,
|
||||||
|
uniform response at every step.
|
||||||
|
|
||||||
|
This column is that trace. A row with `password_revoked_at` set is a local account that HAD a password
|
||||||
|
and had it revoked, so the reset flow treats it as eligible again. Setting a password (reset-confirm or
|
||||||
|
/auth/set-password) clears the stamp. A Google-only account that never had a password keeps it NULL and
|
||||||
|
stays reset-ineligible by design — its owner adds a password from Settings while signed in.
|
||||||
|
|
||||||
|
Backfill: the signature is exact and narrow — a local (google_sub IS NULL), non-demo account with NO
|
||||||
|
password could ONLY have got there through the revoke. Registration always sets a hash, the setup
|
||||||
|
wizard sets one too, every Google row carries a google_sub (the adoption path assigns it BEFORE
|
||||||
|
revoking), and nothing in the codebase ever clears google_sub. Stamp those with the current time so
|
||||||
|
they become recoverable without operator intervention.
|
||||||
|
|
||||||
|
Deliberately NOT also requiring `email_verified = false`, even though the revoke only fires on an
|
||||||
|
unverified account: verifying is exactly what such a victim does NEXT. The revoke does not invalidate
|
||||||
|
the outstanding verification token, so clicking the emailed link sets email_verified=True while
|
||||||
|
password_hash stays NULL — and that row has no route back at all (reset ineligible, resend refuses an
|
||||||
|
already-verified address, and set-password needs a signed-in session the account can no longer obtain:
|
||||||
|
no password to sign in with, and no Google identity). Those are the victims who most need the stamp,
|
||||||
|
so the predicate must not exclude them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0078_password_revoked_at"
|
||||||
|
down_revision: Union[str, None] = "0077_plex_playback_class"
|
||||||
|
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_revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE users SET password_revoked_at = now() "
|
||||||
|
"WHERE password_hash IS NULL "
|
||||||
|
"AND google_sub IS NULL "
|
||||||
|
"AND is_demo = false"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "password_revoked_at")
|
||||||
@@ -10,7 +10,9 @@ verification links, password resets and access-request notices, and the operator
|
|||||||
to be a person telling them nothing arrived.
|
to be a person telling them nothing arrived.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
@@ -105,6 +107,48 @@ def _admin_contact() -> str | None:
|
|||||||
return next(iter(sorted(settings.admin_email_set)), None)
|
return next(iter(sorted(settings.admin_email_set)), None)
|
||||||
|
|
||||||
|
|
||||||
|
# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail,
|
||||||
|
# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the
|
||||||
|
# link as dead text — which, for a verification or password-reset mail, is the whole message. So
|
||||||
|
# every mail also carries an HTML alternative built from the SAME plain body (below), rather than
|
||||||
|
# per-template HTML that would have to be kept in sync with it.
|
||||||
|
#
|
||||||
|
# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim
|
||||||
|
# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping
|
||||||
|
# turns `&` into `&`, so a query string can legitimately end in a semicolon.
|
||||||
|
_URL_RE = re.compile(r"https?://[^\s<]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _html_body(text: str) -> str:
|
||||||
|
"""Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with
|
||||||
|
real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies
|
||||||
|
interpolate untrusted values (a requester's email address, the operator contact), so no caller
|
||||||
|
can inject markup, and `&` inside a URL is already `&` by the time it reaches the href, which
|
||||||
|
is what an HTML attribute wants anyway."""
|
||||||
|
|
||||||
|
def _anchor(m: re.Match[str]) -> str:
|
||||||
|
url = m.group(0)
|
||||||
|
trail = ""
|
||||||
|
while url and url[-1] in ".,!?)":
|
||||||
|
url, trail = url[:-1], url[-1] + trail
|
||||||
|
if not url:
|
||||||
|
return trail
|
||||||
|
return f'<a href="{url}">{url}</a>{trail}'
|
||||||
|
|
||||||
|
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
|
||||||
|
rendered = "".join(
|
||||||
|
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
|
||||||
|
)
|
||||||
|
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
|
||||||
|
# the HTML part exists to make the link clickable, not to become a template to maintain.
|
||||||
|
return (
|
||||||
|
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
|
||||||
|
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
|
||||||
|
f'<div style="max-width:560px">{rendered}</div>'
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
|
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
|
||||||
recipients = [e for e in to if e]
|
recipients = [e for e in to if e]
|
||||||
if not recipients:
|
if not recipients:
|
||||||
@@ -123,7 +167,10 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
if reply_to:
|
if reply_to:
|
||||||
msg["Reply-To"] = reply_to
|
msg["Reply-To"] = reply_to
|
||||||
|
# text/plain stays the primary part (it is the source of truth, and a text-only client still
|
||||||
|
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
|
msg.add_alternative(_html_body(body), subtype="html")
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(c["host"], c["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())
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class User(Base, TimestampMixin):
|
|||||||
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.
|
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
|
||||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
# When SEC-10 dropped a preset password (see auth.revoke_preset_password_if_unverified). Without
|
||||||
|
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
|
||||||
|
# password-reset eligibility check and could never be recovered — it could neither sign in nor
|
||||||
|
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
|
||||||
|
# password is set again (reset-confirm / set-password).
|
||||||
|
password_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
||||||
# verifies via a link). Login requires this AND is_active.
|
# verifies via a link). Login requires this AND is_active.
|
||||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
|||||||
@@ -662,15 +662,22 @@ def _register_account(email: str, password: str) -> None:
|
|||||||
@router.post("/verify")
|
@router.post("/verify")
|
||||||
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
||||||
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||||
on any invalid/expired/used token."""
|
on any invalid/expired/used token.
|
||||||
|
|
||||||
|
`password_reset_required` tells the SPA to send the user straight to the reset flow: an admin who
|
||||||
|
approved this account BEFORE it was verified tripped SEC-10, so the password chosen at registration
|
||||||
|
is gone and sign-in would answer with the deliberately uniform "Invalid email or password" — true,
|
||||||
|
but unreadable to the person who just proved they own the mailbox. Saying so leaks nothing: holding
|
||||||
|
a valid single-use verify token for this account already proves that much."""
|
||||||
user = _consume_token(db, body.token, "verify")
|
user = _consume_token(db, body.token, "verify")
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="This verification link is invalid or has expired."
|
status_code=400, detail="This verification link is invalid or has expired."
|
||||||
)
|
)
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
|
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True, "password_reset_required": needs_password}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify/resend")
|
@router.post("/verify/resend")
|
||||||
@@ -714,8 +721,10 @@ def verify_email(token: str, db: Session = Depends(get_db)):
|
|||||||
if user is None:
|
if user is None:
|
||||||
return RedirectResponse(url="/?verify=invalid")
|
return RedirectResponse(url="/?verify=invalid")
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
|
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/?verify=ok")
|
# Same two outcomes as the POST flow above, so an old link in flight lands on the same guidance.
|
||||||
|
return RedirectResponse(url="/?verify=" + ("ok_reset" if needs_password else "ok"))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password-login")
|
@router.post("/password-login")
|
||||||
@@ -780,10 +789,18 @@ def _send_reset_if_eligible(email: str) -> None:
|
|||||||
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
|
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
|
||||||
for a real (non-demo) password account. Runs after the response with its own DB session, so the
|
for a real (non-demo) password account. Runs after the response with its own DB session, so the
|
||||||
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
|
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
|
||||||
can't leak into proxy/access logs or a Referer (SB3)."""
|
can't leak into proxy/access logs or a Referer (SB3).
|
||||||
|
|
||||||
|
"Password account" is `password_hash OR password_revoked_at` — an account SEC-10 stripped had a
|
||||||
|
password and must be able to get a new one, or it is locked out permanently (no password to sign in
|
||||||
|
with, and formerly no reset link either). A Google-only account that never had a password stays
|
||||||
|
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
|
||||||
|
proves identity through the session rather than by mail."""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
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 None or user.is_demo:
|
||||||
|
return
|
||||||
|
if user.password_hash or user.password_revoked_at:
|
||||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||||
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
|
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
|
||||||
|
|
||||||
@@ -802,6 +819,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
|||||||
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)
|
||||||
|
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
||||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||||
# email — this rescues an account that registered but never clicked the verification link (it
|
# email — this rescues an account that registered but never clicked the verification link (it
|
||||||
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
||||||
@@ -844,9 +862,15 @@ def revoke_preset_password_if_unverified(user: User) -> None:
|
|||||||
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
|
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
|
||||||
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
|
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
|
||||||
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
|
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
|
||||||
rule — both the Google-callback and admin-activation paths call it so they can't drift."""
|
rule — both the Google-callback and admin-activation paths call it so they can't drift.
|
||||||
|
|
||||||
|
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
|
||||||
|
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
|
||||||
|
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
|
||||||
|
was locked out for good (it could not sign in either). The stamp separates the two cases."""
|
||||||
if not user.email_verified:
|
if not user.email_verified:
|
||||||
user.password_hash = None
|
user.password_hash = None
|
||||||
|
user.password_revoked_at = datetime.now(timezone.utc)
|
||||||
bump_session_epoch(user)
|
bump_session_epoch(user)
|
||||||
|
|
||||||
|
|
||||||
@@ -916,6 +940,7 @@ def set_password(
|
|||||||
if not verify_password(body.current_password or "", user.password_hash):
|
if not verify_password(body.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)
|
||||||
|
user.password_revoked_at = None # same as the reset path: a password exists again
|
||||||
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
# 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
|
# 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).
|
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||||
|
|||||||
@@ -341,3 +341,134 @@ def test_register_route_is_refused_when_registration_is_closed(client, db, monke
|
|||||||
db.execute(_select(User).where(User.email == "closed@example.com")).scalar_one_or_none()
|
db.execute(_select(User).where(User.email == "closed@example.com")).scalar_one_or_none()
|
||||||
is None
|
is None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
|
||||||
|
#
|
||||||
|
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
|
||||||
|
# approved by an admin BEFORE its email was verified, ended up with password_hash = NULL. That is
|
||||||
|
# also what a Google-only account looks like, and reset eligibility keyed off `password_hash` alone —
|
||||||
|
# so the account could neither sign in (no password) nor obtain a reset link, and every response on
|
||||||
|
# the way was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two
|
||||||
|
# apart; these pin each step of the way back in.
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||||
|
"""Patch the reset mailer and collect the recipients it is asked to write to."""
|
||||||
|
from app.routes import auth as auth_mod
|
||||||
|
|
||||||
|
sent: list[str] = []
|
||||||
|
monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to))
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_approval_revoke_stamps_password_revoked_at(db, make_user):
|
||||||
|
from app.routes.admin import _activate_user_by_email
|
||||||
|
|
||||||
|
victim = make_user(
|
||||||
|
email="stamped@example.com",
|
||||||
|
password="attacker-chosen",
|
||||||
|
email_verified=False,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
assert victim.password_revoked_at is None
|
||||||
|
|
||||||
|
_activate_user_by_email(db, "stamped@example.com")
|
||||||
|
|
||||||
|
assert victim.password_hash is None
|
||||||
|
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
|
||||||
|
db, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
|
||||||
|
from app.routes.admin import _activate_user_by_email
|
||||||
|
from app.routes.auth import _send_reset_if_eligible
|
||||||
|
|
||||||
|
make_user(
|
||||||
|
email="lockedout@example.com",
|
||||||
|
password="chosen at registration",
|
||||||
|
email_verified=False,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
_activate_user_by_email(db, "lockedout@example.com")
|
||||||
|
sent = _capture_reset_links(monkeypatch)
|
||||||
|
|
||||||
|
_send_reset_if_eligible("lockedout@example.com")
|
||||||
|
|
||||||
|
assert sent == ["lockedout@example.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkeypatch):
|
||||||
|
# The other side of the rule: an account that NEVER had a password gets no reset link — its owner
|
||||||
|
# adds one from Settings while signed in (/auth/set-password), which proves identity by session.
|
||||||
|
from app.routes.auth import _send_reset_if_eligible
|
||||||
|
|
||||||
|
make_user(email="googler@example.com", password=None, google_sub="sub-123")
|
||||||
|
sent = _capture_reset_links(monkeypatch)
|
||||||
|
|
||||||
|
_send_reset_if_eligible("googler@example.com")
|
||||||
|
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
|
||||||
|
from app.routes.admin import _activate_user_by_email
|
||||||
|
from app.routes.auth import _issue_token
|
||||||
|
|
||||||
|
user = make_user(
|
||||||
|
email="recovered@example.com",
|
||||||
|
password="chosen at registration",
|
||||||
|
email_verified=False,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
_activate_user_by_email(db, "recovered@example.com")
|
||||||
|
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ok = client.post(
|
||||||
|
"/auth/password-reset/confirm", json={"token": raw, "password": "a brand new one"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert user.password_hash is not None
|
||||||
|
assert user.password_revoked_at is None # spent — not a permanently reset-eligible account
|
||||||
|
assert _login(client, "recovered@example.com", "a brand new one").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db, make_user):
|
||||||
|
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
|
||||||
|
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
|
||||||
|
# password" and has no way to learn why the password they chose stopped working.
|
||||||
|
from app.routes.admin import _activate_user_by_email
|
||||||
|
from app.routes.auth import _issue_token
|
||||||
|
|
||||||
|
user = make_user(
|
||||||
|
email="needspw@example.com",
|
||||||
|
password="chosen at registration",
|
||||||
|
email_verified=False,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
_activate_user_by_email(db, "needspw@example.com")
|
||||||
|
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = client.post("/auth/verify", json={"token": raw})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True, "password_reset_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
|
||||||
|
from app.routes.auth import _issue_token
|
||||||
|
|
||||||
|
user = make_user(email="intact@example.com", email_verified=False, is_active=False)
|
||||||
|
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = client.post("/auth/verify", json={"token": raw})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True, "password_reset_required": False}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Every outbound mail carries an HTML alternative, so its link is actually clickable.
|
||||||
|
|
||||||
|
The bodies are plain text and stay that way — but a bare URL in a text/plain mail is only a link if
|
||||||
|
the CLIENT chooses to linkify it. Gmail/Outlook/Apple Mail do; lighter web clients render the body
|
||||||
|
verbatim and leave the verification or password-reset link as dead text, which is the entire point of
|
||||||
|
those two messages. So `_send` now attaches an HTML part generated from the same plain body.
|
||||||
|
|
||||||
|
These are pure-logic tests except the last one, which drives the real `_send` against a fake SMTP so
|
||||||
|
the wire format itself is pinned, not just the helper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import email as email_mod
|
||||||
|
|
||||||
|
_SMTP_OK = {
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 587,
|
||||||
|
"user": "u",
|
||||||
|
"password": "p",
|
||||||
|
"from": "Siftlode <u@example.com>",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bare_url_becomes_a_real_link():
|
||||||
|
# THE regression: this is what a client that does not linkify plain text was leaving as dead text.
|
||||||
|
out = email_mod._html_body(
|
||||||
|
"Confirm your email:\n\nhttps://siftlode.example/#verify=tok123\n\n— Siftlode"
|
||||||
|
)
|
||||||
|
assert '<a href="https://siftlode.example/#verify=tok123">' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpolated_content_cannot_inject_markup():
|
||||||
|
# Bodies carry untrusted values — a requester's address in the admin notice, the operator contact.
|
||||||
|
# Escaping must run BEFORE linkification, or a crafted address becomes markup in every admin's mail.
|
||||||
|
out = email_mod._html_body('A request from <script>alert("x")</script> & co')
|
||||||
|
assert "<script>" not in out
|
||||||
|
assert "<script>" in out
|
||||||
|
assert "& co" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_query_string_keeps_its_escaped_ampersand_inside_the_href():
|
||||||
|
# `&` is escaped to `&` before the linkifier sees it, which is also what an HTML attribute
|
||||||
|
# wants — so the href must carry the escaped form and nothing may be trimmed off it.
|
||||||
|
out = email_mod._html_body("https://siftlode.example/x?a=1&b=2")
|
||||||
|
assert (
|
||||||
|
'<a href="https://siftlode.example/x?a=1&b=2">https://siftlode.example/x?a=1&b=2</a>'
|
||||||
|
in out
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body,href,trail",
|
||||||
|
[
|
||||||
|
("See https://siftlode.example/x.", "https://siftlode.example/x", "."),
|
||||||
|
("See https://siftlode.example/x, then sign in", "https://siftlode.example/x", ","),
|
||||||
|
("See (https://siftlode.example/x)", "https://siftlode.example/x", ")"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sentence_punctuation_stays_out_of_the_href(body, href, trail):
|
||||||
|
out = email_mod._html_body(body)
|
||||||
|
assert f'<a href="{href}">{href}</a>{trail}' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_token_ending_in_a_url_safe_character_is_not_trimmed():
|
||||||
|
# token_urlsafe output can end in `-` or `_`; trimming either would hand the user a dead link.
|
||||||
|
for tok in ("abc-", "abc_"):
|
||||||
|
out = email_mod._html_body(f"https://siftlode.example/#reset={tok}")
|
||||||
|
assert f'href="https://siftlode.example/#reset={tok}"' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_lines_become_paragraphs_and_single_breaks_stay_inline():
|
||||||
|
out = email_mod._html_body("Hi,\n\nline one\nline two\n\n— Siftlode")
|
||||||
|
assert out.count("<p>") == 3
|
||||||
|
assert "<p>line one<br>line two</p>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
|
||||||
|
# The wire format, not just the helper: text/plain stays the primary part and the source of
|
||||||
|
# truth, with text/html alongside it.
|
||||||
|
monkeypatch.setattr(email_mod, "_smtp", lambda: _SMTP_OK)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class _FakeSMTP:
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def starttls(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def login(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_message(self, msg):
|
||||||
|
sent.append(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
email_mod.send_verify_email("someone@example.com", "https://siftlode.example/#verify=tok")
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
(msg,) = sent
|
||||||
|
assert msg.get_content_type() == "multipart/alternative"
|
||||||
|
plain = msg.get_body(preferencelist=("plain",))
|
||||||
|
rich = msg.get_body(preferencelist=("html",))
|
||||||
|
assert plain is not None and rich is not None
|
||||||
|
# The plain part must be byte-for-byte the template's own body — the HTML part is derived from
|
||||||
|
# it, never a second copy that could drift.
|
||||||
|
assert "https://siftlode.example/#verify=tok" in plain.get_content()
|
||||||
|
assert "<a href=" not in plain.get_content()
|
||||||
|
assert '<a href="https://siftlode.example/#verify=tok">' in rich.get_content()
|
||||||
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { useUndoable } from "../lib/useUndoable";
|
import { useUndoable } from "../lib/useUndoable";
|
||||||
import { cycleSort, type SortState } from "../lib/columnSort";
|
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||||
|
import { formatRuntime } from "../lib/format";
|
||||||
import { applyView, sameOrder } from "../lib/playlistView";
|
import { applyView, sameOrder } from "../lib/playlistView";
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
@@ -259,6 +260,13 @@ export default function Playlists() {
|
|||||||
return (v: Video) => m.get(v.id) ?? 0;
|
return (v: Video) => m.get(v.id) ?? 0;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
|
||||||
|
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
|
||||||
|
const totalSeconds = useMemo(
|
||||||
|
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
|
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
|
||||||
[t, storedIndex, onPlay, onRemove, canEdit],
|
[t, storedIndex, onPlay, onRemove, canEdit],
|
||||||
@@ -591,176 +599,192 @@ export default function Playlists() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pb-4 pt-3">
|
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||||
|
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||||
|
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||||
|
<div className="px-4 pb-4">
|
||||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||||
fixed band, so only the video list scrolls. */}
|
fixed band, so only the video list scrolls. */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
|
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||||
<div className="flex gap-3 items-start mb-4">
|
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
|
||||||
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
|
row it sheds is list height won back — the old layout stacked three rows next to the
|
||||||
|
cover plus a fourth block under it, for roughly twice this height. */}
|
||||||
|
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||||
{items[0]?.thumbnail_url && (
|
{items[0]?.thumbnail_url && (
|
||||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{renaming ? (
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<input
|
{renaming ? (
|
||||||
autoFocus
|
<input
|
||||||
value={renameValue}
|
autoFocus
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
value={renameValue}
|
||||||
onBlur={saveRename}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onBlur={saveRename}
|
||||||
if (e.key === "Enter") saveRename();
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") setRenaming(false);
|
if (e.key === "Enter") saveRename();
|
||||||
}}
|
if (e.key === "Escape") setRenaming(false);
|
||||||
aria-label={t("playlists.rename")}
|
}}
|
||||||
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
aria-label={t("playlists.rename")}
|
||||||
/>
|
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
) : (
|
/>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
) : (
|
||||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
<>
|
||||||
{editable && (
|
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||||
<button
|
{editable && (
|
||||||
onClick={() => {
|
<button
|
||||||
setRenameValue(detail.name);
|
onClick={() => {
|
||||||
setRenaming(true);
|
setRenameValue(detail.name);
|
||||||
}}
|
setRenaming(true);
|
||||||
title={t("playlists.rename")}
|
}}
|
||||||
aria-label={t("playlists.rename")}
|
title={t("playlists.rename")}
|
||||||
className="shrink-0 text-muted hover:text-fg"
|
aria-label={t("playlists.rename")}
|
||||||
|
className="shrink-0 text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{detail.yt_playlist_id && (
|
||||||
|
<a
|
||||||
|
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
title={t("playlists.openOnYoutube")}
|
||||||
|
aria-label={t("playlists.openOnYoutube")}
|
||||||
|
className="shrink-0 text-muted hover:text-accent"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||||
|
{linked && detail.dirty && (
|
||||||
|
<span
|
||||||
|
title={t("playlists.unsyncedHint")}
|
||||||
|
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
|
{t("playlists.unsynced")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||||
|
disabled={!shown.length}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||||
|
</button>
|
||||||
|
{editable && canWrite && (
|
||||||
|
<button
|
||||||
|
onClick={pushToYoutube}
|
||||||
|
disabled={pushing}
|
||||||
|
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||||
|
aria-label={
|
||||||
|
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||||
|
detail.dirty
|
||||||
|
? "border-accent text-accent hover:bg-accent/10"
|
||||||
|
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pushing ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : linked ? (
|
||||||
|
<Youtube className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{detail.yt_playlist_id && (
|
{linked && detail.dirty && (
|
||||||
<a
|
<button
|
||||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
onClick={revertToYoutube}
|
||||||
target="_blank"
|
disabled={reverting}
|
||||||
rel="noreferrer noopener"
|
title={t("playlists.revert")}
|
||||||
title={t("playlists.openOnYoutube")}
|
aria-label={t("playlists.revert")}
|
||||||
aria-label={t("playlists.openOnYoutube")}
|
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||||
className="shrink-0 text-muted hover:text-accent"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||||
</a>
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
onClick={deletePlaylist}
|
||||||
|
title={t("playlists.delete")}
|
||||||
|
aria-label={t("playlists.delete")}
|
||||||
|
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="text-xs text-muted mt-0.5">
|
|
||||||
{t("playlists.itemCount", { count: items.length })}
|
|
||||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
|
||||||
<button
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
<span className="text-xs text-muted">
|
||||||
disabled={!shown.length}
|
{t("playlists.itemCount", { count: items.length })}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||||
>
|
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
</span>
|
||||||
</button>
|
{(items.length > 1 || viewActive) && (
|
||||||
{editable && canWrite && (
|
<>
|
||||||
<button
|
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||||
onClick={pushToYoutube}
|
·
|
||||||
disabled={pushing}
|
</span>
|
||||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||||
aria-label={
|
<input
|
||||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
type="checkbox"
|
||||||
}
|
checked={groupBy}
|
||||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
onChange={(e) => setGroupBy(e.target.checked)}
|
||||||
detail.dirty
|
className="accent-accent"
|
||||||
? "border-accent text-accent hover:bg-accent/10"
|
/>
|
||||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
{t("playlists.groupByChannel")}
|
||||||
}`}
|
</label>
|
||||||
>
|
{viewActive && (
|
||||||
{pushing ? (
|
<button
|
||||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
onClick={clearView}
|
||||||
) : linked ? (
|
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||||
<Youtube className="w-3.5 h-3.5" />
|
>
|
||||||
) : (
|
{t("playlists.clearView")}
|
||||||
<Upload className="w-3.5 h-3.5" />
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
{canSaveOrder && (
|
||||||
)}
|
<button
|
||||||
{linked && detail.dirty && (
|
onClick={saveOrder}
|
||||||
<button
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||||
onClick={revertToYoutube}
|
>
|
||||||
disabled={reverting}
|
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||||
title={t("playlists.revert")}
|
</button>
|
||||||
aria-label={t("playlists.revert")}
|
)}
|
||||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||||
>
|
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
question a user actually has — "did I just change my playlist?". Worse,
|
||||||
</button>
|
sorting a list whose stored order ALREADY matches the sort produced a
|
||||||
)}
|
warning, no Save button and no visible change, which reads as a malfunction.
|
||||||
{editable && (
|
Now the line says which of the three situations you are in; the disabled grip
|
||||||
<button
|
still explains itself on hover/focus. */}
|
||||||
onClick={deletePlaylist}
|
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||||
title={t("playlists.delete")}
|
{canEdit && (
|
||||||
aria-label={t("playlists.delete")}
|
<div className="ml-auto">
|
||||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
<UndoToolbar
|
||||||
>
|
canUndo={order.canUndo}
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
canRedo={order.canRedo}
|
||||||
</button>
|
onUndo={order.undo}
|
||||||
)}
|
onRedo={order.redo}
|
||||||
{linked && detail.dirty && (
|
/>
|
||||||
<span
|
</div>
|
||||||
title={t("playlists.unsyncedHint")}
|
)}
|
||||||
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1"
|
</>
|
||||||
>
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
|
||||||
{t("playlists.unsynced")}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(items.length > 1 || viewActive) && (
|
|
||||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={groupBy}
|
|
||||||
onChange={(e) => setGroupBy(e.target.checked)}
|
|
||||||
className="accent-accent"
|
|
||||||
/>
|
|
||||||
{t("playlists.groupByChannel")}
|
|
||||||
</label>
|
|
||||||
{viewActive && (
|
|
||||||
<button
|
|
||||||
onClick={clearView}
|
|
||||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
|
||||||
>
|
|
||||||
{t("playlists.clearView")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canSaveOrder && (
|
|
||||||
<button
|
|
||||||
onClick={saveOrder}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
|
||||||
>
|
|
||||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
|
|
||||||
link + save hint + "reordering is off"), and none of them answered the question a
|
|
||||||
user actually has — "did I just change my playlist?". Worse, sorting a list whose
|
|
||||||
stored order ALREADY matches the sort produced a warning, no Save button and no
|
|
||||||
visible change, which reads as a malfunction. Now the line says which of the three
|
|
||||||
situations you are in; the disabled grip still explains itself on hover/focus. */}
|
|
||||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
|
||||||
{canEdit && (
|
|
||||||
<div className="ml-auto">
|
|
||||||
<UndoToolbar
|
|
||||||
canUndo={order.canUndo}
|
|
||||||
canRedo={order.canRedo}
|
|
||||||
onUndo={order.undo}
|
|
||||||
onRedo={order.redo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||||
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
|
|||||||
<div className="text-2xs text-muted">
|
<div className="text-2xs text-muted">
|
||||||
{t("playlists.itemCount", { count: pl.item_count })}
|
{t("playlists.itemCount", { count: pl.item_count })}
|
||||||
{pl.total_duration_seconds > 0 &&
|
{pl.total_duration_seconds > 0 &&
|
||||||
` · ${formatDuration(pl.total_duration_seconds)}`}
|
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexCard } from "../lib/api";
|
import { api, type PlexCard } from "../lib/api";
|
||||||
|
import { formatRuntime } from "../lib/format";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
|
|||||||
}, [q.data]);
|
}, [q.data]);
|
||||||
const blocks = useMemo(() => buildBlocks(items), [items]);
|
const blocks = useMemo(() => buildBlocks(items), [items]);
|
||||||
const order = useMemo(() => items.map((i) => i.id), [items]);
|
const order = useMemo(() => items.map((i) => i.id), [items]);
|
||||||
|
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
|
||||||
|
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
|
||||||
|
const totalSeconds = useMemo(
|
||||||
|
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||||
@@ -517,18 +524,26 @@ export default function PlexPlaylistView({
|
|||||||
|
|
||||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
{renaming ? (
|
<div className="min-w-0 flex-1">
|
||||||
<input
|
{renaming ? (
|
||||||
autoFocus
|
<input
|
||||||
value={name}
|
autoFocus
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onBlur={rename}
|
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
onBlur={rename}
|
||||||
/>
|
className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
) : (
|
/>
|
||||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
) : (
|
||||||
)}
|
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="mt-0.5 text-sm text-muted">
|
||||||
|
{t("plex.browse.items", { count: items.length })}
|
||||||
|
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||||
<ViewSwitcher
|
<ViewSwitcher
|
||||||
value={layout}
|
value={layout}
|
||||||
|
|||||||
@@ -339,7 +339,10 @@ function AuthCard() {
|
|||||||
verifyFired.current = true;
|
verifyFired.current = true;
|
||||||
api
|
api
|
||||||
.verifyEmail(verifyToken)
|
.verifyEmail(verifyToken)
|
||||||
.then(() => setVerify("ok"))
|
// "ok_reset": verified, but SEC-10 had dropped the password this account registered with (an
|
||||||
|
// admin approved it before it was verified). Sign-in would answer with the uniform credentials
|
||||||
|
// error, so point the user at the reset flow instead. Same value the legacy ?verify= redirect uses.
|
||||||
|
.then((r) => setVerify(r.password_reset_required ? "ok_reset" : "ok"))
|
||||||
.catch(() => setVerify("invalid"));
|
.catch(() => setVerify("invalid"));
|
||||||
}, [verifyToken]);
|
}, [verifyToken]);
|
||||||
|
|
||||||
@@ -392,6 +395,13 @@ function AuthCard() {
|
|||||||
setMode("resend");
|
setMode("resend");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Used by the "verified, but your password was revoked" banner — the only way back into that
|
||||||
|
// account is a reset link, so put the user one click from it.
|
||||||
|
const goForgot = () => {
|
||||||
|
reset();
|
||||||
|
setMode("forgot");
|
||||||
|
};
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent) {
|
async function onSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
reset();
|
reset();
|
||||||
@@ -465,6 +475,14 @@ function AuthCard() {
|
|||||||
|
|
||||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||||
|
{verify === "ok_reset" && (
|
||||||
|
<Banner tone="warn">
|
||||||
|
{t("welcome.auth.verifyOkReset")}{" "}
|
||||||
|
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||||
|
{t("welcome.auth.verifyOkResetCta")}
|
||||||
|
</button>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
{verify === "invalid" && (
|
{verify === "invalid" && (
|
||||||
<Banner tone="warn">
|
<Banner tone="warn">
|
||||||
{t("welcome.auth.verifyInvalid")}{" "}
|
{t("welcome.auth.verifyInvalid")}{" "}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export type PageDef = {
|
|||||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||||
feed: { Component: FeedPage },
|
feed: { Component: FeedPage },
|
||||||
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
||||||
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
||||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||||
messages: { Component: MessagesPage },
|
messages: { Component: MessagesPage },
|
||||||
|
|||||||
@@ -350,7 +350,8 @@
|
|||||||
"browse": "Browse"
|
"browse": "Browse"
|
||||||
},
|
},
|
||||||
"browse": {
|
"browse": {
|
||||||
"items": "{{count}} items"
|
"items_one": "{{count}} item",
|
||||||
|
"items_other": "{{count}} items"
|
||||||
},
|
},
|
||||||
"discover": {
|
"discover": {
|
||||||
"tab": "Discover",
|
"tab": "Discover",
|
||||||
|
|||||||
@@ -86,6 +86,8 @@
|
|||||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
|
"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.",
|
"resetDone": "Your password has been set — you can sign in now.",
|
||||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||||
|
"verifyOkReset": "Email confirmed. Your account was approved before you confirmed it, so the password you registered with was cleared for security — choose a new one to sign in.",
|
||||||
|
"verifyOkResetCta": "Set a new password",
|
||||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||||
"resendCta": "Send a new link",
|
"resendCta": "Send a new link",
|
||||||
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
||||||
|
|||||||
@@ -350,7 +350,8 @@
|
|||||||
"browse": "Böngészés"
|
"browse": "Böngészés"
|
||||||
},
|
},
|
||||||
"browse": {
|
"browse": {
|
||||||
"items": "{{count}} elem"
|
"items_one": "{{count}} elem",
|
||||||
|
"items_other": "{{count}} elem"
|
||||||
},
|
},
|
||||||
"discover": {
|
"discover": {
|
||||||
"tab": "Felfedezés",
|
"tab": "Felfedezés",
|
||||||
|
|||||||
@@ -86,6 +86,8 @@
|
|||||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
|
"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.",
|
"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.",
|
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
|
||||||
|
"verifyOkReset": "E-mail megerősítve. A fiókodat még a megerősítés előtt jóváhagyták, ezért biztonsági okból töröltük a regisztrációkor megadott jelszavadat — válassz újat a belépéshez.",
|
||||||
|
"verifyOkResetCta": "Új jelszó beállítása",
|
||||||
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
||||||
"resendCta": "Új link küldése",
|
"resendCta": "Új link küldése",
|
||||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export const authApi = {
|
|||||||
{ quiet: true },
|
{ quiet: true },
|
||||||
),
|
),
|
||||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
// `password_reset_required` = SEC-10 dropped the password this account registered with (an admin
|
||||||
|
// approved it before it was verified), so signing in would fail with the uniform credentials error.
|
||||||
|
// The card sends the user to the reset flow instead of leaving them to guess.
|
||||||
|
verifyEmail: (token: string): Promise<{ ok: boolean; password_reset_required?: boolean }> =>
|
||||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||||
// reveals whether the email has a pending account.
|
// reveals whether the email has a pending account.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format";
|
import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format";
|
||||||
import en from "../i18n/locales/en/time.json";
|
import en from "../i18n/locales/en/time.json";
|
||||||
import hu from "../i18n/locales/hu/time.json";
|
import hu from "../i18n/locales/hu/time.json";
|
||||||
|
|
||||||
@@ -108,3 +108,37 @@ describe("relativeFromMs", () => {
|
|||||||
expect(hu).toHaveProperty("justNow");
|
expect(hu).toHaveProperty("justNow");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a
|
||||||
|
// TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no
|
||||||
|
// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
|
||||||
|
// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
|
||||||
|
// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches.
|
||||||
|
describe("formatRuntime", () => {
|
||||||
|
const CASES: [number | null | undefined, string][] = [
|
||||||
|
[null, ""],
|
||||||
|
[undefined, ""],
|
||||||
|
[0, ""], // nothing to show — the call sites suppress their separator on the empty string
|
||||||
|
[1, "1s"],
|
||||||
|
[45, "45s"], // one Short: the regression case
|
||||||
|
[45.7, "45s"], // truncates, so the seconds tier cannot overstate either
|
||||||
|
[59, "59s"], // last second before the minute tier
|
||||||
|
[60, "1m"],
|
||||||
|
[90, "1m"], // floors, never rounds up — a total must not overstate
|
||||||
|
[90.9, "1m"],
|
||||||
|
[3599, "59m"],
|
||||||
|
[3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation
|
||||||
|
[5025, "1h 23m"],
|
||||||
|
[360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(CASES)("formats %o as %o", (sec, expected) => {
|
||||||
|
expect(formatRuntime(sec)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reports a non-zero duration as zero", () => {
|
||||||
|
for (let sec = 1; sec < 3600; sec++) {
|
||||||
|
expect(formatRuntime(sec), `${sec}s`).not.toBe("0m");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,10 +96,16 @@ 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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact human runtime ("1h 30m", "45m") from seconds — used for Plex movie/episode lengths.
|
/** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's
|
||||||
* Empty string for a missing/zero duration (nothing to show). */
|
* length (Plex movies/episodes) and an aggregate total (playlist playtime, on the YouTube and the
|
||||||
|
* Plex side alike) — it is the one renderer for a playlist total, so a total never appears in two
|
||||||
|
* shapes on one screen. Empty string for a missing/zero duration (nothing to show).
|
||||||
|
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
|
||||||
|
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0m" made a
|
||||||
|
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
|
||||||
export function formatRuntime(sec?: number | null): string {
|
export function formatRuntime(sec?: number | null): string {
|
||||||
if (!sec) return "";
|
if (!sec) return "";
|
||||||
|
if (sec < 60) return `${Math.floor(sec)}s`;
|
||||||
const h = Math.floor(sec / 3600);
|
const h = Math.floor(sec / 3600);
|
||||||
const m = Math.floor((sec % 3600) / 60);
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
return h ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
|||||||
@@ -14,6 +14,53 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.79.0",
|
||||||
|
date: "2026-09-03",
|
||||||
|
summary:
|
||||||
|
"A playlist now tells you how long it is, and its header takes half the room it used to — so you see more of the list and less of the frame around it.",
|
||||||
|
features: [
|
||||||
|
'Every playlist header shows the total playtime beside the video count: "32 videos · 2h 17m". It is the whole playlist, not whatever a filter is currently showing, so it answers the question you actually have — is there time for this tonight?',
|
||||||
|
"The header was three stacked rows next to a large cover; it is two tight ones next to a smaller cover. Nothing was hidden behind a menu to get there — the title, rename, the YouTube link, Play all, the sync and delete buttons, group-by-channel, the view controls and undo are all still on screen. That band never scrolls, so the height it gave back is list you can now see: about eighty pixels on a normal window.",
|
||||||
|
"Plex playlists show their total playtime in the same place, in the same words.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"The column header no longer jolts upward the instant you start scrolling a playlist. It used to sit twelve pixels below the top of the list and snap flush on the first scroll, which read as the page flinching.",
|
||||||
|
'A playlist’s length is written one way everywhere now. The list on the left said "2:15:30" while the header beside it said "2h 15m" for the very same playlist — one number in two shapes, side by side.',
|
||||||
|
'A playlist shorter than a minute reported "0m". A single Short is 45 seconds long, and the header said it had no runtime at all; it now says "45s".',
|
||||||
|
'A Plex playlist holding exactly one thing said "1 items" in English.',
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
'12D reads the durations already in hand rather than adding an endpoint: both headers reduce over their own items, and formatRuntime grew a sub-minute tier because an aggregate is the first input that can legitimately land between 1 and 59 seconds — the fix lives in the function, not behind a `>= 60` guard at each of its seven call sites. PlaylistsRail moved off formatDuration onto the same helper so a playlist total has one renderer. 12A keeps every control and its gating (`items.length > 1 || viewActive`, editable/canWrite/canEdit, linked && dirty) byte-for-byte; 12B drops the wrapper’s pt-3 and passes fadeTop:false for the playlists page, matching the channel table. formatRuntime had no unit tests across seven call sites and now has a tier table plus a sweep asserting no non-zero second ever renders as "0m". Epic-end deep review over five dimensions: eighteen raw findings, ten distinct, two dropped after 3-of-3 adversarial refutations (meta-line segment order; the null-duration lower bound, which matches the server’s own long-shipped SUM), six fixed, two logged as deferrals. Rounds converged 6 → 1 → 0.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.78.8",
|
||||||
|
date: "2026-09-02",
|
||||||
|
summary:
|
||||||
|
"Links in Siftlode's emails are now real links in every mail app, not just the ones that guess.",
|
||||||
|
fixes: [
|
||||||
|
"Siftlode's messages were plain text, which left it to your mail app to decide whether the address in them was clickable. Gmail, Outlook and Apple Mail do that for you; lighter web-based mail readers show the address as plain text you have to select and copy by hand — in a message whose whole point is that one link, like confirming your email or resetting your password. Every message now carries a formatted version alongside the plain one, so the link is a link wherever you read it.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"_send() attaches an HTML alternative generated from the same plain body rather than per-template HTML that would drift from it: one converter, all eleven templates, text/plain still the primary part. Escaping runs before linkification — bodies interpolate untrusted values (a requester's address, the operator contact) — so no caller can inject markup, and a query string's & is already & when it reaches the href. Sentence-final punctuation is trimmed out of the href while url-safe token characters (- and _) are left alone. Inline styles only: no images, no external CSS, no tracking.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.78.7",
|
||||||
|
date: "2026-09-02",
|
||||||
|
summary:
|
||||||
|
"The verification link in your sign-up email did nothing — it opened a plain sign-in page. Accounts an admin approved before that link was clicked lost the password they registered with, and could not get a reset link either, which left them shut out with no way back.",
|
||||||
|
fixes: [
|
||||||
|
"Confirming your email works again. The link carries its token in the part of the address after the #, and the app was wiping that away as it started up, before the sign-in card ever read it — so the click looked like it had done nothing at all. Password-reset links arrived through the same door and were lost the same way.",
|
||||||
|
"If an admin approved your account before you confirmed your email, the password you chose at sign-up is deleted for security — someone else could have registered your address and chosen it. That part is intended, but it used to be a dead end: sign-in refused you, and Forgot password sent nothing, because the account no longer looked like a password account. It does now, so a reset link reaches you and you can choose a new password.",
|
||||||
|
"Confirming your email in that situation now says so, and offers the reset in the same breath, instead of leaving you to work out why a password you just typed correctly is being refused.",
|
||||||
|
"Existing accounts already stuck this way are repaired on upgrade — including those that had confirmed their email after the fact, who had the least way out. An account that has only ever signed in with Google is deliberately unaffected: it still adds a password from Settings while signed in, not by email.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"urlWithParams/clearParams now carry window.location.hash. The nav-param mirrors (useUrlMirror in the FeedFilters/Playlists/Plex/Channels providers) mount ABOVE the lazily-loaded <App>, so their mount effect replaceState'd the fragment away before AuthCard snapshotted it — the emailed token never reached POST /auth/verify. New users.password_revoked_at (migration 0078) records SEC-10's revoke so _send_reset_if_eligible can tell a stripped account from a Google-only one; cleared on reset-confirm and set-password. POST /auth/verify returns password_reset_required, driving an ok_reset banner. The backfill stamps every local, non-demo, passwordless row — deliberately not also requiring email_verified=false, since the revoke leaves the verify token usable and those victims have no other route back. Deep-reviewed across seven dimensions: nine findings, one confirmed (that backfill predicate) and fixed; the rest logged.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.78.6",
|
version: "0.78.6",
|
||||||
date: "2026-09-01",
|
date: "2026-09-01",
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ describe("urlWithParams", () => {
|
|||||||
setLocation("?page=channels");
|
setLocation("?page=channels");
|
||||||
expect(urlWithParams({ page: null })).toBe("/app");
|
expect(urlWithParams({ page: null })).toBe("/app");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the fragment over — the one-time auth tokens ride it", () => {
|
||||||
|
// Regression (prod, 2026-09-02): the providers that mirror nav params mount ABOVE the lazy <App>,
|
||||||
|
// so this URL was replaceState'd before the auth card's chunk loaded. Dropping the hash ate the
|
||||||
|
// #verify= token and the verification link opened a bare sign-in page.
|
||||||
|
window.history.replaceState(null, "", "/app?page=plex#verify=tok123");
|
||||||
|
expect(urlWithParams({ v: "show:1" })).toBe("/app?page=plex&v=show%3A1#verify=tok123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the fragment over even when the query empties out", () => {
|
||||||
|
window.history.replaceState(null, "", "/app?page=channels#reset=tok456");
|
||||||
|
expect(urlWithParams({ page: null })).toBe("/app#reset=tok456");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearParams", () => {
|
describe("clearParams", () => {
|
||||||
|
|||||||
@@ -146,10 +146,16 @@ export function readPage(): Page {
|
|||||||
return isPage(p) ? p : "feed";
|
return isPage(p) ? p : "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
|
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
|
||||||
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
|
* a null value deletes that key, any other string sets it. Lets each provider own its own nav key(s) —
|
||||||
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
|
* page / chan / v / pl / the feed-filter keys — and update the address bar without clobbering the others
|
||||||
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
|
* (read-modify-write on the live query). The caller decides pushState vs replaceState with the string.
|
||||||
|
*
|
||||||
|
* ⚠️ The FRAGMENT must be carried over. The one-time auth tokens ride it (`#verify=…` / `#reset=…`, SB3),
|
||||||
|
* and the providers that mirror nav params (useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE
|
||||||
|
* the lazily-loaded <App> in main.tsx — their mount effect replaceState's this URL before the auth card's
|
||||||
|
* chunk has even loaded. Dropping the hash here silently ate the token: the verification link opened a
|
||||||
|
* bare sign-in page and the account could never be verified. */
|
||||||
export function urlWithParams(updates: Record<string, string | null>): string {
|
export function urlWithParams(updates: Record<string, string | null>): string {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
for (const [k, v] of Object.entries(updates)) {
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
@@ -157,7 +163,7 @@ export function urlWithParams(updates: Record<string, string | null>): string {
|
|||||||
else params.set(k, v);
|
else params.set(k, v);
|
||||||
}
|
}
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||||
@@ -178,6 +184,8 @@ export function clearParams(...keys: string[]): void {
|
|||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
window.history.state,
|
window.history.state,
|
||||||
"",
|
"",
|
||||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
// Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
|
||||||
|
// token that has not been consumed yet must survive. Welcome's own cleanup is what clears it.
|
||||||
|
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,14 @@ describe("useUrlMirror", () => {
|
|||||||
renderHook(() => useUrlMirror({ pl: "5" }));
|
renderHook(() => useUrlMirror({ pl: "5" }));
|
||||||
expect(window.location.search).toBe("?page=playlists&pl=5");
|
expect(window.location.search).toBe("?page=playlists&pl=5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves an unconsumed auth token in the fragment alone", () => {
|
||||||
|
// This hook's mount effect is what ran before <App> (and therefore before the auth card) had even
|
||||||
|
// loaded, so whatever it writes to the address bar is what the card later reads. It must not eat
|
||||||
|
// the #verify=/#reset= token that the emailed link carries.
|
||||||
|
window.history.replaceState(null, "", "/?page=playlists#verify=tok123");
|
||||||
|
renderHook(() => useUrlMirror({ pl: "5" }));
|
||||||
|
expect(window.location.hash).toBe("#verify=tok123");
|
||||||
|
expect(window.location.search).toBe("?page=playlists&pl=5");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user