eb37df347e
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).
150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
"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; method: "set" }
|
|
| { success: true; method: "reset_link_sent"; message: string }
|
|
| { success: false; error: string };
|
|
|
|
/**
|
|
* Emergency recovery action — resets the password for the specified
|
|
* email so the platform admin can hand a working credential to the
|
|
* user.
|
|
*
|
|
* 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,
|
|
): Promise<ResetAdminPasswordResult> {
|
|
// 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 neon_auth.user WHERE email = $1 LIMIT 1`,
|
|
[normalizedEmail],
|
|
);
|
|
const user = rows[0];
|
|
if (!user) {
|
|
return {
|
|
success: false,
|
|
error: `No Neon Auth user found for ${normalizedEmail}.`,
|
|
};
|
|
}
|
|
|
|
// 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 {
|
|
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 : String(err),
|
|
};
|
|
}
|
|
}
|