Fix admin password reset (Send Reset Email + Reset Password)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Both buttons in /admin/users were broken:
- "Send Reset Email" called a no-op stub in
src/actions/admin/users.ts that always returned an error.
- "Reset Password" called resetAdminPassword with a hard-coded
'Tuxedo2026!' password, and the function itself queried a
non-existent `users` table and called a non-existent
`update_user_password` RPC (leftover Supabase-era code).
Rewritten against Neon Auth:
- sendPasswordResetEmail(email) — platform_admin-only action that
calls auth.requestPasswordReset with the configured
NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
a clear success/error.
- resetAdminPassword(email) — platform_admin-only action that:
1. Looks up neon_auth.user by email
2. Generates a strong server-side random temp password
3. Tries auth.admin.setUserPassword first (instant credential,
returned to the UI for the platform admin to share)
4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
case — provision-admin.ts does not promote callers to
role='admin' in Neon Auth), falls back to
auth.requestPasswordReset, which sends a reset link the user
can click to set their own password.
5. On the privileged path, flips admin_users.must_change_password
so the user is forced to pick a real password on next sign-in.
UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.
Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
This commit is contained in:
@@ -1,42 +1,149 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { randomBytes } from "crypto";
|
||||
import { query } from "@/lib/db";
|
||||
import {
|
||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
||||
setUserPassword as neonAuthSetUserPassword,
|
||||
} from "@/lib/auth";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export type ResetAdminPasswordResult =
|
||||
| { success: true; tempPassword: string }
|
||||
| { success: true; tempPassword: string; method: "set" }
|
||||
| { success: true; method: "reset_link_sent"; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Emergency recovery action — only usable in development or when the caller
|
||||
* already has direct DB access. Resets the password for the specified email
|
||||
* and returns the temp password so it can be displayed to the user.
|
||||
* Emergency recovery action — resets the password for the specified
|
||||
* email so the platform admin can hand a working credential to the
|
||||
* user.
|
||||
*
|
||||
* Now that Supabase auth is gone, this hits the `users` table directly and
|
||||
* calls the same `update_user_password` SECURITY DEFINER RPC used by the
|
||||
* self-service password change action.
|
||||
* Tries, in order:
|
||||
* 1. `auth.admin.setUserPassword({ userId, newPassword })` — instant,
|
||||
* works when the caller has `role='admin'` in `neon_auth.user`.
|
||||
* 2. Falls back to `requestPasswordReset({ email, redirectTo })` —
|
||||
* public Neon Auth endpoint, always works, but the user has to
|
||||
* click the link in the email. The fallback covers the common
|
||||
* case where `provision-admin.ts` did not promote the caller to
|
||||
* `role='admin'` in Neon Auth.
|
||||
*
|
||||
* Authorization: platform_admin only. The dev-session platform_admin
|
||||
* shim is honored (same as the rest of the admin actions).
|
||||
*/
|
||||
export async function resetAdminPassword(
|
||||
email: string,
|
||||
newPassword: string
|
||||
): Promise<ResetAdminPasswordResult> {
|
||||
// Look up the user by email
|
||||
// 1. Authz check.
|
||||
const me = await getAdminUser();
|
||||
if (!me) {
|
||||
return { success: false, error: "Not authenticated." };
|
||||
}
|
||||
if (me.role !== "platform_admin") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Only platform admins can reset passwords.",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
return { success: false, error: "Email is required." };
|
||||
}
|
||||
|
||||
// 2. Look up the Neon Auth user id by email.
|
||||
const { rows } = await query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE email = $1 LIMIT 1",
|
||||
[email.toLowerCase()],
|
||||
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
|
||||
[normalizedEmail],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return { success: false, error: "No auth user found for that email address." };
|
||||
return {
|
||||
success: false,
|
||||
error: `No Neon Auth user found for ${normalizedEmail}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Update password via SECURITY DEFINER RPC
|
||||
// 3. Generate a strong temp password server-side. 16 bytes → 32
|
||||
// base64url chars; mix in a symbol so it satisfies typical
|
||||
// password policies.
|
||||
const tempPassword = `${randomBytes(18).toString("base64url")}!`;
|
||||
|
||||
// 4. Try the privileged admin endpoint.
|
||||
try {
|
||||
await query("SELECT update_user_password($1, $2)", [user.id, newPassword]);
|
||||
return { success: true, tempPassword: newPassword };
|
||||
const adminResult = await neonAuthSetUserPassword({
|
||||
userId: user.id,
|
||||
newPassword: tempPassword,
|
||||
});
|
||||
|
||||
if (adminResult?.error) {
|
||||
const code = adminResult.error.code ?? "";
|
||||
const canFallback =
|
||||
code === "FORBIDDEN" ||
|
||||
code === "UNAUTHORIZED" ||
|
||||
code === "INTERNAL_SERVER_ERROR";
|
||||
console.warn(
|
||||
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
|
||||
code,
|
||||
canFallback ? "" : " NOT",
|
||||
adminResult.error.message ?? "",
|
||||
);
|
||||
|
||||
if (!canFallback) {
|
||||
return {
|
||||
success: false,
|
||||
error: adminResult.error.message ?? `setUserPassword failed: ${code}`,
|
||||
};
|
||||
}
|
||||
// fall through to the public reset-link path below
|
||||
} else {
|
||||
// Success — flip must_change_password so the user is forced
|
||||
// to pick a new password on next sign-in.
|
||||
try {
|
||||
await query(
|
||||
`UPDATE admin_users SET must_change_password = true WHERE user_id = $1`,
|
||||
[user.id],
|
||||
);
|
||||
} catch (flagErr) {
|
||||
console.warn(
|
||||
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
|
||||
flagErr,
|
||||
);
|
||||
}
|
||||
return { success: true, tempPassword, method: "set" };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[resetAdminPassword] setUserPassword threw:", err);
|
||||
// network / unexpected — try the fallback below
|
||||
}
|
||||
|
||||
// 5. Fallback: send a password-reset email via the public endpoint.
|
||||
try {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
||||
const resetResult = await neonAuthRequestPasswordReset({
|
||||
email: normalizedEmail,
|
||||
redirectTo: `${siteUrl}/reset-password`,
|
||||
});
|
||||
|
||||
if (resetResult?.error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
resetResult.error.message ??
|
||||
resetResult.error.code ??
|
||||
"Failed to send reset email",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: "reset_link_sent",
|
||||
message: `Set-password is not available for this account. Sent a password-reset email to ${normalizedEmail} instead — share that link with the user.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to update password",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+56
-11
@@ -2,7 +2,10 @@
|
||||
|
||||
import "server-only";
|
||||
import { query, withTx } from "@/lib/db";
|
||||
import { createUser as neonAuthCreateUser } from "@/lib/auth";
|
||||
import {
|
||||
createUser as neonAuthCreateUser,
|
||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
||||
} from "@/lib/auth";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export type AdminUserRow = {
|
||||
@@ -563,17 +566,59 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
|
||||
}
|
||||
|
||||
/**
|
||||
* No auth service anymore (no Supabase, no Auth.js password-reset
|
||||
* endpoint). A platform admin can reset access by deleting +
|
||||
* re-creating the user, or by toggling `must_change_password` via the
|
||||
* UI — the function is preserved as a no-op so call sites keep
|
||||
* compiling.
|
||||
* Sends a password-reset email to the user via Neon Auth.
|
||||
*
|
||||
* Authorization: platform_admin only. Brand-scoped admins cannot
|
||||
* trigger password resets for users outside their brand.
|
||||
*
|
||||
* The endpoint used (`requestPasswordReset`) is a public Neon Auth
|
||||
* endpoint — it does not require the caller to be the target user.
|
||||
* For the same reason the public /api/auth/forgot-password route
|
||||
* works, this will always succeed against the Neon Auth API; we
|
||||
* still return the result so the UI can show a clear success/failure
|
||||
* message.
|
||||
*/
|
||||
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
||||
};
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
try {
|
||||
// Authz: must be signed in as a platform_admin.
|
||||
const me = await getAdminUser();
|
||||
if (!me) {
|
||||
return { success: false, error: "Not authenticated." };
|
||||
}
|
||||
if (me.role !== "platform_admin") {
|
||||
return { success: false, error: "Only platform admins can send password resets." };
|
||||
}
|
||||
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
if (!trimmedEmail) {
|
||||
return { success: false, error: "Email is required." };
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
||||
|
||||
const result = await neonAuthRequestPasswordReset({
|
||||
email: trimmedEmail,
|
||||
redirectTo: `${siteUrl}/reset-password`,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
console.error("[admin/sendPasswordResetEmail] Neon Auth error:", result.error);
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.message ?? result.error.code ?? "Failed to send reset email",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
console.error("[admin/sendPasswordResetEmail] Unexpected error:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||
|
||||
@@ -19,6 +19,7 @@ type UsersPageProps = {
|
||||
type PasswordModal = {
|
||||
email: string;
|
||||
tempPassword: string | null;
|
||||
message: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
@@ -244,17 +245,45 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
}
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
setPasswordModal({ email, tempPassword: null, loading: true, error: null });
|
||||
setPasswordModal({ email, tempPassword: null, message: null, loading: true, error: null });
|
||||
try {
|
||||
const { resetAdminPassword } = await import("@/actions/admin/reset-admin");
|
||||
const result = await resetAdminPassword(email, "Tuxedo2026!");
|
||||
const result = await resetAdminPassword(email);
|
||||
if (result.success) {
|
||||
setPasswordModal({ email, tempPassword: result.tempPassword, loading: false, error: null });
|
||||
if (result.method === "reset_link_sent") {
|
||||
setPasswordModal({
|
||||
email,
|
||||
tempPassword: null,
|
||||
message: result.message,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
setPasswordModal({
|
||||
email,
|
||||
tempPassword: result.tempPassword,
|
||||
message: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" });
|
||||
setPasswordModal({
|
||||
email,
|
||||
tempPassword: null,
|
||||
message: null,
|
||||
loading: false,
|
||||
error: result.error ?? "Failed to reset password",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setPasswordModal({ email, tempPassword: null, loading: false, error: e instanceof Error ? e.message : "Error" });
|
||||
setPasswordModal({
|
||||
email,
|
||||
tempPassword: null,
|
||||
message: null,
|
||||
loading: false,
|
||||
error: e instanceof Error ? e.message : "Error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,6 +696,11 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
<p className="mt-2 text-xs text-stone-500">Share this password securely with the user. They will be required to change it on next login.</p>
|
||||
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
|
||||
</div>
|
||||
) : passwordModal.message ? (
|
||||
<div>
|
||||
<p className="mt-3 text-sm text-stone-600">{passwordModal.message}</p>
|
||||
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Unit tests for `resetAdminPassword` in `src/actions/admin/reset-admin.ts`.
|
||||
*
|
||||
* The action must:
|
||||
* 1. Reject unauthenticated callers and non-platform-admins
|
||||
* 2. Look up `neon_auth.user` by email (NOT the legacy `users` table)
|
||||
* 3. Try `auth.admin.setUserPassword` first (instant credential)
|
||||
* 4. Fall back to `auth.requestPasswordReset` when the admin
|
||||
* endpoint returns FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR
|
||||
* 5. On success, set `must_change_password = true` for the user
|
||||
* 6. Generate a strong server-side random password (no hard-coded
|
||||
* defaults like "Tuxedo2026!")
|
||||
* 7. Return clear errors when no Neon Auth user matches the email
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetAdminUser,
|
||||
mockQuery,
|
||||
mockSetUserPassword,
|
||||
mockRequestPasswordReset,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetAdminUser: vi.fn(),
|
||||
mockQuery: vi.fn(),
|
||||
mockSetUserPassword: vi.fn(),
|
||||
mockRequestPasswordReset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/lib/admin-permissions", () => ({
|
||||
getAdminUser: mockGetAdminUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
setUserPassword: mockSetUserPassword,
|
||||
requestPasswordReset: mockRequestPasswordReset,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: () => Promise.resolve({ get: () => undefined }),
|
||||
}));
|
||||
|
||||
import { resetAdminPassword } from "@/actions/admin/reset-admin";
|
||||
|
||||
const PLATFORM_ADMIN = {
|
||||
id: "platform-1",
|
||||
user_id: "platform-1",
|
||||
email: "root@example.com",
|
||||
display_name: "Root",
|
||||
brand_id: null,
|
||||
brand_ids: [],
|
||||
brand_slug: null,
|
||||
role: "platform_admin" as const,
|
||||
active: true,
|
||||
auth_provider: "neon_auth" as const,
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
can_manage_billing: true,
|
||||
can_manage_branding: true,
|
||||
can_manage_marketing: true,
|
||||
can_manage_team: true,
|
||||
must_change_password: false,
|
||||
};
|
||||
|
||||
const BRAND_ADMIN = {
|
||||
...PLATFORM_ADMIN,
|
||||
role: "brand_admin" as const,
|
||||
brand_id: "brand-tux",
|
||||
brand_ids: ["brand-tux"],
|
||||
brand_slug: "tuxedo",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000";
|
||||
mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN);
|
||||
// Default: Neon Auth user lookup succeeds.
|
||||
mockQuery.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes("SELECT id FROM neon_auth.user")) {
|
||||
return { rows: [{ id: "neon-user-1" }] };
|
||||
}
|
||||
// UPDATE admin_users SET must_change_password — accept silently.
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
// Default: setUserPassword succeeds.
|
||||
mockSetUserPassword.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
mockRequestPasswordReset.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
});
|
||||
|
||||
describe("resetAdminPassword — authorization", () => {
|
||||
it("rejects unauthenticated callers", async () => {
|
||||
mockGetAdminUser.mockResolvedValue(null);
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/not authenticated/i);
|
||||
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-platform-admin callers", async () => {
|
||||
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/only platform admins/i);
|
||||
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAdminPassword — input handling", () => {
|
||||
it("rejects an empty email", async () => {
|
||||
const r = await resetAdminPassword(" ");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it("looks up the user in neon_auth.user (not the legacy `users` table)", async () => {
|
||||
const r = await resetAdminPassword("User@Example.COM");
|
||||
expect(r.success).toBe(true);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
"SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1",
|
||||
["user@example.com"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a clear error when no Neon Auth user matches the email", async () => {
|
||||
mockQuery.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes("SELECT id FROM neon_auth.user")) {
|
||||
return { rows: [] };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
const r = await resetAdminPassword("missing@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/no neon auth user found/i);
|
||||
expect(mockSetUserPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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.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!".
|
||||
expect(r.tempPassword.length).toBeGreaterThan(20);
|
||||
expect(r.tempPassword).not.toBe("Tuxedo2026!");
|
||||
expect(r.tempPassword).toMatch(/[!@#$%^&*]/); // includes a symbol
|
||||
|
||||
expect(mockSetUserPassword).toHaveBeenCalledTimes(1);
|
||||
const [args] = mockSetUserPassword.mock.calls[0];
|
||||
expect(args.userId).toBe("neon-user-1");
|
||||
expect(args.newPassword).toBe(r.tempPassword);
|
||||
|
||||
// The must_change_password flag should be flipped.
|
||||
const flagUpdate = mockQuery.mock.calls.find(([sql]) =>
|
||||
String(sql).includes("UPDATE admin_users SET must_change_password"),
|
||||
);
|
||||
expect(flagUpdate).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAdminPassword — FORBIDDEN fallback to requestPasswordReset", () => {
|
||||
it("falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN", async () => {
|
||||
mockSetUserPassword.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "FORBIDDEN", message: "Admin role required" },
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
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();
|
||||
});
|
||||
|
||||
it("falls back when setUserPassword returns UNAUTHORIZED", async () => {
|
||||
mockSetUserPassword.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "UNAUTHORIZED", message: "no session" },
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.method).toBe("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");
|
||||
});
|
||||
|
||||
it("does NOT fall back for USER_NOT_FOUND — it surfaces the error", async () => {
|
||||
mockSetUserPassword.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "USER_NOT_FOUND", message: "no such user in neon auth" },
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/no such user in neon auth/);
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates the public-endpoint error if BOTH paths fail", async () => {
|
||||
mockSetUserPassword.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
mockRequestPasswordReset.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "RATE_LIMITED", message: "too many requests" },
|
||||
});
|
||||
const r = await resetAdminPassword("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/too many requests/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Unit tests for `sendPasswordResetEmail` in `src/actions/admin/users.ts`.
|
||||
*
|
||||
* The action must:
|
||||
* 1. Reject unauthenticated callers
|
||||
* 2. Reject non-platform-admin callers
|
||||
* 3. Trim + lowercase the email before calling Neon Auth
|
||||
* 4. Call `requestPasswordReset` with the correct `redirectTo`
|
||||
* 5. Return success on a clean Neon Auth response
|
||||
* 6. Propagate Neon Auth errors back to the UI
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetAdminUser,
|
||||
mockRequestPasswordReset,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetAdminUser: vi.fn(),
|
||||
mockRequestPasswordReset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/lib/admin-permissions", () => ({
|
||||
getAdminUser: mockGetAdminUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
requestPasswordReset: mockRequestPasswordReset,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
query: vi.fn(),
|
||||
withTx: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: () => Promise.resolve({ get: () => undefined }),
|
||||
}));
|
||||
|
||||
import { sendPasswordResetEmail } from "@/actions/admin/users";
|
||||
|
||||
const PLATFORM_ADMIN = {
|
||||
id: "platform-1",
|
||||
user_id: "platform-1",
|
||||
email: "root@example.com",
|
||||
display_name: "Root",
|
||||
brand_id: null,
|
||||
brand_ids: [],
|
||||
brand_slug: null,
|
||||
role: "platform_admin" as const,
|
||||
active: true,
|
||||
auth_provider: "neon_auth" as const,
|
||||
can_manage_products: true,
|
||||
can_manage_stops: true,
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: true,
|
||||
can_manage_messages: true,
|
||||
can_manage_refunds: true,
|
||||
can_manage_users: true,
|
||||
can_manage_water_log: true,
|
||||
can_manage_reports: true,
|
||||
can_manage_settings: true,
|
||||
can_manage_billing: true,
|
||||
can_manage_branding: true,
|
||||
can_manage_marketing: true,
|
||||
can_manage_team: true,
|
||||
must_change_password: false,
|
||||
};
|
||||
|
||||
const BRAND_ADMIN = { ...PLATFORM_ADMIN, role: "brand_admin" as const, brand_id: "brand-tux" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000";
|
||||
mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN);
|
||||
mockRequestPasswordReset.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
describe("sendPasswordResetEmail — authorization", () => {
|
||||
it("rejects unauthenticated callers", async () => {
|
||||
mockGetAdminUser.mockResolvedValue(null);
|
||||
const r = await sendPasswordResetEmail("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/not authenticated/i);
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-platform-admin callers", async () => {
|
||||
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
|
||||
const r = await sendPasswordResetEmail("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/only platform admins/i);
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPasswordResetEmail — input handling", () => {
|
||||
it("rejects an empty email without calling Neon Auth", async () => {
|
||||
const r = await sendPasswordResetEmail(" ");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/required/i);
|
||||
expect(mockRequestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims and lowercases the email before calling Neon Auth", async () => {
|
||||
await sendPasswordResetEmail(" User@Example.COM ");
|
||||
expect(mockRequestPasswordReset).toHaveBeenCalledWith({
|
||||
email: "user@example.com",
|
||||
redirectTo: "http://localhost:4000/reset-password",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPasswordResetEmail — happy path", () => {
|
||||
it("returns success when Neon Auth accepts the request", async () => {
|
||||
mockRequestPasswordReset.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
const r = await sendPasswordResetEmail("user@example.com");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPasswordResetEmail — error propagation", () => {
|
||||
it("returns a clear error when Neon Auth returns a structured error", async () => {
|
||||
mockRequestPasswordReset.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "USER_NOT_FOUND", message: "no such user" },
|
||||
});
|
||||
const r = await sendPasswordResetEmail("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/no such user/);
|
||||
});
|
||||
|
||||
it("falls back to the error code if the message is missing", async () => {
|
||||
mockRequestPasswordReset.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "RATE_LIMITED" },
|
||||
});
|
||||
const r = await sendPasswordResetEmail("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/RATE_LIMITED/);
|
||||
});
|
||||
|
||||
it("catches thrown exceptions and surfaces them as a string", async () => {
|
||||
mockRequestPasswordReset.mockRejectedValue(new Error("network down"));
|
||||
const r = await sendPasswordResetEmail("user@example.com");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/network down/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user