Fix security audit finding: replace fake client-side captcha
All checks were successful
build-and-release / build (push) Successful in 2m5s

The captcha on login and all four signup forms was generated and
checked entirely in the browser (answer readable via window global),
so it provided no real bot/brute-force protection. Wire up the new
server-side captcha endpoint instead: fetch a challenge on mount,
submit captcha_id + captcha_answer with login/register, and refresh
the challenge on CAPTCHA_FAILED.

Also bump patchable dependency vulnerabilities via npm audit fix
(all criticals resolved; remainder needs an upstream SolidStart/vinxi
bump not yet available).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-23 17:32:43 +05:30
parent 070c4bdab1
commit 7a0799f1d1
11 changed files with 1266 additions and 1435 deletions

2297
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -56,5 +56,19 @@
},
"engines": {
"node": ">=20"
},
"overrides": {
"tar": "^7.5.21",
"node-forge": "^1.4.0",
"serialize-javascript": "^7.0.7",
"defu": "^6.1.7",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"lodash": "^4.18.1",
"nitropack": "^2.13.4",
"postcss": "^8.5.10",
"@babel/core": "^7.29.7",
"esbuild": "^0.28.1",
"shell-quote": "^1.10.0"
}
}

View file

@ -1,7 +1,8 @@
import { createEffect, onMount } from 'solid-js';
type CaptchaCanvasProps = {
code: string;
/** Human-readable challenge text from the server, e.g. "7 + 12 = ?" */
challenge: string;
class?: string;
};
@ -14,11 +15,6 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Expose captcha code for automated testing
if (typeof window !== 'undefined') {
window.__captchaCode = props.code;
}
const width = 176;
const height = 52;
@ -52,26 +48,25 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
ctx.fill();
}
// Draw characters (fixed positioning)
const chars = String(props.code || '').slice(0, 6).split('');
const startX = 16;
const charGap = 24;
const text = String(props.challenge || '').trim();
if (!text) return;
chars.forEach((char, index) => {
const x = startX + index * charGap;
const y = height / 2;
const rotation = 0;
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.textBaseline = 'middle';
ctx.font = `800 22px "Courier New", monospace`;
ctx.fillStyle = index % 2 === 0 ? '#0f172a' : '#c2410c';
ctx.lineWidth = 0;
ctx.fillText(char, 0, 0);
ctx.restore();
});
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(() => {
@ -79,8 +74,8 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
});
createEffect(() => {
// Access props.code to track it and redraw when it changes
const _ = props.code;
// Access props.challenge to track it and redraw when it changes
const _ = props.challenge;
drawCaptcha();
});
@ -89,7 +84,7 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
ref={canvasRef}
width={176}
height={52}
aria-label="Captcha image"
aria-label="Captcha challenge"
draggable={false}
onContextMenu={(event) => event.preventDefault()}
class={props.class}

1
src/global.d.ts vendored
View file

@ -3,7 +3,6 @@
declare global {
interface Window {
__captchaCode?: string;
__testMode?: boolean;
}
}

View file

@ -59,6 +59,21 @@ export async function request<T = any>(
return { data: data as T, status: res.status, headers: res.headers };
}
export type CaptchaChallenge = {
captcha_id: string;
challenge: string;
};
/**
* Request a new server-side captcha challenge.
* Single-use and expires after 5 minutes callers must fetch a fresh one
* whenever the previous captcha_id was consumed or the submit failed with
* CAPTCHA_FAILED.
*/
export async function fetchCaptcha(): Promise<CaptchaChallenge> {
return apiFetch('/api/auth/captcha', { method: 'POST' });
}
export async function fetchProfile(rolePrefix: string): Promise<any> {
return apiFetch(`/api/${rolePrefix}/profile/me`);
}

View file

