From 7e079e0186e7ecfbe2c7e1e4674e96053b39187f Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 12:31:38 -0600 Subject: [PATCH] Show success banner after sending a password-reset email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- src/components/admin/UsersPage.tsx | 26 +++++++++++++++++++++++-- tests/unit/reset-admin-password.test.ts | 22 ++++++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/components/admin/UsersPage.tsx b/src/components/admin/UsersPage.tsx index 5228ce6..8bf6e06 100644 --- a/src/components/admin/UsersPage.tsx +++ b/src/components/admin/UsersPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AdminUserRow, UpdateAdminUserInput } from "@/actions/admin/users"; import { formatDate } from "@/lib/format-date"; import CreateUserModal from "./CreateUserModal"; @@ -124,6 +124,14 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre const [actionMenuId, setActionMenuId] = useState(null); const [passwordModal, setPasswordModal] = useState(null); const [resetLinkLoading, setResetLinkLoading] = useState(null); + const [success, setSuccess] = useState(null); + + // Auto-dismiss the success banner after 6 seconds. + useEffect(() => { + if (!success) return; + const t = setTimeout(() => setSuccess(null), 6000); + return () => clearTimeout(t); + }, [success]); const canManageUsers = currentUser.role === "platform_admin" || @@ -300,12 +308,14 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre async function handleSendResetLink(email: string) { setResetLinkLoading(email); + setError(null); + setSuccess(null); try { const { sendPasswordResetEmail } = await import("@/actions/admin/users"); const result = await sendPasswordResetEmail(email); setResetLinkLoading(null); if (result.error) throw new Error(result.error); - setError(null); + setSuccess(`Password-reset email sent to ${email}.`); } catch (e: unknown) { setResetLinkLoading(null); setError(e instanceof Error ? e.message : "Failed to send reset email"); @@ -341,6 +351,18 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre )} + {/* Success banner */} + {success && ( +
+ ✓ {success} + +
+ )} + {/* Error banner */} {error && (
diff --git a/tests/unit/reset-admin-password.test.ts b/tests/unit/reset-admin-password.test.ts index df3d56f..8d1d670 100644 --- a/tests/unit/reset-admin-password.test.ts +++ b/tests/unit/reset-admin-password.test.ts @@ -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/); }); });