diff --git a/src/components/CaptchaCanvas.tsx b/src/components/CaptchaCanvas.tsx new file mode 100644 index 0000000..c7bf700 --- /dev/null +++ b/src/components/CaptchaCanvas.tsx @@ -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 ( + event.preventDefault()} + class={props.class} + /> + ); +} diff --git a/src/routes/login.tsx b/src/routes/login.tsx index e72042c..116924c 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -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() { + +
+ +
+ + + { setCaptchaInput(e.currentTarget.value); clearMessages(); }} + placeholder="Enter answer" + class={inputCls} + /> +
+
{/* Reset mode */}