@ -78,21 +78,15 @@ export function isPasswordStrong(checks: PasswordChecks): boolean {
}
/**
* Validate CAPTCHA input
* @param input - User's captcha input
* @param expected - Expected captcha value
* @returns true if captcha matches (case-insensitive)
* Validate that a CAPTCHA answer was entered.
*
* DEV NOTE: In development mode, CAPTCHA validation is bypassed to enable
* automated testing and local development without dealing with the visual CAPTCHA.
* The __captchaCode global is still exposed on the canvas for manual testing.
* NOTE: The captcha challenge itself (arithmetic question) is generated and
* verified server-side (POST /api/auth/captcha, then captcha_id +
* captcha_answer sent with register/login). The client can no longer check
* correctness it only guards against submitting with an empty answer.
*/
export function isValidCaptcha(input: string, expected: string): boolean {
// Bypass captcha in development for easier testing
if (typeof import.meta !== 'undefined' && import.meta.env?.DEV) {
return true;
}
return input.trim().toUpperCase() === expected.toUpperCase();
export function isValidCaptcha(input: string): boolean {
return input.trim().length > 0;
}
/**
@ -265,7 +259,6 @@ export interface RegisterFormData {
password: string;
confirmPassword: string;
captcha: string;
expectedCaptcha: string;
termsAccepted: boolean;
}
@ -313,11 +306,9 @@ export function validateRegisterForm(formData: RegisterFormData): ValidationResu
errors.confirmPassword = getValidationErrorMessage('confirmPassword', 'noMatch');
}
// Validate captcha
if (!formData.captcha) {
// Validate captcha — correctness is verified server-side; only require an answer.
if (!isValidCaptcha(formData.captcha)) {
errors.captcha = getValidationErrorMessage('captcha', 'required');
} else if (!isValidCaptcha(formData.captcha, formData.expectedCaptcha)) {
errors.captcha = getValidationErrorMessage('captcha', 'invalid');
}
// Validate terms acceptance

View file

@ -1,10 +1,11 @@
import { A, useNavigate } from "@solidjs/router";
import { createMemo, createSignal, For, Show } from "solid-js";
import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import { useAuth } from "~/lib/auth";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from "~/components/CaptchaCanvas";
import { isValidEmail } from "~/lib/form-validation";
import { fetchCaptcha } from "~/lib/api";
type RoleKey = "company" | "job_seeker" | "professional" | "customer";
@ -76,11 +77,6 @@ function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string
return "";
}
function makeCaptcha() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
}
function PasswordVisibilityIcon(props: { visible: boolean }) {
if (props.visible) {
return (
@ -108,7 +104,8 @@ export default function LoginRoute() {
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [showVerify, setShowVerify] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false);
const [captcha, setCaptcha] = createSignal(makeCaptcha());
const [captchaId, setCaptchaId] = createSignal("");
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
const [captchaInput, setCaptchaInput] = createSignal("");
const [error, setError] = createSignal("");
const [submitting, setSubmitting] = createSignal(false);
@ -118,6 +115,22 @@ export default function LoginRoute() {
const otpCode = createMemo(() => otp().join(""));
const loadCaptcha = async () => {
setCaptchaInput("");
try {
const challenge = await fetchCaptcha();
setCaptchaId(challenge.captcha_id);
setCaptchaChallenge(challenge.challenge);
} catch {
setCaptchaId("");
setCaptchaChallenge("");
}
};
onMount(() => {
void loadCaptcha();
});
const formatRoleLabel = (value: string): string =>
String(value || "")
.trim()
@ -233,12 +246,8 @@ export default function LoginRoute() {
setError("Password is required.");
return;
}
// DEV bypass: skip CAPTCHA validation in development
const isDev = typeof import.meta !== 'undefined' && import.meta.env?.DEV;
if (!isDev && (!captchaInput().trim() || captchaInput().trim().toUpperCase() !== captcha().toUpperCase())) {
setError("Captcha does not match. Please try again.");
setCaptcha(makeCaptcha());
setCaptchaInput("");
if (!captchaInput().trim()) {
setError("Enter the captcha answer to continue.");
return;
}
setSubmitting(true);
@ -250,6 +259,8 @@ export default function LoginRoute() {
body: JSON.stringify({
email: email().trim().toLowerCase(),
password: password(),
captcha_id: captchaId(),
captcha_answer: captchaInput().trim(),
}),
});
const data = await res.json().catch(() => ({}));
@ -260,6 +271,11 @@ export default function LoginRoute() {
setError("Email not verified. Enter OTP sent to your inbox.");
return;
}
if (code === "CAPTCHA_FAILED") {
setError(String(data?.error || "Invalid or expired captcha. Please try again."));
void loadCaptcha();
return;
}
setError(String(data?.error || data?.message || "Invalid login credentials."));
return;
}
@ -427,7 +443,6 @@ export default function LoginRoute() {
};
if (typeof window !== 'undefined') {
window.__captchaCode = captcha();
(window as any).__loginVerifyOtp = verifyThenLogin;
(window as any).__setLoginOtp = setOtp;
(window as any).__loginOtp = otp;
@ -518,21 +533,17 @@ export default function LoginRoute() {
<button
class="auth-captcha-refresh"
type="button"
onClick={() => {
const newCaptcha = makeCaptcha();
setCaptcha(newCaptcha);
window.__captchaCode = newCaptcha;
setCaptchaInput("");
}}
aria-label="Refresh captcha"
onClick={() => void loadCaptcha()}
>
</button>
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
<input
class="input"
value={captchaInput()}
onInput={(e) => setCaptchaInput(e.currentTarget.value)}
placeholder="Enter captcha"
placeholder="Enter answer"
/>
</div>
</div>

View file

@ -3,13 +3,13 @@ import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from "~/components/CaptchaCanvas";
import { fetchCaptcha } from "~/lib/api";
import {
checkPasswordStrength,
isPasswordStrong,
isValidCaptcha,
isValidEmail,
isValidName,
validateRegisterForm,
} from "~/lib/form-validation";
export default function CompanySignupRoute() {
@ -22,7 +22,8 @@ export default function CompanySignupRoute() {
const [confirmPassword, setConfirmPassword] = createSignal("");
const [companyName, setCompanyName] = createSignal("");
const [captcha, setCaptcha] = createSignal("");
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [captchaId, setCaptchaId] = createSignal("");
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [serverError, setServerError] = createSignal("");
@ -48,23 +49,26 @@ export default function CompanySignupRoute() {
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode())
isValidCaptcha(captcha())
);
function randomCaptcha(length = 6): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
const refreshCaptcha = () => {
const refreshCaptcha = async () => {
setCaptcha("");
setCaptchaCode(randomCaptcha());
setCaptchaId("");
setCaptchaChallenge("");
try {
const challenge = await fetchCaptcha();
setCaptchaId(challenge.captcha_id);
setCaptchaChallenge(challenge.challenge);
} catch {
// Leave captcha blank; user can retry via the refresh button.
}
};
onMount(() => {
void refreshCaptcha();
});
const checkEmailExists = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
@ -147,7 +151,7 @@ export default function CompanySignupRoute() {
if (!isPasswordStrong(checks)) {
validationErrors.password = "Password must be strong";
}
if (!isValidCaptcha(captcha(), captchaCode())) {
if (!isValidCaptcha(captcha())) {
validationErrors.captcha = "Enter captcha to continue";
}
@ -169,13 +173,21 @@ export default function CompanySignupRoute() {
intent: "company",
company_name: companyName().trim(),
role_key: "COMPANY",
captcha_id: captchaId(),
captcha_answer: captcha().trim(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const code = String(data?.code || "").toUpperCase();
if (code === "CAPTCHA_FAILED") {
setServerError(String(data?.error || "Invalid or expired captcha. Please try again."));
void refreshCaptcha();
return;
}
setServerError(String(data?.error || data?.message || "Unable to create account."));
refreshCaptcha();
void refreshCaptcha();
return;
}
@ -190,7 +202,7 @@ export default function CompanySignupRoute() {
setStep("verify");
} catch (err) {
setServerError("Network error — please check your connection and try again.");
refreshCaptcha();
void refreshCaptcha();
} finally {
setSubmitting(false);
}
@ -521,27 +533,27 @@ export default function CompanySignupRoute() {
type="button"
class="auth-captcha-refresh"
aria-label="Refresh captcha"
onClick={refreshCaptcha}
onClick={() => void refreshCaptcha()}
>
</button>
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
<input
id="captcha"
class="input"
value={captcha()}
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
placeholder="Enter captcha"
onInput={(e) => setCaptcha(e.currentTarget.value)}
placeholder="Enter answer"
/>
</div>
<p
class="validation-note"
style={{
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
}}
>
{captcha() && captcha().toUpperCase() === captchaCode()
? "✓ Captcha matched"
{isValidCaptcha(captcha())
? "✓ Answer entered"
: "• Enter captcha to continue"}
</p>
</div>

View file

@ -1,8 +1,9 @@
import { A, useNavigate } from "@solidjs/router";
import { createMemo, createSignal, For, Show } from "solid-js";
import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from "~/components/CaptchaCanvas";
import { fetchCaptcha } from "~/lib/api";
import {
checkPasswordStrength,
isPasswordStrong,
@ -21,7 +22,8 @@ export default function CustomerSignupRoute() {
const [password, setPassword] = createSignal("");
const [confirmPassword, setConfirmPassword] = createSignal("");
const [captcha, setCaptcha] = createSignal("");
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [captchaId, setCaptchaId] = createSignal("");
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [serverError, setServerError] = createSignal("");
@ -47,23 +49,26 @@ export default function CustomerSignupRoute() {
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode())
isValidCaptcha(captcha())
);
function randomCaptcha(length = 6): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
const refreshCaptcha = () => {
const refreshCaptcha = async () => {
setCaptcha("");
setCaptchaCode(randomCaptcha());
setCaptchaId("");
setCaptchaChallenge("");
try {
const challenge = await fetchCaptcha();
setCaptchaId(challenge.captcha_id);
setCaptchaChallenge(challenge.challenge);
} catch {
// Leave captcha blank; user can retry via the refresh button.
}
};
onMount(() => {
void refreshCaptcha();
});
const checkEmailExists = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
@ -145,7 +150,7 @@ export default function CustomerSignupRoute() {
if (!isPasswordStrong(checks)) {
validationErrors.password = "Password must be strong";
}
if (!isValidCaptcha(captcha(), captchaCode())) {
if (!isValidCaptcha(captcha())) {
validationErrors.captcha = "Enter captcha to continue";
}
@ -166,13 +171,21 @@ export default function CustomerSignupRoute() {
phone: "",
intent: "customer",
role_key: "CUSTOMER",
captcha_id: captchaId(),
captcha_answer: captcha().trim(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const code = String(data?.code || "").toUpperCase();
if (code === "CAPTCHA_FAILED") {
setServerError(String(data?.error || "Invalid or expired captcha. Please try again."));
void refreshCaptcha();
return;
}
setServerError(String(data?.error || data?.message || "Unable to create account."));
refreshCaptcha();
void refreshCaptcha();
return;
}
@ -187,7 +200,7 @@ export default function CustomerSignupRoute() {
setStep("verify");
} catch (err) {
setServerError("Network error — please check your connection and try again.");
refreshCaptcha();
void refreshCaptcha();
} finally {
setSubmitting(false);
}
@ -519,27 +532,27 @@ export default function CustomerSignupRoute() {
type="button"
class="auth-captcha-refresh"
aria-label="Refresh captcha"
onClick={refreshCaptcha}
onClick={() => void refreshCaptcha()}
>
</button>
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
<input
id="captcha"
class="input"
value={captcha()}
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
placeholder="Enter captcha"
onInput={(e) => setCaptcha(e.currentTarget.value)}
placeholder="Enter answer"
/>
</div>
<p
class="validation-note"
style={{
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
}}
>
{captcha() && captcha().toUpperCase() === captchaCode()
? "✓ Captcha matched"
{isValidCaptcha(captcha())
? "✓ Answer entered"
: "• Enter captcha to continue"}
</p>
</div>

View file

@ -3,6 +3,7 @@ import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from "~/components/CaptchaCanvas";
import { fetchCaptcha } from "~/lib/api";
import {
checkPasswordStrength,
isPasswordStrong,
@ -22,7 +23,8 @@ export default function JobSeekerSignupRoute() {
const [password, setPassword] = createSignal("");
const [confirmPassword, setConfirmPassword] = createSignal("");
const [captcha, setCaptcha] = createSignal("");
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [captchaId, setCaptchaId] = createSignal("");
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [serverError, setServerError] = createSignal("");
@ -48,23 +50,26 @@ export default function JobSeekerSignupRoute() {
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode())
isValidCaptcha(captcha())
);
function randomCaptcha(length = 6): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
const refreshCaptcha = () => {
const refreshCaptcha = async () => {
setCaptcha("");
setCaptchaCode(randomCaptcha());
setCaptchaId("");
setCaptchaChallenge("");
try {
const challenge = await fetchCaptcha();
setCaptchaId(challenge.captcha_id);
setCaptchaChallenge(challenge.challenge);
} catch {
// Leave captcha blank; user can retry via the refresh button.
}
};
onMount(() => {
void refreshCaptcha();
});
const checkEmailExists = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
@ -136,7 +141,6 @@ export default function JobSeekerSignupRoute() {
password: password(),
confirmPassword: confirmPassword(),
captcha: captcha(),
expectedCaptcha: captchaCode(),
termsAccepted: true,
});
setErrors(validation.errors);
@ -156,13 +160,21 @@ export default function JobSeekerSignupRoute() {
phone: "",
intent: "job_seeker",
role_key: "JOB_SEEKER",
captcha_id: captchaId(),
captcha_answer: captcha().trim(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const code = String(data?.code || "").toUpperCase();
if (code === "CAPTCHA_FAILED") {
setServerError(String(data?.error || "Invalid or expired captcha. Please try again."));
void refreshCaptcha();
return;
}
setServerError(String(data?.error || data?.message || "Unable to create account."));
refreshCaptcha();
void refreshCaptcha();
return;
}
@ -177,7 +189,7 @@ export default function JobSeekerSignupRoute() {
setStep("verify");
} catch (err) {
setServerError("Network error — please check your connection and try again.");
refreshCaptcha();
void refreshCaptcha();
} finally {
setSubmitting(false);
}
@ -509,27 +521,27 @@ export default function JobSeekerSignupRoute() {
type="button"
class="auth-captcha-refresh"
aria-label="Refresh captcha"
onClick={refreshCaptcha}
onClick={() => void refreshCaptcha()}
>
</button>
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
<input
id="captcha"
class="input"
value={captcha()}
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
placeholder="Enter captcha"
onInput={(e) => setCaptcha(e.currentTarget.value)}
placeholder="Enter answer"
/>
</div>
<p
class="validation-note"
style={{
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
}}
>
{captcha() && captcha().toUpperCase() === captchaCode()
? "✓ Captcha matched"
{isValidCaptcha(captcha())
? "✓ Answer entered"
: "• Enter captcha to continue"}
</p>
</div>

View file

@ -3,6 +3,7 @@ import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from "~/components/CaptchaCanvas";
import { fetchCaptcha } from "~/lib/api";
import {
checkPasswordStrength,
isPasswordStrong,
@ -23,7 +24,8 @@ export default function ProfessionalSignupRoute() {
const [confirmPassword, setConfirmPassword] = createSignal("");
const [profession, setProfession] = createSignal("");
const [captcha, setCaptcha] = createSignal("");
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [captchaId, setCaptchaId] = createSignal("");
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [serverError, setServerError] = createSignal("");
@ -50,7 +52,7 @@ export default function ProfessionalSignupRoute() {
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode())
isValidCaptcha(captcha())
);
const professions = [
@ -66,20 +68,23 @@ export default function ProfessionalSignupRoute() {
{ value: "UGC_CONTENT_CREATOR", label: "UGC Content Creator" },
];
function randomCaptcha(length = 6): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
const refreshCaptcha = () => {
const refreshCaptcha = async () => {
setCaptcha("");
setCaptchaCode(randomCaptcha());
setCaptchaId("");
setCaptchaChallenge("");
try {
const challenge = await fetchCaptcha();
setCaptchaId(challenge.captcha_id);
setCaptchaChallenge(challenge.challenge);
} catch {
// Leave captcha blank; user can retry via the refresh button.
}
};
onMount(() => {
void refreshCaptcha();
});
const checkEmailExists = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
@ -153,7 +158,6 @@ export default function ProfessionalSignupRoute() {
password: password(),
confirmPassword: confirmPassword(),
captcha: captcha(),
expectedCaptcha: captchaCode(),
termsAccepted: true,
});
setErrors(validation.errors);
@ -173,13 +177,21 @@ export default function ProfessionalSignupRoute() {
phone: "",
intent: "professional",
profession: profession().toUpperCase(),
captcha_id: captchaId(),
captcha_answer: captcha().trim(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const code = String(data?.code || "").toUpperCase();
if (code === "CAPTCHA_FAILED") {
setServerError(String(data?.error || "Invalid or expired captcha. Please try again."));
void refreshCaptcha();
return;
}
setServerError(String(data?.error || data?.message || "Unable to create account."));
refreshCaptcha();
void refreshCaptcha();
return;
}
@ -195,7 +207,7 @@ export default function ProfessionalSignupRoute() {
setStep("verify");
} catch (err) {
setServerError("Network error — please check your connection and try again.");
refreshCaptcha();
void refreshCaptcha();
} finally {
setSubmitting(false);
}
@ -549,27 +561,27 @@ export default function ProfessionalSignupRoute() {
type="button"
class="auth-captcha-refresh"
aria-label="Refresh captcha"
onClick={refreshCaptcha}
onClick={() => void refreshCaptcha()}
>
</button>
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
<input
id="captcha"
class="input"
value={captcha()}
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
placeholder="Enter captcha"
onInput={(e) => setCaptcha(e.currentTarget.value)}
placeholder="Enter answer"
/>
</div>
<p
class="validation-note"
style={{
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
}}
>
{captcha() && captcha().toUpperCase() === captchaCode()
? "✓ Captcha matched"
{isValidCaptcha(captcha())
? "✓ Answer entered"
: "• Enter captcha to continue"}
</p>
</div>