Files
route-commerce/src/actions/admin/reset-admin.ts
T
Nora 29d9d23a26 fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)
- CartContext: lazy initializers replace mount-only useEffect
  hydration; remove 8 no-initialize-state warnings
- Toast/AdminSearchInput: React 19 useContext/use + drop
  forwardRef (3 no-react19-deprecated-apis)
- ProductFormModal: lazy initializers + useSyncExternalStore
  for mount; parent adds key=editingProduct.id
- InstallPrompt: useReducer for prompt state (no-cascading-set-state)
- QRScanModal: ref-based latest-callback pattern replaces
  useEffectEvent deps mistake
- OnboardingFlow: functional setState (rerender-functional-setstate)
- UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers
  (rerender-lazy-state-init)
- FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic
  in layout; remove supabase import
- LandingPageWrapper: href='#' → href='#top' (anchor-is-valid)
- TuxedoVideoHero: replace animate-bounce with ease-out-expo
  (no-inline-bounce-easing)
- ProductTableClient: useCallback for handleDeleted
  (jsx-no-new-function-as-prop)
- excel-parser: pre-compile delimiter regexes (js-hoist-regexp)
- water-log/settings: Promise.all for parallel DB calls
  (async-parallel)
- ToastNotification: extract toast store to separate file
  (only-export-components)
- WholesaleClient: inline <WholesaleIcon/> instead of hoisting to
  const (rendering-hoist-jsx)
2026-06-26 02:41:56 -06:00

153 lines
4.7 KiB
TypeScript

"use server";
import "server-only";
import { randomBytes } from "crypto";
import { query } from "@/lib/db";
import { serverWarn } from "@/lib/server-log";
import {
requestPasswordReset as neonAuthRequestPasswordReset,
setUserPassword as neonAuthSetUserPassword,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
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> {
await getSession(); // 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";
serverWarn(
"[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) {
serverWarn(
"[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),
};
}
}