feat(admin): delete a whitelist entry, and stop hiding a broken mail setup
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.
- DELETE /api/admin/invites/{id} with a delete control on both the pending
requests and the previously-decided list. Deny keeps the row so the person
cannot immediately re-ask; delete forgets the address. Refused with 409 while
a User row exists for it, because is_allowed reads that row and removing it
would revoke a live user's access with no other sign — the list ships
has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
records an email_undelivered notification for every admin when a message is
skipped or rejected, naming the message that was lost, throttled to one an
hour. /api/me carries email_configured, driving a standing warning on
Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
views are history entries (useHistorySubview), so Back returns to the previous
view instead of only moving the address bar.
Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
This commit is contained in:
@@ -30,6 +30,7 @@ class AuditAction:
|
|||||||
INVITE_APPROVE = "invite_approve"
|
INVITE_APPROVE = "invite_approve"
|
||||||
INVITE_DENY = "invite_deny"
|
INVITE_DENY = "invite_deny"
|
||||||
INVITE_ADD = "invite_add"
|
INVITE_ADD = "invite_add"
|
||||||
|
INVITE_DELETE = "invite_delete"
|
||||||
DEMO_ADD = "demo_add"
|
DEMO_ADD = "demo_add"
|
||||||
DEMO_REMOVE = "demo_remove"
|
DEMO_REMOVE = "demo_remove"
|
||||||
DEMO_RESET = "demo_reset"
|
DEMO_RESET = "demo_reset"
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
|
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
|
||||||
or a send errors, we log and return False so the caller can fall back to in-app
|
or a send errors, we log and return False so the caller can fall back to in-app
|
||||||
notifications — email is never load-bearing for the app's core flow.
|
notifications — email is never load-bearing for the app's core flow.
|
||||||
|
|
||||||
|
Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins'
|
||||||
|
inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows
|
||||||
|
verification links, password resets and access-request notices, and the operator's first clue used
|
||||||
|
to be a person telling them nothing arrived.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,12 +16,60 @@ import ssl
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.notifications import create_notification
|
||||||
|
from app.ratelimit import RateLimiter
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.email")
|
log = logging.getLogger("siftlode.email")
|
||||||
|
|
||||||
|
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
|
||||||
|
# stay readable; the log keeps the full record either way.
|
||||||
|
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
|
||||||
|
"""Record an in-app notice that a message did NOT go out. Best effort in every direction: it
|
||||||
|
opens its own session (callers are often background tasks), swallows its own errors, and never
|
||||||
|
reports failure to the caller — this is the fallback path, it must not become a new way to
|
||||||
|
break a send."""
|
||||||
|
if not _alert_throttle.allow(reason):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from app.models import User # local: app.models imports settle before this ever runs
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
admins = (
|
||||||
|
db.execute(
|
||||||
|
select(User.id).where(
|
||||||
|
User.role == "admin",
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.is_suspended.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not admins:
|
||||||
|
return
|
||||||
|
for uid in admins:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=uid,
|
||||||
|
type="email_undelivered",
|
||||||
|
# English fallback; the inbox renders a translated version from `data`.
|
||||||
|
title="Email could not be sent",
|
||||||
|
body=subject,
|
||||||
|
data={"reason": reason, "subject": subject, "recipients": recipients},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Could not record the undelivered-email notice (%r)", subject)
|
||||||
|
|
||||||
|
|
||||||
def _smtp() -> dict:
|
def _smtp() -> dict:
|
||||||
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
|
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
|
||||||
@@ -47,6 +100,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||||||
c = _smtp()
|
c = _smtp()
|
||||||
if not (c["host"] and c["user"] and c["password"]):
|
if not (c["host"] and c["user"] and c["password"]):
|
||||||
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
||||||
|
_alert_admins("not_configured", subject, recipients)
|
||||||
return False
|
return False
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
@@ -67,6 +121,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
||||||
|
_alert_admins("send_failed", subject, recipients)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
||||||
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
@@ -31,7 +32,18 @@ from app.utils import iso, valid_email
|
|||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
def _serialize(inv: Invite) -> dict:
|
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
|
||||||
|
"""Which of these addresses already have an account. Batched, so listing the invites costs one
|
||||||
|
extra query. An invite whose address has an account must not be deletable: `is_allowed` reads
|
||||||
|
this row, so removing it would silently revoke a live user's access."""
|
||||||
|
wanted = {e.lower() for e in emails if e}
|
||||||
|
if not wanted:
|
||||||
|
return set()
|
||||||
|
rows = db.execute(select(User.email).where(User.email.in_(wanted))).scalars()
|
||||||
|
return {e.lower() for e in rows if e}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": inv.id,
|
"id": inv.id,
|
||||||
"email": inv.email,
|
"email": inv.email,
|
||||||
@@ -40,6 +52,8 @@ def _serialize(inv: Invite) -> dict:
|
|||||||
"requested_at": iso(inv.requested_at),
|
"requested_at": iso(inv.requested_at),
|
||||||
"decided_at": iso(inv.decided_at),
|
"decided_at": iso(inv.decided_at),
|
||||||
"decided_by": inv.decided_by,
|
"decided_by": inv.decided_by,
|
||||||
|
# Drives the delete affordance: an entry backed by an account is not removable here.
|
||||||
|
"has_account": has_account,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +70,8 @@ def list_invites(
|
|||||||
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
|
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
|
||||||
order = {"pending": 0, "approved": 1, "denied": 2}
|
order = {"pending": 0, "approved": 1, "denied": 2}
|
||||||
rows.sort(key=lambda i: order.get(i.status, 9))
|
rows.sort(key=lambda i: order.get(i.status, 9))
|
||||||
return [_serialize(i) for i in rows]
|
accounts = _accounts_for(db, (i.email for i in rows))
|
||||||
|
return [_serialize(i, has_account=(i.email or "").lower() in accounts) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
def _activate_user_by_email(db: Session, email: str) -> None:
|
def _activate_user_by_email(db: Session, email: str) -> None:
|
||||||
@@ -107,7 +122,7 @@ def approve_invite(
|
|||||||
inv = _decide(db, invite_id, admin, approved=True)
|
inv = _decide(db, invite_id, admin, approved=True)
|
||||||
_activate_user_by_email(db, inv.email)
|
_activate_user_by_email(db, inv.email)
|
||||||
background.add_task(email_mod.send_access_approved, inv.email)
|
background.add_task(email_mod.send_access_approved, inv.email)
|
||||||
return _serialize(inv)
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/invites/{invite_id}/deny")
|
@router.post("/invites/{invite_id}/deny")
|
||||||
@@ -121,7 +136,7 @@ def deny_invite(
|
|||||||
# account is activated, so this is an email only (the requester has no in-app inbox yet).
|
# account is activated, so this is an email only (the requester has no in-app inbox yet).
|
||||||
inv = _decide(db, invite_id, admin, approved=False)
|
inv = _decide(db, invite_id, admin, approved=False)
|
||||||
background.add_task(email_mod.send_access_denied, inv.email)
|
background.add_task(email_mod.send_access_denied, inv.email)
|
||||||
return _serialize(inv)
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
class AddInviteIn(BaseModel):
|
class AddInviteIn(BaseModel):
|
||||||
@@ -150,7 +165,41 @@ def add_invite(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
_activate_user_by_email(db, email)
|
_activate_user_by_email(db, email)
|
||||||
return _serialize(inv)
|
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/invites/{invite_id}")
|
||||||
|
def delete_invite(
|
||||||
|
invite_id: int,
|
||||||
|
admin: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
|
||||||
|
never became an account (an approval added by mistake, or one that is no longer wanted). Denying
|
||||||
|
a request keeps the row so the person cannot immediately re-ask; deleting it forgets them, and a
|
||||||
|
fresh request from that address starts over as pending.
|
||||||
|
|
||||||
|
Refused once an account exists: `is_allowed` reads this row, so removing it would silently revoke
|
||||||
|
a live user's access. Delete the USER instead — purge_user removes the invite along with it."""
|
||||||
|
inv = db.get(Invite, invite_id)
|
||||||
|
if inv is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No such invite")
|
||||||
|
if _accounts_for(db, [inv.email]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="That address already has an account — delete the account instead.",
|
||||||
|
)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
AuditAction.INVITE_DELETE,
|
||||||
|
target_type="invite",
|
||||||
|
target_id=inv.id,
|
||||||
|
summary=inv.email,
|
||||||
|
)
|
||||||
|
db.delete(inv)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": invite_id}
|
||||||
|
|
||||||
|
|
||||||
# --- Users & roles --------------------------------------------------------------------
|
# --- Users & roles --------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.auth import (
|
|||||||
optional_current_user,
|
optional_current_user,
|
||||||
purge_user,
|
purge_user,
|
||||||
)
|
)
|
||||||
|
from app import email as email_mod
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
@@ -98,11 +99,16 @@ def get_me(
|
|||||||
if user is None:
|
if user is None:
|
||||||
return {"authenticated": False}
|
return {"authenticated": False}
|
||||||
pending_invites = 0
|
pending_invites = 0
|
||||||
|
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
|
||||||
|
# config costs verification links, password resets and access-request notices with nothing
|
||||||
|
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
|
||||||
|
email_configured = True
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
pending_invites = (
|
pending_invites = (
|
||||||
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
email_configured = email_mod.email_enabled()
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
@@ -121,6 +127,7 @@ def get_me(
|
|||||||
"can_read": has_read_scope(user),
|
"can_read": has_read_scope(user),
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
"pending_invites": pending_invites,
|
"pending_invites": pending_invites,
|
||||||
|
"email_configured": email_configured,
|
||||||
"preferences": user.preferences or {},
|
"preferences": user.preferences or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,13 +196,17 @@ def _reset_rate_limiters():
|
|||||||
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
||||||
presents the same host — so without this the whole suite's login/register attempts pool into one
|
presents the same host — so without this the whole suite's login/register attempts pool into one
|
||||||
300s window and, past the cap, some unrelated test starts failing with a 429 depending on order.
|
300s window and, past the cap, some unrelated test starts failing with a 429 depending on order.
|
||||||
Cleared before each test; a test that wants to exercise throttling can still exhaust one itself."""
|
Cleared before each test; a test that wants to exercise throttling can still exhaust one itself.
|
||||||
import app.routes.auth as _auth
|
|
||||||
import app.routes.messages as _messages
|
Sweeps every loaded `app.*` module rather than a hand-kept list, so a limiter added in a new
|
||||||
import app.routes.public as _public
|
module is covered the day it lands instead of silently pooling until something fails."""
|
||||||
|
import sys
|
||||||
|
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
|
|
||||||
for mod in (_auth, _messages, _public):
|
for name, mod in list(sys.modules.items()):
|
||||||
|
if name != "app" and not name.startswith("app."):
|
||||||
|
continue
|
||||||
for obj in vars(mod).values():
|
for obj in vars(mod).values():
|
||||||
if isinstance(obj, RateLimiter):
|
if isinstance(obj, RateLimiter):
|
||||||
obj.reset()
|
obj.reset()
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Deleting a whitelist / access-request entry (admin).
|
||||||
|
|
||||||
|
Deny keeps the row so a refused person cannot immediately re-ask; delete forgets the address
|
||||||
|
outright, for an approval added by mistake or one that is no longer wanted. The one hard rule is
|
||||||
|
that an entry backed by an account is NOT deletable here: `is_allowed` reads this row, so removing
|
||||||
|
it would silently revoke a live user's access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import Invite, User
|
||||||
|
|
||||||
|
|
||||||
|
def _invites(client):
|
||||||
|
r = client.get("/api/admin/invites")
|
||||||
|
assert r.status_code == 200
|
||||||
|
return {i["email"]: i for i in r.json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_user, as_user):
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
db.add(Invite(email="mistake@example.com", status="approved", decided_by=admin.email))
|
||||||
|
db.commit()
|
||||||
|
client = as_user(admin)
|
||||||
|
listed = _invites(client)["mistake@example.com"]
|
||||||
|
assert listed["has_account"] is False # drives the delete affordance in the UI
|
||||||
|
|
||||||
|
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||||
|
|
||||||
|
assert r.status_code == 200 and r.json() == {"deleted": listed["id"]}
|
||||||
|
db.expire_all()
|
||||||
|
assert (
|
||||||
|
db.execute(select(Invite).where(Invite.email == "mistake@example.com")).scalar_one_or_none()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user):
|
||||||
|
# The guard that matters: this row is what is_allowed() reads, so deleting it would revoke the
|
||||||
|
# user's access without touching the account or saying so.
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
member = make_user(email="member@example.com")
|
||||||
|
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
|
||||||
|
db.commit()
|
||||||
|
client = as_user(admin)
|
||||||
|
listed = _invites(client)[member.email]
|
||||||
|
assert listed["has_account"] is True
|
||||||
|
|
||||||
|
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||||
|
|
||||||
|
assert r.status_code == 409
|
||||||
|
db.expire_all()
|
||||||
|
assert (
|
||||||
|
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
|
||||||
|
# A pending row is deletable too — "not now, and don't keep the record" is a different decision
|
||||||
|
# from Deny, which keeps the row precisely so the person cannot immediately re-ask.
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
db.add(Invite(email="asker@example.com", status="pending"))
|
||||||
|
db.commit()
|
||||||
|
client = as_user(admin)
|
||||||
|
|
||||||
|
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "asker@example.com" not in _invites(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_a_missing_invite_is_404(db, make_user, as_user):
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
assert as_user(admin).delete("/api/admin/invites/9999").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_regular_user_cannot_delete_an_invite(db, make_user, as_user):
|
||||||
|
make_user(email="admin@example.com", role="admin")
|
||||||
|
plain = make_user(email="plain@example.com")
|
||||||
|
db.add(Invite(email="mistake@example.com", status="approved"))
|
||||||
|
db.commit()
|
||||||
|
inv_id = db.execute(select(Invite.id).where(Invite.email == "mistake@example.com")).scalar_one()
|
||||||
|
|
||||||
|
assert as_user(plain).delete(f"/api/admin/invites/{inv_id}").status_code == 403
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Invite, inv_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_user):
|
||||||
|
# The refusal above tells the admin to delete the account instead; purge_user takes the invite
|
||||||
|
# with it, so the address really is forgotten and a fresh request starts over as pending.
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
member = make_user(email="member@example.com")
|
||||||
|
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
|
||||||
|
db.commit()
|
||||||
|
client = as_user(admin)
|
||||||
|
|
||||||
|
assert client.delete(f"/api/admin/users/{member.id}").status_code == 200
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.execute(select(User).where(User.email == member.email)).scalar_one_or_none() is None
|
||||||
|
assert (
|
||||||
|
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
|
||||||
|
)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""An undelivered email leaves a trace in the admins' inbox.
|
||||||
|
|
||||||
|
Sending is deliberately fail-soft — a missing SMTP setting or a rejecting relay logs and returns
|
||||||
|
False so nothing user-facing breaks. The cost of that used to be total silence: an instance whose
|
||||||
|
SMTP was never filled in swallowed verification links, password resets and access-request notices,
|
||||||
|
and the operator's first clue was somebody saying nothing arrived.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import email as email_mod
|
||||||
|
from app.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_smtp_config(monkeypatch):
|
||||||
|
"""Every test here drives the real `_send`, so make sure it never reaches a socket."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
email_mod,
|
||||||
|
"_smtp",
|
||||||
|
lambda: {"host": "", "port": 587, "user": "", "password": "", "from": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notifications(db, user_id: int) -> list[Notification]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(Notification)
|
||||||
|
.where(Notification.user_id == user_id, Notification.type == "email_undelivered")
|
||||||
|
.order_by(Notification.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_skipped_email_notifies_every_admin(db, make_user):
|
||||||
|
admin_a = make_user(email="admin-a@example.com", role="admin")
|
||||||
|
admin_b = make_user(email="admin-b@example.com", role="admin")
|
||||||
|
plain = make_user(email="plain@example.com")
|
||||||
|
|
||||||
|
assert email_mod.send_test("someone@example.com") is False # fail-soft, as before
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
for admin in (admin_a, admin_b):
|
||||||
|
(n,) = _notifications(db, admin.id)
|
||||||
|
assert n.data["reason"] == "not_configured"
|
||||||
|
assert n.data["subject"] == "Siftlode SMTP test"
|
||||||
|
assert n.data["recipients"] == ["someone@example.com"]
|
||||||
|
assert _notifications(db, plain.id) == [] # not an operator's problem to see
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make_user):
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
email_mod.send_test("someone@example.com")
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert len(_notifications(db, admin.id)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_user, monkeypatch):
|
||||||
|
# A configured-but-rejecting relay is a different operator problem from an empty config, and the
|
||||||
|
# inbox copy differs — so the reason has to survive to the notification.
|
||||||
|
admin = make_user(email="admin@example.com", role="admin")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
email_mod,
|
||||||
|
"_smtp",
|
||||||
|
lambda: {
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 587,
|
||||||
|
"user": "u",
|
||||||
|
"password": "p",
|
||||||
|
"from": "Siftlode <u@example.com>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _boom(*a, **kw):
|
||||||
|
raise smtplib.SMTPException("nope")
|
||||||
|
|
||||||
|
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
|
||||||
|
|
||||||
|
assert email_mod.send_test("someone@example.com") is False
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
(n,) = _notifications(db, admin.id)
|
||||||
|
assert n.data["reason"] == "send_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_admins_means_no_notification_and_no_error(db, make_user):
|
||||||
|
# A fresh instance can have no admin yet (the wizard has not run). The alert must stay silent
|
||||||
|
# rather than blowing up inside the fail-soft path it is supposed to make visible.
|
||||||
|
plain = make_user(email="plain@example.com")
|
||||||
|
|
||||||
|
assert email_mod.send_test("someone@example.com") is False
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert _notifications(db, plain.id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
|
||||||
|
# The alert is the fallback path; if IT fails, the caller must still get the ordinary fail-soft
|
||||||
|
# False rather than an exception surfacing from a background task.
|
||||||
|
make_user(email="admin@example.com", role="admin")
|
||||||
|
|
||||||
|
def _boom(*a, **kw):
|
||||||
|
raise RuntimeError("notification store is down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(email_mod, "create_notification", _boom)
|
||||||
|
|
||||||
|
assert email_mod.send_test("someone@example.com") is False
|
||||||
@@ -7,6 +7,7 @@ import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import InlineNotice from "./InlineNotice";
|
||||||
import { Section } from "./ui/form";
|
import { Section } from "./ui/form";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
@@ -266,6 +267,7 @@ function AdminInvites() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const me = useMe();
|
||||||
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
|
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -297,6 +299,23 @@ function AdminInvites() {
|
|||||||
});
|
});
|
||||||
if (ok) deny.mutate(i.id);
|
if (ok) deny.mutate(i.id);
|
||||||
};
|
};
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteInvite(id),
|
||||||
|
onSuccess: (_d, _id) => {
|
||||||
|
refresh();
|
||||||
|
notify({ level: "success", message: t("settings.invites.deleted") });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: t("settings.invites.deleteFailed") }),
|
||||||
|
});
|
||||||
|
const onDelete = async (i: { id: number; email: string }) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("settings.invites.deleteTitle"),
|
||||||
|
message: t("settings.invites.deleteConfirm", { email: i.email }),
|
||||||
|
confirmLabel: t("settings.invites.delete"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) remove.mutate(i.id);
|
||||||
|
};
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
mutationFn: (email: string) => api.addInvite(email),
|
mutationFn: (email: string) => api.addInvite(email),
|
||||||
onSuccess: (_d, email) => {
|
onSuccess: (_d, email) => {
|
||||||
@@ -313,6 +332,9 @@ function AdminInvites() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section card title={t("settings.invites.title")}>
|
<Section card title={t("settings.invites.title")}>
|
||||||
|
{!me.email_configured && (
|
||||||
|
<InlineNotice className="mb-3">{t("settings.invites.emailOff")}</InlineNotice>
|
||||||
|
)}
|
||||||
{invites.isError && !invites.data ? (
|
{invites.isError && !invites.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => invites.refetch()}>
|
<StateMessage tone="error" onRetry={() => invites.refetch()}>
|
||||||
{t("common.loadError")}
|
{t("common.loadError")}
|
||||||
@@ -329,7 +351,8 @@ function AdminInvites() {
|
|||||||
inv={i}
|
inv={i}
|
||||||
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
|
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
|
||||||
onDeny={() => onDeny(i)}
|
onDeny={() => onDeny(i)}
|
||||||
busy={approve.isPending || deny.isPending}
|
onDelete={() => onDelete(i)}
|
||||||
|
busy={approve.isPending || deny.isPending || remove.isPending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -364,11 +387,31 @@ function AdminInvites() {
|
|||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
{decided.map((i) => (
|
{decided.map((i) => (
|
||||||
<div key={i.id} className="flex items-center justify-between gap-2">
|
<div key={i.id} className="flex items-center gap-2">
|
||||||
<span className="truncate">{i.email}</span>
|
<span className="truncate flex-1">{i.email}</span>
|
||||||
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
|
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
|
||||||
{i.status}
|
{i.status}
|
||||||
</span>
|
</span>
|
||||||
|
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||||
|
user's access, since is_allowed reads this row. Delete the user instead. */}
|
||||||
|
{i.has_account ? (
|
||||||
|
<Tooltip hint={t("settings.invites.deleteHasAccount")}>
|
||||||
|
<span className="shrink-0 p-1 text-muted/50" aria-hidden>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(i)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
|
||||||
|
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -506,11 +549,13 @@ function InviteRow({
|
|||||||
inv,
|
inv,
|
||||||
onApprove,
|
onApprove,
|
||||||
onDeny,
|
onDeny,
|
||||||
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
}: {
|
}: {
|
||||||
inv: Invite;
|
inv: Invite;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onDeny: () => void;
|
onDeny: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -546,6 +591,20 @@ function InviteRow({
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{/* Deny keeps the row (so the person can't immediately re-ask); delete forgets the address
|
||||||
|
entirely. Withheld once an account exists — that would revoke a live user's access. */}
|
||||||
|
{!inv.has_account && (
|
||||||
|
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={busy}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||||
|
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Plug, RotateCcw, Send } from "lucide-react";
|
|||||||
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import InlineNotice from "./InlineNotice";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
@@ -35,6 +37,7 @@ const GROUP_ORDER = [
|
|||||||
export default function ConfigPanel() {
|
export default function ConfigPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const me = useMe();
|
||||||
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
|
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
|
||||||
const data = q.data;
|
const data = q.data;
|
||||||
|
|
||||||
@@ -204,7 +207,11 @@ export default function ConfigPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeGroup === "email" && (
|
{activeGroup === "email" && (
|
||||||
<div className="mt-3 pt-3 border-t border-border/60">
|
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
|
||||||
|
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
|
||||||
|
half-filled config costs verification links, resets and access-request notices
|
||||||
|
with no error anywhere — say so next to the fields that fix it. */}
|
||||||
|
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||||
<Tooltip hint={t("config.testEmailHint")}>
|
<Tooltip hint={t("config.testEmailHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => testEmail.mutate()}
|
onClick={() => testEmail.mutate()}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
// An in-flow warning that sits INSIDE a panel, next to the thing it is about — as opposed to
|
||||||
|
// Banner, which is the full-bleed top-of-app bar (border-b, app chrome) and looks wrong nested in a
|
||||||
|
// card. Same warn tokens as Banner's warning tone so the two read as one system.
|
||||||
|
export default function InlineNotice({
|
||||||
|
children,
|
||||||
|
icon: Icon = AlertTriangle,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={`flex items-start gap-2 rounded-xl border border-warn/30 bg-warn/15 px-3 py-2 text-sm text-fg ${className}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 shrink-0 mt-0.5 text-warn" aria-hidden />
|
||||||
|
<span className="min-w-0">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -243,6 +243,7 @@ function NotificationRow({
|
|||||||
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||||
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
|
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
|
||||||
const isDownloadDone = n.type === "download_done";
|
const isDownloadDone = n.type === "download_done";
|
||||||
|
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||||
let title = n.title;
|
let title = n.title;
|
||||||
let body = n.body;
|
let body = n.body;
|
||||||
if (isMaintenance) {
|
if (isMaintenance) {
|
||||||
@@ -258,6 +259,17 @@ function NotificationRow({
|
|||||||
job,
|
job,
|
||||||
});
|
});
|
||||||
body = d.summary || n.body;
|
body = d.summary || n.body;
|
||||||
|
} else if (isEmailUndelivered) {
|
||||||
|
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
|
||||||
|
// the message that was lost, since that is what tells the operator what their users didn't get.
|
||||||
|
const d = n.data as { reason: string; subject?: string };
|
||||||
|
title = t("inbox.emailUndelivered.title");
|
||||||
|
body = t(
|
||||||
|
d.reason === "not_configured"
|
||||||
|
? "inbox.emailUndelivered.notConfigured"
|
||||||
|
: "inbox.emailUndelivered.sendFailed",
|
||||||
|
{ subject: d.subject || n.body || "" },
|
||||||
|
);
|
||||||
} else if (isDownloadDone) {
|
} else if (isDownloadDone) {
|
||||||
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
|
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
|
||||||
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
|
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -27,7 +28,19 @@ import { inputCls } from "./ui/form";
|
|||||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
const PASSWORD_MIN = 10;
|
const PASSWORD_MIN = 10;
|
||||||
|
|
||||||
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
||||||
|
|
||||||
|
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
|
||||||
|
// any other page — they used to be plain state, and Back silently changed only the address bar.
|
||||||
|
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
|
||||||
|
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
|
||||||
|
// form that cannot submit.
|
||||||
|
export const MODE_URL: SubviewUrl<Mode> = {
|
||||||
|
key: "m",
|
||||||
|
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
|
||||||
|
decode: (raw: string) =>
|
||||||
|
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
|
||||||
|
};
|
||||||
|
|
||||||
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
|
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
|
||||||
// keeps adding a new mode a one-line change and makes a missing key a compile error.
|
// keeps adding a new mode a one-line change and makes a missing key a compile error.
|
||||||
@@ -328,7 +341,10 @@ function AuthCard() {
|
|||||||
.catch(() => setVerify("invalid"));
|
.catch(() => setVerify("invalid"));
|
||||||
}, [verifyToken]);
|
}, [verifyToken]);
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
|
const { view: mode, open: setMode } = useHistorySubview<Mode>(
|
||||||
|
resetToken ? "reset" : "signin",
|
||||||
|
MODE_URL,
|
||||||
|
);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -344,6 +360,13 @@ function AuthCard() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
||||||
|
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
|
||||||
|
// — without this, Back out of a submitted form would show the old success message under the
|
||||||
|
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
|
||||||
|
useEffect(() => {
|
||||||
|
setDone("");
|
||||||
|
setError("");
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||||
// by the demo whitelist server-side.
|
// by the demo whitelist server-side.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be
|
||||||
|
// plain React state: clicking one changed nothing in history, so Back popped to whatever entry came
|
||||||
|
// before the card and the address bar moved while the form on screen did not. They now ride
|
||||||
|
// useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes
|
||||||
|
// are mirrored, and that Back walks the views. The hook's own mechanics live in
|
||||||
|
// history.subview.dom.test.ts.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useHistorySubview } from "../lib/history";
|
||||||
|
import { MODE_URL, type Mode } from "./Welcome";
|
||||||
|
|
||||||
|
const render = (root: Mode = "signin") => renderHook(() => useHistorySubview<Mode>(root, MODE_URL));
|
||||||
|
|
||||||
|
// jsdom's history.back() is asynchronous and does not rewind location, so — as in
|
||||||
|
// history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing
|
||||||
|
// popstate. `open()` pushing a REAL entry is asserted separately via history.length.
|
||||||
|
const popTo = (search: string, state: unknown = null) =>
|
||||||
|
act(() => {
|
||||||
|
window.history.replaceState(state, "", search || "/");
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// jsdom keeps one history across a file; land on a clean root entry before each test.
|
||||||
|
beforeEach(() => window.history.replaceState(null, "", "/"));
|
||||||
|
afterEach(() => window.history.replaceState(null, "", "/"));
|
||||||
|
|
||||||
|
describe("login card mode ↔ URL", () => {
|
||||||
|
it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => {
|
||||||
|
expect(MODE_URL.encode("signin")).toBeNull();
|
||||||
|
expect(MODE_URL.encode("register")).toBe("register");
|
||||||
|
expect(MODE_URL.encode("forgot")).toBe("forgot");
|
||||||
|
expect(MODE_URL.encode("resend")).toBe("resend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never mirrors the reset view", () => {
|
||||||
|
// Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would
|
||||||
|
// hand out a bookmark that opens a password form with no token behind it.
|
||||||
|
expect(MODE_URL.encode("reset")).toBeNull();
|
||||||
|
expect(MODE_URL.decode("reset")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => {
|
||||||
|
expect(MODE_URL.decode("register")).toBe("register");
|
||||||
|
expect(MODE_URL.decode("")).toBeNull();
|
||||||
|
expect(MODE_URL.decode("nonsense")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => {
|
||||||
|
const { result } = render();
|
||||||
|
const entries = window.history.length;
|
||||||
|
expect(result.current.view).toBe("signin");
|
||||||
|
|
||||||
|
act(() => result.current.open("forgot"));
|
||||||
|
|
||||||
|
// The whole point: a Back has somewhere to go. Before this the click changed state only.
|
||||||
|
expect(window.history.length).toBe(entries + 1);
|
||||||
|
expect(result.current.view).toBe("forgot");
|
||||||
|
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
|
||||||
|
|
||||||
|
popTo("/");
|
||||||
|
|
||||||
|
expect(result.current.view).toBe("signin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks back one view at a time when the user hops between them", () => {
|
||||||
|
const { result } = render();
|
||||||
|
const entries = window.history.length;
|
||||||
|
act(() => result.current.open("register"));
|
||||||
|
act(() => result.current.open("forgot"));
|
||||||
|
expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot
|
||||||
|
|
||||||
|
popTo("/?m=register", { _sub: "register" });
|
||||||
|
expect(result.current.view).toBe("register");
|
||||||
|
|
||||||
|
popTo("/");
|
||||||
|
expect(result.current.view).toBe("signin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => {
|
||||||
|
const { result } = render("reset");
|
||||||
|
expect(result.current.view).toBe("reset");
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a bookmarked view from the URL alone", () => {
|
||||||
|
window.history.replaceState(null, "", "/?m=resend");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.view).toBe("resend");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
"job_run": "Scheduler job run",
|
"job_run": "Scheduler job run",
|
||||||
"audit_clear": "Audit log cleared",
|
"audit_clear": "Audit log cleared",
|
||||||
"download_credential_set": "Download cookies set",
|
"download_credential_set": "Download cookies set",
|
||||||
"download_credential_delete": "Download cookies removed"
|
"download_credential_delete": "Download cookies removed",
|
||||||
|
"invite_delete": "Whitelist entry deleted"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,5 +216,6 @@
|
|||||||
"plexPickLibraries": "Libraries to expose:",
|
"plexPickLibraries": "Libraries to expose:",
|
||||||
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
|
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
|
||||||
"plexMovies": "Movies",
|
"plexMovies": "Movies",
|
||||||
"plexShows": "TV Shows"
|
"plexShows": "TV Shows",
|
||||||
|
"emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,10 @@
|
|||||||
"jobDone": {
|
"jobDone": {
|
||||||
"titleOk": "{{job}} finished",
|
"titleOk": "{{job}} finished",
|
||||||
"titleError": "{{job}} failed"
|
"titleError": "{{job}} failed"
|
||||||
|
},
|
||||||
|
"emailUndelivered": {
|
||||||
|
"title": "Email could not be sent",
|
||||||
|
"notConfigured": "SMTP is not configured, so “{{subject}}” was skipped. Until it is set up, no message leaves this instance.",
|
||||||
|
"sendFailed": "The mail server rejected “{{subject}}”. Check Configuration → Email / SMTP and send a test."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,15 @@
|
|||||||
"deny": "Deny",
|
"deny": "Deny",
|
||||||
"denied": "Request denied",
|
"denied": "Request denied",
|
||||||
"denyTitle": "Deny access request?",
|
"denyTitle": "Deny access request?",
|
||||||
"denyConfirm": "Deny the access request from {{email}}?"
|
"denyConfirm": "Deny the access request from {{email}}?",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteLabel": "Delete {{email}} from the whitelist",
|
||||||
|
"deleteHint": "Remove this address entirely. A later request from it starts over.",
|
||||||
|
"deleteTitle": "Delete whitelist entry?",
|
||||||
|
"deleteConfirm": "Remove {{email}} from the whitelist? If they were approved, they lose access until you add them again.",
|
||||||
|
"deleteHasAccount": "This address has an account — delete the user instead.",
|
||||||
|
"deleted": "Whitelist entry removed.",
|
||||||
|
"deleteFailed": "Could not remove the entry.",
|
||||||
|
"emailOff": "Email sending is not configured, so nobody gets a verification link and you get no notice when someone signs up. Fix it under Configuration → Email / SMTP."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
"job_run": "Ütemező job lefutott",
|
"job_run": "Ütemező job lefutott",
|
||||||
"audit_clear": "Napló ürítve",
|
"audit_clear": "Napló ürítve",
|
||||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||||
"download_credential_delete": "Letöltési cookie-k eltávolítva"
|
"download_credential_delete": "Letöltési cookie-k eltávolítva",
|
||||||
|
"invite_delete": "Whitelista-bejegyzés törölve"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,5 +216,6 @@
|
|||||||
"plexPickLibraries": "Megjelenítendő könyvtárak:",
|
"plexPickLibraries": "Megjelenítendő könyvtárak:",
|
||||||
"plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.",
|
"plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.",
|
||||||
"plexMovies": "Filmek",
|
"plexMovies": "Filmek",
|
||||||
"plexShows": "Sorozatok"
|
"plexShows": "Sorozatok",
|
||||||
|
"emailOff": "Az e-mail-küldés nincs beállítva — a Siftlode némán kihagy minden üzenetet: megerősítő linkeket, jelszó-visszaállításokat és hozzáférés-kérés értesítőket. Töltsd ki a fenti mezőket, majd küldj tesztet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,10 @@
|
|||||||
"jobDone": {
|
"jobDone": {
|
||||||
"titleOk": "{{job}} kész",
|
"titleOk": "{{job}} kész",
|
||||||
"titleError": "{{job}} hibázott"
|
"titleError": "{{job}} hibázott"
|
||||||
|
},
|
||||||
|
"emailUndelivered": {
|
||||||
|
"title": "Egy e-mailt nem sikerült elküldeni",
|
||||||
|
"notConfigured": "Az SMTP nincs beállítva, ezért a(z) „{{subject}}” kimaradt. Amíg nincs beállítva, egyetlen üzenet sem hagyja el ezt a példányt.",
|
||||||
|
"sendFailed": "A levelezőszerver visszautasította a(z) „{{subject}}” üzenetet. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,15 @@
|
|||||||
"deny": "Elutasítás",
|
"deny": "Elutasítás",
|
||||||
"denied": "Kérelem elutasítva",
|
"denied": "Kérelem elutasítva",
|
||||||
"denyTitle": "Elutasítod a hozzáférési kérelmet?",
|
"denyTitle": "Elutasítod a hozzáférési kérelmet?",
|
||||||
"denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?"
|
"denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?",
|
||||||
|
"delete": "Törlés",
|
||||||
|
"deleteLabel": "{{email}} törlése a whitelistáról",
|
||||||
|
"deleteHint": "A cím teljes eltávolítása. Egy későbbi kérelme elölről indul.",
|
||||||
|
"deleteTitle": "Törlöd a whitelista-bejegyzést?",
|
||||||
|
"deleteConfirm": "Eltávolítod {{email}} címet a whitelistáról? Ha jóvá volt hagyva, elveszíti a hozzáférést, amíg újra hozzá nem adod.",
|
||||||
|
"deleteHasAccount": "Ehhez a címhez már tartozik fiók — a felhasználót töröld helyette.",
|
||||||
|
"deleted": "Whitelista-bejegyzés eltávolítva.",
|
||||||
|
"deleteFailed": "A bejegyzést nem sikerült eltávolítani.",
|
||||||
|
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és te sem kapsz értesítést, ha valaki regisztrál. A Konfiguráció → E-mail / SMTP fülön javítható."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface Me {
|
|||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
pending_invites: number;
|
pending_invites: number;
|
||||||
|
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
|
||||||
|
email_configured: boolean;
|
||||||
preferences: Record<string, unknown>;
|
preferences: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface Invite {
|
|||||||
requested_at: string | null;
|
requested_at: string | null;
|
||||||
decided_at: string | null;
|
decided_at: string | null;
|
||||||
decided_by: string | null;
|
decided_by: string | null;
|
||||||
|
/** An account already exists for this address, so the entry can't be deleted from here — deleting
|
||||||
|
* it would revoke that user's access (is_allowed reads this row). Delete the user instead. */
|
||||||
|
has_account: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||||
@@ -169,4 +172,5 @@ export const adminApi = {
|
|||||||
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
||||||
addInvite: (email: string) =>
|
addInvite: (email: string) =>
|
||||||
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
deleteInvite: (id: number) => req(`/api/admin/invites/${id}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.78.6",
|
||||||
|
date: "2026-09-01",
|
||||||
|
summary:
|
||||||
|
"Admins can now delete a whitelist entry, and an instance that cannot send email finally says so — in the inbox when a message is dropped, and on the screens where it matters.",
|
||||||
|
features: [
|
||||||
|
"Delete an entry from Users → Access requests: both a pending request and an address you whitelisted earlier. Denying a request keeps the row so the person cannot immediately re-ask; deleting forgets the address, and a later request from it starts over. An entry that already has an account behind it stays read-only — removing it would revoke that person's access without saying so, so delete the user instead.",
|
||||||
|
"When a message cannot go out, every admin gets it in their inbox, naming the message that was lost — an access request, a verification link, a password reset. Sending is deliberately fail-soft so a mail problem never breaks sign-up, but that used to mean it left no trace anywhere. Throttled to one notice an hour, so a dead relay cannot bury the inbox.",
|
||||||
|
"A standing warning where it can be acted on: on Configuration → Email / SMTP while the settings are incomplete, and above the access-request list, where it explains that nobody is getting a verification link and no request will reach you by email.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"On the sign-in card, Create an account / Forgot password / Didn't get the verification email are proper history steps now, so Back — including the mouse's back button — returns to the previous view. It used to move the address bar and leave the form on screen unchanged.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"DELETE /api/admin/invites/{id}, refused with 409 while a User row exists for the address; the invite list carries has_account so the UI can withhold the affordance rather than offer a button that fails. Audited as invite_delete.",
|
||||||
|
"email._send() records an email_undelivered notification for every active admin on both the not-configured and the send-failed path, opening its own session and swallowing its own errors so the alert can never become a new way to break a send; /api/me carries email_configured for admins. The login card's mode moved to useHistorySubview, and the per-test rate-limiter reset now sweeps every loaded app.* module instead of a hand-kept list — this release added a limiter outside it.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.78.5",
|
version: "0.78.5",
|
||||||
date: "2026-09-01",
|
date: "2026-09-01",
|
||||||
|
|||||||
Reference in New Issue
Block a user