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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user