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
+39 -5
View File
@@ -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>