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:
+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 }> {
|
||||
|
||||
Reference in New Issue
Block a user