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