Fix admin login: send required captcha, unblocking all admin sign-ins
All checks were successful
build-and-release / build (push) Successful in 57s
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:
parent
280d99da32
commit
47f36a3d69
2 changed files with 152 additions and 2 deletions
93
src/components/CaptchaCanvas.tsx
Normal file
93
src/components/CaptchaCanvas.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 1–2 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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue