Fix admin password reset (Send Reset Email + Reset Password)
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:
Tyler
2026-06-17 12:22:34 -06:00
parent 7e665ea43e
commit eb37df347e
5 changed files with 608 additions and 32 deletions
+123 -16
View File
@@ -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
View File
@@ -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 }> {