fix: address the feature review — accurate copy, reachable controls, real coverage

Round 1 on the feature diff found 15; the substantive ones:

- The delete control VANISHED on any invite whose address had registered, which
  is the ordinary sign-up shape (register writes the User and the pending invite
  together). The guard stays broad — that row is the account's approval gate, and
  orphaning it strands someone who can then neither sign in nor be approved — but
  the control is now a disabled button carrying the reason, on both lists, so an
  admin never sees a row silently missing a button its neighbours have.
- Deleting an entry is not always a revoke: is_allowed falls back to the env
  ALLOWED_EMAILS / ADMIN_EMAILS lists once no row exists, and an ADMIN_EMAILS
  address is force-promoted on its next Google sign-in. The route now reports
  still_allowed_by_env and the UI says so instead of claiming access was removed.
- "Deny keeps the row so they can't immediately re-ask" was simply false —
  upsert_pending_invite reopens a denied row. Corrected everywhere it was stated.
- The Configuration warning reads me.email_configured, which save() did not
  invalidate, so it kept warning about fields the admin had just filled in.
- The read-only variant was an aria-hidden span explained only by a Tooltip that
  the hints toggle can switch off; now a disabled button with title + aria-label.
- The delete error path threw away the 409's reason (the stale-list case) and
  left the stale row on screen.
- The throttle window was spent even when no notice was written, silencing the
  next hour on the path whose job is to stop silent drops.
- email_enabled() opened a second session and decrypted the SMTP password on
  every /api/me poll; it now takes the caller's session.
- welcomeMode.dom.test.ts never rendered the card, so reverting the history
  wiring to useState left it green. A render test now drives the real links and
  fails on that revert (verified).

