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