Fix admin login: send required captcha, unblocking all admin sign-ins
All checks were successful
build-and-release / build (push) Successful in 57s

Backend commit fdd5c7a added a mandatory captcha to
/api/admin/auth/login, but this login page was never updated to
collect one — every admin login has been failing with a 422
("missing field captcha_id") since that change shipped. Add the same
challenge/canvas/verify flow the public site already uses (challenge
from /api/auth/captcha, verified server-side via shared Redis).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-27 19:30:16 +05:30
parent 280d99da32
commit 47f36a3d69
2 changed files with 152 additions and 2 deletions

View file

@ -0,0 +1,93 @@
import { createEffect, onMount } from 'solid-js';
type CaptchaCanvasProps = {
/** Human-readable challenge text from the server, e.g. "7 + 12 = ?" */
challenge: string;
class?: string;
};
export default function CaptchaCanvas(props: CaptchaCanvasProps) {
let canvasRef: HTMLCanvasElement | undefined;
const drawCaptcha = () => {
const canvas = canvasRef;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = 176;
const height = 52;
// Set canvas resolution (fixed at 1x to prevent zoom issues)
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(1, 0, 0, 1, 0, 0);
// Clear and fill background
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Draw decorative lines (within bounds)
for (let i = 0; i < 2; i += 1) {
ctx.strokeStyle = i % 2 === 0 ? 'rgba(253,98,22,0.16)' : 'rgba(27,36,64,0.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * width, Math.random() * height);
ctx.lineTo(Math.random() * width, Math.random() * height);
ctx.stroke();
}
// Draw decorative circles (within bounds)
for (let i = 0; i < 3; i += 1) {
ctx.fillStyle = i % 2 === 0 ? 'rgba(253,98,22,0.10)' : 'rgba(27,36,64,0.09)';
ctx.beginPath();
ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 1.8 + 0.6, 0, Math.PI * 2);
ctx.fill();
}
const text = String(props.challenge || '').trim();
if (!text) return;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
// Shrink the font until the (variable-length) challenge text fits.
const paddingX = 10;
const maxWidth = width - paddingX * 2;
let fontSize = 20;
const minFontSize = 10;
ctx.font = `800 ${fontSize}px "Courier New", monospace`;
while (fontSize > minFontSize && ctx.measureText(text).width > maxWidth) {
fontSize -= 1;
ctx.font = `800 ${fontSize}px "Courier New", monospace`;
}
ctx.fillStyle = '#0f172a';
ctx.fillText(text, width / 2, height / 2, maxWidth);
};
onMount(() => {
drawCaptcha();
});
createEffect(() => {
// Access props.challenge to track it and redraw when it changes
const _ = props.challenge;
drawCaptcha();
});
return (
<canvas
ref={canvasRef}
width={176}
height={52}
aria-label="Captcha challenge"
draggable={false}
onContextMenu={(event) => event.preventDefault()}
class={props.class}
/>
);
}

View file

@ -1,6 +1,7 @@
import { useNavigate } from '@solidjs/router';
import { Show, createMemo, createSignal } from 'solid-js';
import { Show, createMemo, createSignal, onMount } from 'solid-js';
import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth';
import CaptchaCanvas from '~/components/CaptchaCanvas';
type AuthMode = 'login' | 'reset';
type ResetStep = 'request' | 'verify';
@ -26,6 +27,9 @@ export default function LoginPage() {
const [isSubmitting, setIsSubmitting] = createSignal(false);
const [error, setError] = createSignal('');
const [info, setInfo] = createSignal('');
const [captchaId, setCaptchaId] = createSignal('');
const [captchaChallenge, setCaptchaChallenge] = createSignal('');
const [captchaInput, setCaptchaInput] = createSignal('');
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);
@ -33,6 +37,26 @@ export default function LoginPage() {
const clearMessages = () => { setError(''); setInfo(''); };
// The employees service (which backs /api/admin/auth/login) requires the
// same server-side captcha challenge the public site uses — generated by
// the users service at /api/auth/captcha and verified against shared Redis.
const loadCaptcha = async () => {
setCaptchaInput('');
try {
const r = await fetch('/api/auth/captcha', { method: 'POST', headers: { Accept: 'application/json' }, credentials: 'include' });
const payload = await r.json().catch(() => ({}));
setCaptchaId(String(payload?.captcha_id || ''));
setCaptchaChallenge(String(payload?.challenge || ''));
} catch {
setCaptchaId('');
setCaptchaChallenge('');
}
};
onMount(() => {
void loadCaptcha();
});
const switchMode = (next: AuthMode) => {
clearMessages();
setMode(next);
@ -65,15 +89,26 @@ export default function LoginPage() {
const directSignIn = async () => {
clearMessages();
if (!canSubmitLogin()) { setError('Email and password are required.'); return; }
if (!captchaInput().trim()) { setError('Enter the captcha answer to continue.'); return; }
setIsSubmitting(true);
try {
const body = JSON.stringify({ email: email().trim().toLowerCase(), password: password(), loginTarget: 'admin' });
const body = JSON.stringify({
email: email().trim().toLowerCase(),
password: password(),
loginTarget: 'admin',
captcha_id: captchaId(),
captcha_answer: captchaInput().trim(),
});
const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'x-portal-target': 'admin' };
let payload: any = {}; let status = 500; let success = false;
const r = await fetch('/api/admin/auth/login', { method: 'POST', headers, credentials: 'include', body });
status = r.status; payload = await r.json().catch(() => ({}));
if (r.ok) { success = true; }
if (!success) {
if (String(payload?.code || '').toUpperCase() === 'CAPTCHA_FAILED') {
void loadCaptcha();
throw new Error(String(payload?.error || 'Invalid or expired captcha. Please try again.'));
}
const fallback = status === 502 ? 'Auth service unavailable (502). Please retry in 12 minutes.' : 'Sign in failed.';
throw new Error(pickManagementLoginError(payload) || fallback);
}
@ -252,6 +287,28 @@ export default function LoginPage() {
</button>
</div>
</div>
<div>
<label class={labelCls}>Captcha</label>
<div class="flex items-center gap-2">
<button
type="button"
aria-label="Refresh captcha"
onClick={() => void loadCaptcha()}
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white text-[#5b6480] transition hover:text-[#1b2440]"
>
</button>
<CaptchaCanvas challenge={captchaChallenge()} class="h-11 shrink-0 rounded-xl border border-[#E5E7EB]" />
<input
type="text"
value={captchaInput()}
onInput={(e) => { setCaptchaInput(e.currentTarget.value); clearMessages(); }}
placeholder="Enter answer"
class={inputCls}
/>
</div>
</div>
</Show>
{/* Reset mode */}