Plus /api/me flag tests, the registration-shaped pending case, the env-allowlist
case, and a release note that matches what the code does.
This commit is contained in:
2026-09-01 23:31:49 +02:00
parent 44f626f732
commit 85043013f0
14 changed files with 298 additions and 55 deletions
+27 -15
View File
@@ -17,10 +17,12 @@ from email.message import EmailMessage
from email.utils import formatdate
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.models import User
from app.notifications import create_notification
from app.ratelimit import RateLimiter
@@ -39,8 +41,6 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
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(
@@ -54,6 +54,9 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
.all()
)
if not admins:
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
# path whose job is to stop drops going unseen. Same on the failure branch below.
_alert_throttle.reset()
return
for uid in admins:
create_notification(
@@ -68,24 +71,33 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
)
db.commit()
except Exception:
_alert_throttle.reset()
log.exception("Could not record the undelivered-email notice (%r)", subject)
def _smtp() -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
session because email is often sent from a BackgroundTask, outside a request's db."""
with SessionLocal() as db:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def _read_smtp(db: Session) -> dict:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def email_enabled() -> bool:
c = _smtp()
def _smtp(db: Session | None = None) -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
value, and /api/me polls this for every admin tab."""
if db is not None:
return _read_smtp(db)
with SessionLocal() as own:
return _read_smtp(own)
def email_enabled(db: Session | None = None) -> bool:
c = _smtp(db)
return bool(c["host"] and c["user"] and c["password"])
+16 -6
View File
@@ -19,6 +19,7 @@ from app.auth import (
operator_contact,
purge_user,
)
from app.config import settings
from app.db import get_db
from app.demo import reset_demo_state
from app.models import (
@@ -175,12 +176,19 @@ def delete_invite(
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.
never became an account (an approval added by mistake, or one that is no longer wanted). Deny
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
simply comes back as a fresh pending request.
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."""
Refused once an account exists for the address, whatever its state: this row is the account's
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
be approved. Delete the USER instead — purge_user removes the invite along with it.
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
@@ -189,6 +197,8 @@ def delete_invite(
status_code=409,
detail="That address already has an account — delete the account instead.",
)
email = (inv.email or "").lower()
still_allowed_by_env = email in settings.allowed_email_set or email in settings.admin_email_set
audit.record(
db,
admin.id,
@@ -199,7 +209,7 @@ def delete_invite(
)
db.delete(inv)
db.commit()
return {"deleted": invite_id}
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
# --- Users & roles --------------------------------------------------------------------
+1 -1
View File
@@ -108,7 +108,7 @@ def get_me(
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
or 0
)
email_configured = email_mod.email_enabled()
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
return {
"authenticated": True,
"id": user.id,
+46 -1
View File
@@ -27,7 +27,7 @@ def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_u
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 200 and r.json() == {"deleted": 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()
@@ -56,6 +56,50 @@ def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user
)
def test_a_pending_request_from_a_registration_is_not_deletable(db, make_user, as_user):
# The shape the ordinary sign-up produces: _register_account writes the User (inactive) AND the
# pending Invite together. The invite is that account's approval gate, so deleting it would
# strand the person — they could neither sign in nor be approved, and re-registering is a silent
# no-op. Managed from Users & roles instead, which is what the UI and the 409 both say.
admin = make_user(email="admin@example.com", role="admin")
make_user(email="signup@example.com", is_active=False, email_verified=False)
db.add(Invite(email="signup@example.com", status="pending"))
db.commit()
client = as_user(admin)
listed = _invites(client)["signup@example.com"]
assert listed["has_account"] is True # the UI shows a disabled control, not a missing one
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 == "signup@example.com")).scalar_one_or_none()
is not None
)
def test_deleting_an_env_allow_listed_address_reports_that_it_kept_access(
db, make_user, as_user, monkeypatch
):
# Removing the row is NOT always a revoke: is_allowed falls back to the env bootstrap lists once
# no invite exists. The admin has to be told, or they think they removed access and did not.
from app.config import settings
monkeypatch.setattr(
type(settings), "allowed_email_set", property(lambda self: {"ops@example.com"})
)
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="ops@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
r = client.delete(f"/api/admin/invites/{_invites(client)['ops@example.com']['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is True
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.
@@ -67,6 +111,7 @@ def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is False # nothing in the env allow-lists
assert "asker@example.com" not in _invites(client)
+58
View File
@@ -0,0 +1,58 @@
"""/api/me carries whether this instance can actually send email.
It drives the operator warnings on the Configuration and Access-requests screens. Admin-only,
because it is an operator concern and a regular user has nothing to do with it.
"""
from app import email as email_mod
_PW = "correct horse battery"
def _signed_in(client, make_user, **kw):
"""/api/me resolves its user through optional_current_user, which the as_user override does not
cover — so sign in for real and read the bootstrap payload the SPA actually gets."""
user = make_user(password=_PW, **kw)
assert (
client.post("/auth/password-login", json={"email": user.email, "password": _PW}).status_code
== 200
)
r = client.get("/api/me")
assert r.status_code == 200 and r.json()["authenticated"] is True
return r.json()
def test_an_admin_is_told_when_email_is_not_configured(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is False
def test_an_admin_sees_nothing_wrong_when_email_works(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is True
def test_a_regular_user_is_never_warned_about_the_mail_setup(client, make_user, monkeypatch):
# Not their problem, and the flag must not become a way to probe the instance's configuration.
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="plain@example.com")
assert me["email_configured"] is True
def test_the_flag_reuses_the_request_session(client, make_user, monkeypatch):
# /api/me is polled once a minute per open tab; reading the SMTP settings decrypts a Fernet
# value, so it must not also check out a second pooled connection each time.
seen: list[object] = []
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: seen.append(db) or True)
_signed_in(client, make_user, email="admin@example.com", role="admin")
assert seen and seen[0] is not None
+43 -12
View File
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { api, HttpError, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
@@ -301,11 +301,26 @@ function AdminInvites() {
};
const remove = useMutation({
mutationFn: (id: number) => api.deleteInvite(id),
onSuccess: (_d, _id) => {
onSuccess: (d) => {
refresh();
notify({ level: "success", message: t("settings.invites.deleted") });
// Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so
// for an address on those the person keeps access. Saying "removed" alone would mislead.
if (d.still_allowed_by_env)
notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") });
else notify({ level: "success", message: t("settings.invites.deleted") });
},
onError: (e) => {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
notify({
level: "error",
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) refresh();
},
onError: () => notify({ level: "error", message: t("settings.invites.deleteFailed") }),
});
const onDelete = async (i: { id: number; email: string }) => {
const ok = await confirm({
@@ -395,11 +410,15 @@ function AdminInvites() {
{/* 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>
<button
type="button"
disabled
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
@@ -591,9 +610,21 @@ function InviteRow({
<X className="w-4 h-4" />
</button>
</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 && (
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
instead — shown as a disabled control rather than omitted, or the admin sees a row that is
silently missing a button their other rows have. */}
{inv.has_account ? (
<button
type="button"
disabled
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
onClick={onDelete}
+4
View File
@@ -103,6 +103,10 @@ export default function ConfigPanel() {
}
}
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
// The email-not-configured notice reads me.email_configured, which is otherwise only
// refetched on its own 60s interval — without this it keeps warning about fields the admin
// has just filled in, which reads as the save not having taken.
await qc.invalidateQueries({ queryKey: qk.me() });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
//
// The sign-in card's sub-views as the user meets them: clicking a link must leave a history entry,
// so Back — including the mouse's back button — returns to the previous view. They used to be plain
// React state, and Back moved only the address bar while the form on screen stayed put. The
// encode/decode table lives in welcomeMode.dom.test.ts; this file renders the real card, so a revert
// to useState fails here.
//
// i18n is mocked to a key-passthrough so queries read stable keys, and the API module is stubbed —
// the card probes /auth/config on mount and must not reach the network.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: { ...actual.api, authConfig: () => Promise.resolve({ google_enabled: false }) },
};
});
import Welcome from "./Welcome";
const back = () =>
act(() => {
// jsdom's history.back() is async and does not rewind location, so land on the previous entry
// the way the browser would and fire popstate. The push itself is asserted via history.length.
window.history.replaceState(null, "", "/");
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
});
beforeEach(() => window.history.replaceState(null, "", "/"));
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("sign-in card sub-views and the Back button", () => {
it("opens Forgot password as a history entry and comes back to sign in", async () => {
render(<Welcome />);
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
const entries = window.history.length;
fireEvent.click(screen.getByText("welcome.auth.forgotLink"));
expect(screen.getByText("welcome.auth.forgotTitle")).toBeTruthy();
expect(window.history.length).toBe(entries + 1); // a Back now has somewhere to go
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
expect(screen.queryByText("welcome.auth.forgotTitle")).toBeNull();
});
it("does the same for Create an account and the verification-email link", async () => {
render(<Welcome />);
await screen.findByText("welcome.auth.signinTitle");
fireEvent.click(screen.getByText("welcome.auth.createLink"));
expect(screen.getByText("welcome.auth.createTitle")).toBeTruthy();
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
fireEvent.click(screen.getByText("welcome.auth.resendLink"));
expect(screen.getByText("welcome.auth.resendTitle")).toBeTruthy();
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
});
});
+3 -3
View File
@@ -30,8 +30,8 @@
"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."
"title": "Email is not going out",
"notConfigured": "SMTP is not configured, so nothing this instance sends is being delivered — verification links, password resets and access requests alike. The most recent was “{{subject}}”.",
"sendFailed": "The mail server rejected a message, so deliveries are failing — the most recent was “{{subject}}”. Check Configuration → Email / SMTP and send a test."
}
}
+5 -4
View File
@@ -167,12 +167,13 @@
"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.",
"deleteHint": "Forget this address. A later request from it comes back as a new one.",
"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.",
"deleteConfirm": "Forget {{email}}? Siftlode drops the record, including an approval you already gave, so anyone at that address would have to ask again.",
"deleteHasAccount": "Someone has registered on this address — manage it under Users & roles, where deleting the account removes this entry too.",
"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."
"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.",
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that."
}
}
+3 -3
View File
@@ -30,8 +30,8 @@
"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."
"title": "Nem megy ki e-mail",
"notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférés-kérelmek egyaránt. A legutóbbi: „{{subject}}”.",
"sendFailed": "A levelezőszerver visszautasított egy üzenetet, tehát a kézbesítés hibára fut — a legutóbbi: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet."
}
}
+5 -4
View File
@@ -167,12 +167,13 @@
"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.",
"deleteHint": "A cím elfelejtése. Egy későbbi kérelme új kérelemként jelenik meg.",
"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.",
"deleteConfirm": "Elfelejted {{email}} címet? A Siftlode eldobja a bejegyzést, a korábban megadott jóváhagyással együtt, így az adott címről újra kérelmezni kell.",
"deleteHasAccount": "Erre a címre már regisztrált valaki — a Felhasználók és szerepkörök fülön kezelhető, ahol a fiók törlése ezt a bejegyzést is eltávolítja.",
"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ó."
"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ó.",
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál."
}
}
+4 -1
View File
@@ -172,5 +172,8 @@ export const adminApi = {
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
deleteInvite: (id: number) => req(`/api/admin/invites/${id}`, { method: "DELETE" }),
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
* ALLOWED_EMAILS / ADMIN_EMAILS, i.e. removing the row did not actually revoke access. */
deleteInvite: (id: number): Promise<{ deleted: number; still_allowed_by_env: boolean }> =>
req(`/api/admin/invites/${id}`, { method: "DELETE" }),
};
+5 -5
View File
@@ -18,18 +18,18 @@ 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.",
"Admins can 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.",
"Delete an entry under Users → Access requests: an address you whitelisted earlier, or a request nobody has acted on. Deny records your decision and keeps the record; delete forgets the address, and a later request from it arrives as a new one. Entries that already have an account behind them show the control disabled — that row is the account's approval gate, so it is managed from Users & roles, where deleting the account removes the entry too. If the address is also allow-listed in the server's configuration, the confirmation says so, because then removing the entry does not actually take access away.",
"When a message cannot go out, every admin gets it in their inbox, naming the most recent 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.",
"DELETE /api/admin/invites/{id}, refused with 409 while a User row exists for the address, whatever its state — orphaning an account from its invite would leave someone who can neither sign in nor be approved. The list carries has_account so the UI disables rather than hides the control, and the response reports still_allowed_by_env, since is_allowed falls back to ALLOWED_EMAILS / ADMIN_EMAILS once no row exists. 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; a failed or unnecessary write releases the throttle rather than costing the next hour. /api/me carries email_configured for admins, read through the request's own session. The login card's mode moved to useHistorySubview, covered by a render test that fails if it goes back to useState, and the per-test rate-limiter reset now sweeps every loaded app.* module instead of a hand-kept list.",
],
},
{