fix(admin-auth): point forgot-password flow at endpoints that actually exist
All checks were successful
build-and-release / build (push) Successful in 48s
All checks were successful
build-and-release / build (push) Successful in 48s
The reset-password flow called /api/auth/internal/forgot-password/request-code and /verify-code with a challengeId-based two-step contract — neither route exists anywhere in the backend, so every reset attempt failed with "Failed to send reset code." The real backend only exposes /api/auth/forgot-password (sends a one-time code, always 200 to avoid account enumeration) and /api/auth/reset-password (consumes the code + new password, no challengeId involved). Rewrites both request/verify handlers to match that contract. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0866b7a069
commit
812fc5ef04
1 changed files with 22 additions and 49 deletions
|
|
@ -6,21 +6,6 @@ import { hasAdminSession, setAdminSession } from '~/lib/admin-session';
|
||||||
type AuthMode = 'login' | 'reset';
|
type AuthMode = 'login' | 'reset';
|
||||||
type ResetStep = 'request' | 'verify';
|
type ResetStep = 'request' | 'verify';
|
||||||
|
|
||||||
function pickChallengeId(payload: any): string {
|
|
||||||
const direct = String(payload?.challengeId || '').trim();
|
|
||||||
if (direct) return direct;
|
|
||||||
const nested = String(payload?.data?.challengeId || '').trim();
|
|
||||||
if (nested) return nested;
|
|
||||||
return String(payload?.challenge_id || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickMaskedEmail(payload: any, fallback: string): string {
|
|
||||||
const direct = String(payload?.maskedEmail || '').trim();
|
|
||||||
if (direct) return direct;
|
|
||||||
const nested = String(payload?.data?.maskedEmail || '').trim();
|
|
||||||
return nested || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Matches public website input exactly */
|
/* Matches public website input exactly */
|
||||||
const inputCls =
|
const inputCls =
|
||||||
'h-11 w-full rounded-xl border border-[#E5E7EB] bg-white px-4 text-sm text-[#111827] outline-none transition placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[#FFE6D9]';
|
'h-11 w-full rounded-xl border border-[#E5E7EB] bg-white px-4 text-sm text-[#111827] outline-none transition placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[#FFE6D9]';
|
||||||
|
|
@ -38,7 +23,6 @@ export default function LoginPage() {
|
||||||
const [resetCode, setResetCode] = createSignal('');
|
const [resetCode, setResetCode] = createSignal('');
|
||||||
const [newPassword, setNewPassword] = createSignal('');
|
const [newPassword, setNewPassword] = createSignal('');
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||||
const [challengeId, setChallengeId] = createSignal('');
|
|
||||||
const [maskedEmail, setMaskedEmail] = createSignal('');
|
const [maskedEmail, setMaskedEmail] = createSignal('');
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
||||||
const [error, setError] = createSignal('');
|
const [error, setError] = createSignal('');
|
||||||
|
|
@ -46,7 +30,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
const canSubmitLogin = createMemo(() => email().trim().length > 0 && password().trim().length > 0);
|
const canSubmitLogin = createMemo(() => email().trim().length > 0 && password().trim().length > 0);
|
||||||
const canSubmitResetRequest = createMemo(() => email().trim().length > 0 && newPassword().trim().length > 0 && confirmPassword().trim().length > 0);
|
const canSubmitResetRequest = createMemo(() => email().trim().length > 0 && newPassword().trim().length > 0 && confirmPassword().trim().length > 0);
|
||||||
const canSubmitResetVerify = createMemo(() => challengeId().trim().length > 0 && resetCode().trim().length === 6);
|
const canSubmitResetVerify = createMemo(() => resetCode().trim().length === 6);
|
||||||
|
|
||||||
const clearMessages = () => { setError(''); setInfo(''); };
|
const clearMessages = () => { setError(''); setInfo(''); };
|
||||||
|
|
||||||
|
|
@ -55,7 +39,7 @@ export default function LoginPage() {
|
||||||
setMode(next);
|
setMode(next);
|
||||||
if (next === 'login') {
|
if (next === 'login') {
|
||||||
setResetStep('request');
|
setResetStep('request');
|
||||||
setResetCode(''); setChallengeId(''); setMaskedEmail('');
|
setResetCode(''); setMaskedEmail('');
|
||||||
setNewPassword(''); setConfirmPassword('');
|
setNewPassword(''); setConfirmPassword('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -105,30 +89,23 @@ export default function LoginPage() {
|
||||||
if (!canSubmitResetRequest()) { setError('All fields are required.'); return; }
|
if (!canSubmitResetRequest()) { setError('All fields are required.'); return; }
|
||||||
if (newPassword() !== confirmPassword()) { setError('Passwords do not match.'); return; }
|
if (newPassword() !== confirmPassword()) { setError('Passwords do not match.'); return; }
|
||||||
const trimmedEmail = email().trim().toLowerCase();
|
const trimmedEmail = email().trim().toLowerCase();
|
||||||
const pw = newPassword().trim();
|
|
||||||
const reqBody = { email: trimmedEmail, newPassword: pw, new_password: pw, password: pw };
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let payload: any = {}; let status = 500;
|
const r = await fetch('/api/auth/forgot-password', {
|
||||||
for (const { url, body } of [
|
method: 'POST',
|
||||||
{ url: '/api/auth/internal/forgot-password/request-code', body: JSON.stringify(reqBody) },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
{ url: '/api/auth/internal/forgot-password/request-code', body: JSON.stringify(reqBody) },
|
body: JSON.stringify({ email: trimmedEmail }),
|
||||||
{ url: '/api/auth/internal/forgot-password/request-code', body: JSON.stringify({ data: reqBody }) },
|
});
|
||||||
{ url: '/api/auth/internal/forgot-password/request-code', body: JSON.stringify({ data: reqBody }) },
|
if (!r.ok) {
|
||||||
]) {
|
const payload = await r.json().catch(() => ({}));
|
||||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body });
|
const fallback = r.status === 502 ? 'Service unavailable (502). Please retry.' : 'Failed to send reset code.';
|
||||||
status = r.status; payload = await r.json().catch(() => ({}));
|
|
||||||
if (r.ok) break;
|
|
||||||
}
|
|
||||||
const cId = pickChallengeId(payload);
|
|
||||||
if (!cId) {
|
|
||||||
const fallback = status === 502 ? 'Service unavailable (502). Please retry.' : 'Failed to send reset code.';
|
|
||||||
throw new Error(String(payload?.message || payload?.error || fallback).trim());
|
throw new Error(String(payload?.message || payload?.error || fallback).trim());
|
||||||
}
|
}
|
||||||
setChallengeId(cId);
|
// The backend intentionally responds 200 whether or not the email exists,
|
||||||
setMaskedEmail(pickMaskedEmail(payload, trimmedEmail));
|
// to avoid account enumeration — there's no challengeId in this API.
|
||||||
|
setMaskedEmail(trimmedEmail);
|
||||||
setResetStep('verify');
|
setResetStep('verify');
|
||||||
setInfo('Reset code sent to your email.');
|
setInfo('If that email has an account, a reset code has been sent.');
|
||||||
} catch (e: any) { setError(String(e?.message || 'Failed to send reset code.')); }
|
} catch (e: any) { setError(String(e?.message || 'Failed to send reset code.')); }
|
||||||
finally { setIsSubmitting(false); }
|
finally { setIsSubmitting(false); }
|
||||||
};
|
};
|
||||||
|
|
@ -137,20 +114,16 @@ export default function LoginPage() {
|
||||||
clearMessages();
|
clearMessages();
|
||||||
if (!canSubmitResetVerify()) { setError('A valid 6-digit code is required.'); return; }
|
if (!canSubmitResetVerify()) { setError('A valid 6-digit code is required.'); return; }
|
||||||
if (newPassword().trim() !== confirmPassword().trim()) { setError('Passwords do not match.'); return; }
|
if (newPassword().trim() !== confirmPassword().trim()) { setError('Passwords do not match.'); return; }
|
||||||
const pw = newPassword().trim();
|
|
||||||
const cId = challengeId().trim();
|
|
||||||
const code = resetCode().trim();
|
|
||||||
const body = JSON.stringify({ challengeId: cId, challenge_id: cId, code, otp: code, verificationCode: code, verification_code: code, newPassword: pw, new_password: pw, password: pw });
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let payload: any = {}; let status = 500; let success = false;
|
const r = await fetch('/api/auth/reset-password', {
|
||||||
for (const url of ['/api/auth/internal/forgot-password/verify-code', '/api/auth/internal/forgot-password/verify-code']) {
|
method: 'POST',
|
||||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body });
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
status = r.status; payload = await r.json().catch(() => ({}));
|
body: JSON.stringify({ code: resetCode().trim(), new_password: newPassword().trim() }),
|
||||||
if (r.ok) { success = true; break; }
|
});
|
||||||
}
|
const payload = await r.json().catch(() => ({}));
|
||||||
if (!success) {
|
if (!r.ok) {
|
||||||
const fallback = status === 502 ? 'Service unavailable (502). Please retry.' : 'Password reset failed.';
|
const fallback = r.status === 502 ? 'Service unavailable (502). Please retry.' : 'Password reset failed.';
|
||||||
throw new Error(String(payload?.message || payload?.error || fallback).trim());
|
throw new Error(String(payload?.message || payload?.error || fallback).trim());
|
||||||
}
|
}
|
||||||
setPassword(''); switchMode('login');
|
setPassword(''); switchMode('login');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue