Show success banner after sending a password-reset email
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
The 'Send Reset Email' button in /admin/users previously just cleared the error banner on success, with no actual 'yes it worked' feedback. Added a green success banner that mirrors the error banner's style and auto-dismisses after 6 seconds. The 'Reset Password' button already shows confirmation in the modal (temp password to copy, or 'reset email sent' message), so it doesn't need the banner. Also tightened the type narrowing in the resetAdminPassword unit tests — the discriminated union needed a two-step narrow (`r.success` then `r.method`) before TypeScript would allow access to variant-specific fields like `tempPassword`.
This commit is contained in:
@@ -105,6 +105,7 @@ describe("resetAdminPassword — authorization", () => {
|
||||
mockGetAdminUser.mockResolvedValue(null);
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) throw new Error("expected failure");
|
||||
expect(r.error).toMatch(/not authenticated/i);
|
||||
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
@@ -114,6 +115,7 @@ describe("resetAdminPassword — authorization", () => {
|
||||
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) throw new Error("expected failure");
|
||||
expect(r.error).toMatch(/only platform admins/i);
|
||||
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
@@ -124,6 +126,7 @@ describe("resetAdminPassword — input handling", () => {
|
||||
it("rejects an empty email", async () => {
|
||||
const r = await resetAdminPassword(" ");
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) throw new Error("expected failure");
|
||||
expect(r.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
@@ -145,6 +148,7 @@ describe("resetAdminPassword — input handling", () => {
|
||||
});
|
||||
const r = await resetAdminPassword("missing@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) throw new Error("expected failure");
|
||||
expect(r.error).toMatch(/no neon auth user found/i);
|
||||
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -154,6 +158,7 @@ describe("resetAdminPassword — happy path via setUserPassword", () => {
|
||||
it("returns a server-generated temp password and flips must_change_password", async () => {
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
if (!r.success) throw new Error("expected success");
|
||||
if (r.method !== "set") throw new Error("expected method=set");
|
||||
// The password is server-generated — must be a non-empty strong
|
||||
// string, never a hard-coded constant like "Tuxedo2026!".
|
||||
@@ -182,17 +187,16 @@ describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", ()
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
if (!r.success) throw new Error("expected success");
|
||||
if (r.method !== "reset_link_sent") throw new Error("expected method=reset_link_sent");
|
||||
expect(r.message).toMatch(/reset email/i);
|
||||
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
|
||||
email: "user@example.com",
|
||||
redirectTo: "http://localhost:4000/reset-password",
|
||||
});
|
||||
// In the fallback case, no tempPassword is returned and the
|
||||
// must_change_password flag is NOT flipped (the user picks their
|
||||
// own password via the link).
|
||||
if (r.method === "set") throw new Error("unreachable");
|
||||
expect((r as { tempPassword?: string }).tempPassword).toBeUndefined();
|
||||
// The fallback case returns method="reset_link_sent" with a
|
||||
// `message` describing what was sent; TypeScript guarantees
|
||||
// tempPassword is not on this variant.
|
||||
});
|
||||
|
||||
it("falls back when setUserPassword returns UNAUTHORIZED", async () => {
|
||||
@@ -202,14 +206,16 @@ describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", ()
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.method).toBe("reset_link_sent");
|
||||
if (!r.success) throw new Error("expected success");
|
||||
if (r.method !== "reset_link_sent") throw new Error("expected method=reset_link_sent");
|
||||
});
|
||||
|
||||
it("falls back when setUserPassword throws", async () => {
|
||||
mockSetUserPassword.mockRejectedValue(new Error("network down"));
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.method).toBe("reset_link_sent");
|
||||
if (!r.success) throw new Error("expected success");
|
||||
if (r.method !== "reset_link_sent") throw new Error("expected method=reset_link_sent");
|
||||
});
|
||||
|
||||
it("does NOT fall back for USER_NOT_FOUND — it surfaces the error", async () => {
|
||||
@@ -219,6 +225,7 @@ describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", ()
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) throw new Error("expected failure");
|
||||
expect(r.error).toMatch(/no such user in neon auth/);
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -234,6 +241,7 @@ describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", ()
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) throw new Error("expected failure");
|
||||
expect(r.error).toMatch(/too many requests/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user