Show success banner after sending a password-reset email
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:
Tyler
2026-06-17 12:31:38 -06:00
parent eb37df347e
commit 7e079e0186
2 changed files with 39 additions and 9 deletions
+24 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { AdminUserRow, UpdateAdminUserInput } from "@/actions/admin/users"; import { AdminUserRow, UpdateAdminUserInput } from "@/actions/admin/users";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import CreateUserModal from "./CreateUserModal"; import CreateUserModal from "./CreateUserModal";
@@ -124,6 +124,14 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
const [actionMenuId, setActionMenuId] = useState<string | null>(null); const [actionMenuId, setActionMenuId] = useState<string | null>(null);
const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null); const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null);
const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null); const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 = const canManageUsers =
currentUser.role === "platform_admin" || currentUser.role === "platform_admin" ||
@@ -300,12 +308,14 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
async function handleSendResetLink(email: string) { async function handleSendResetLink(email: string) {
setResetLinkLoading(email); setResetLinkLoading(email);
setError(null);
setSuccess(null);
try { try {
const { sendPasswordResetEmail } = await import("@/actions/admin/users"); const { sendPasswordResetEmail } = await import("@/actions/admin/users");
const result = await sendPasswordResetEmail(email); const result = await sendPasswordResetEmail(email);
setResetLinkLoading(null); setResetLinkLoading(null);
if (result.error) throw new Error(result.error); if (result.error) throw new Error(result.error);
setError(null); setSuccess(`Password-reset email sent to ${email}.`);
} catch (e: unknown) { } catch (e: unknown) {
setResetLinkLoading(null); setResetLinkLoading(null);
setError(e instanceof Error ? e.message : "Failed to send reset email"); setError(e instanceof Error ? e.message : "Failed to send reset email");
@@ -341,6 +351,18 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
)} )}
</div> </div>
{/* Success banner */}
{success && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-emerald-50 p-4 text-sm text-emerald-800 gap-3 border border-emerald-200">
<span> {success}</span>
<button onClick={() => setSuccess(null)} className="text-emerald-500 hover:text-emerald-700 shrink-0" aria-label="Dismiss">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Error banner */} {/* Error banner */}
{error && ( {error && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200"> <div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
+15 -7
View File
@@ -105,6 +105,7 @@ describe("resetAdminPassword — authorization", () => {
mockGetAdminUser.mockResolvedValue(null); mockGetAdminUser.mockResolvedValue(null);
const r = await resetAdminPassword("user@example.com"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(false); expect(r.success).toBe(false);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/not authenticated/i); expect(r.error).toMatch(/not authenticated/i);
expect(mockSetUserPassword).not.toHaveBeenCalled(); expect(mockSetUserPassword).not.toHaveBeenCalled();
expect(mockRequestPasswordReset).not.toHaveBeenCalled(); expect(mockRequestPasswordReset).not.toHaveBeenCalled();
@@ -114,6 +115,7 @@ describe("resetAdminPassword — authorization", () => {
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN); mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
const r = await resetAdminPassword("user@example.com"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(false); expect(r.success).toBe(false);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/only platform admins/i); expect(r.error).toMatch(/only platform admins/i);
expect(mockSetUserPassword).not.toHaveBeenCalled(); expect(mockSetUserPassword).not.toHaveBeenCalled();
expect(mockRequestPasswordReset).not.toHaveBeenCalled(); expect(mockRequestPasswordReset).not.toHaveBeenCalled();
@@ -124,6 +126,7 @@ describe("resetAdminPassword — input handling", () => {
it("rejects an empty email", async () => { it("rejects an empty email", async () => {
const r = await resetAdminPassword(" "); const r = await resetAdminPassword(" ");
expect(r.success).toBe(false); expect(r.success).toBe(false);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/required/i); expect(r.error).toMatch(/required/i);
}); });
@@ -145,6 +148,7 @@ describe("resetAdminPassword — input handling", () => {
}); });
const r = await resetAdminPassword("missing@example.com"); const r = await resetAdminPassword("missing@example.com");
expect(r.success).toBe(false); expect(r.success).toBe(false);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/no neon auth user found/i); expect(r.error).toMatch(/no neon auth user found/i);
expect(mockSetUserPassword).not.toHaveBeenCalled(); 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 () => { it("returns a server-generated temp password and flips must_change_password", async () => {
const r = await resetAdminPassword("user@example.com"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(true); expect(r.success).toBe(true);
if (!r.success) throw new Error("expected success");
if (r.method !== "set") throw new Error("expected method=set"); if (r.method !== "set") throw new Error("expected method=set");
// The password is server-generated — must be a non-empty strong // The password is server-generated — must be a non-empty strong
// string, never a hard-coded constant like "Tuxedo2026!". // 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"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(true); 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"); if (r.method !== "reset_link_sent") throw new Error("expected method=reset_link_sent");
expect(r.message).toMatch(/reset email/i); expect(r.message).toMatch(/reset email/i);
expect(mockRequestPasswordReset).toHaveBeenCalledWith({ expect(mockRequestPasswordReset).toHaveBeenCalledWith({
email: "user@example.com", email: "user@example.com",
redirectTo: "http://localhost:4000/reset-password", redirectTo: "http://localhost:4000/reset-password",
}); });
// In the fallback case, no tempPassword is returned and the // The fallback case returns method="reset_link_sent" with a
// must_change_password flag is NOT flipped (the user picks their // `message` describing what was sent; TypeScript guarantees
// own password via the link). // tempPassword is not on this variant.
if (r.method === "set") throw new Error("unreachable");
expect((r as { tempPassword?: string }).tempPassword).toBeUndefined();
}); });
it("falls back when setUserPassword returns UNAUTHORIZED", async () => { 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"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(true); 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 () => { it("falls back when setUserPassword throws", async () => {
mockSetUserPassword.mockRejectedValue(new Error("network down")); mockSetUserPassword.mockRejectedValue(new Error("network down"));
const r = await resetAdminPassword("user@example.com"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(true); 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 () => { 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"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(false); expect(r.success).toBe(false);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/no such user in neon auth/); expect(r.error).toMatch(/no such user in neon auth/);
expect(mockRequestPasswordReset).not.toHaveBeenCalled(); expect(mockRequestPasswordReset).not.toHaveBeenCalled();
}); });
@@ -234,6 +241,7 @@ describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", ()
}); });
const r = await resetAdminPassword("user@example.com"); const r = await resetAdminPassword("user@example.com");
expect(r.success).toBe(false); expect(r.success).toBe(false);
if (r.success) throw new Error("expected failure");
expect(r.error).toMatch(/too many requests/); expect(r.error).toMatch(/too many requests/);
}); });
}); });