Fix security audit finding: replace fake client-side captcha
All checks were successful
build-and-release / build (push) Successful in 2m5s
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:
parent
070c4bdab1
commit
7a0799f1d1
11 changed files with 1266 additions and 1435 deletions
2297
package-lock.json
generated
2297
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -56,5 +56,19 @@
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { createEffect, onMount } from 'solid-js';
|
import { createEffect, onMount } from 'solid-js';
|
||||||
|
|
||||||
type CaptchaCanvasProps = {
|
type CaptchaCanvasProps = {
|
||||||
code: string;
|
/** Human-readable challenge text from the server, e.g. "7 + 12 = ?" */
|
||||||
|
challenge: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -14,11 +15,6 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Expose captcha code for automated testing
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.__captchaCode = props.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = 176;
|
const width = 176;
|
||||||
const height = 52;
|
const height = 52;
|
||||||
|
|
||||||
|
|
@ -52,26 +48,25 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw characters (fixed positioning)
|
const text = String(props.challenge || '').trim();
|
||||||
const chars = String(props.code || '').slice(0, 6).split('');
|
if (!text) return;
|
||||||
const startX = 16;
|
|
||||||
const charGap = 24;
|
|
||||||
|
|
||||||
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.textBaseline = 'middle';
|
||||||
ctx.font = `800 22px "Courier New", monospace`;
|
ctx.textAlign = 'center';
|
||||||
ctx.fillStyle = index % 2 === 0 ? '#0f172a' : '#c2410c';
|
|
||||||
ctx.lineWidth = 0;
|
// Shrink the font until the (variable-length) challenge text fits.
|
||||||
ctx.fillText(char, 0, 0);
|
const paddingX = 10;
|
||||||
ctx.restore();
|
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(() => {
|
onMount(() => {
|
||||||
|
|
@ -79,8 +74,8 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// Access props.code to track it and redraw when it changes
|
// Access props.challenge to track it and redraw when it changes
|
||||||
const _ = props.code;
|
const _ = props.challenge;
|
||||||
drawCaptcha();
|
drawCaptcha();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -89,7 +84,7 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) {
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={176}
|
width={176}
|
||||||
height={52}
|
height={52}
|
||||||
aria-label="Captcha image"
|
aria-label="Captcha challenge"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
|
|
|
||||||
1
src/global.d.ts
vendored
1
src/global.d.ts
vendored
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__captchaCode?: string;
|
|
||||||
__testMode?: boolean;
|
__testMode?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,21 @@ export async function request<T = any>(
|
||||||
return { data: data as T, status: res.status, headers: res.headers };
|
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> {
|
export async function fetchProfile(rolePrefix: string): Promise<any> {
|
||||||
return apiFetch(`/api/${rolePrefix}/profile/me`);
|
return apiFetch(`/api/${rolePrefix}/profile/me`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,21 +78,15 @@ export function isPasswordStrong(checks: PasswordChecks): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate CAPTCHA input
|
* Validate that a CAPTCHA answer was entered.
|
||||||
* @param input - User's captcha input
|
|
||||||
* @param expected - Expected captcha value
|
|
||||||
* @returns true if captcha matches (case-insensitive)
|
|
||||||
*
|
*
|
||||||
* DEV NOTE: In development mode, CAPTCHA validation is bypassed to enable
|
* NOTE: The captcha challenge itself (arithmetic question) is generated and
|
||||||
* automated testing and local development without dealing with the visual CAPTCHA.
|
* verified server-side (POST /api/auth/captcha, then captcha_id +
|
||||||
* The __captchaCode global is still exposed on the canvas for manual testing.
|
* 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 {
|
export function isValidCaptcha(input: string): boolean {
|
||||||
// Bypass captcha in development for easier testing
|
return input.trim().length > 0;
|
||||||
if (typeof import.meta !== 'undefined' && import.meta.env?.DEV) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return input.trim().toUpperCase() === expected.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -265,7 +259,6 @@ export interface RegisterFormData {
|
||||||
password: string;
|
password: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
captcha: string;
|
captcha: string;
|
||||||
expectedCaptcha: string;
|
|
||||||
termsAccepted: boolean;
|
termsAccepted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,11 +306,9 @@ export function validateRegisterForm(formData: RegisterFormData): ValidationResu
|
||||||
errors.confirmPassword = getValidationErrorMessage('confirmPassword', 'noMatch');
|
errors.confirmPassword = getValidationErrorMessage('confirmPassword', 'noMatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate captcha
|
// Validate captcha — correctness is verified server-side; only require an answer.
|
||||||
if (!formData.captcha) {
|
if (!isValidCaptcha(formData.captcha)) {
|
||||||
errors.captcha = getValidationErrorMessage('captcha', 'required');
|
errors.captcha = getValidationErrorMessage('captcha', 'required');
|
||||||
} else if (!isValidCaptcha(formData.captcha, formData.expectedCaptcha)) {
|
|
||||||
errors.captcha = getValidationErrorMessage('captcha', 'invalid');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate terms acceptance
|
// Validate terms acceptance
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
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 { useAuth } from "~/lib/auth";
|
||||||
import PublicBackground from "~/components/PublicBackground";
|
import PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from "~/components/PublicHeader";
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
||||||
import { isValidEmail } from "~/lib/form-validation";
|
import { isValidEmail } from "~/lib/form-validation";
|
||||||
|
import { fetchCaptcha } from "~/lib/api";
|
||||||
|
|
||||||
type RoleKey = "company" | "job_seeker" | "professional" | "customer";
|
type RoleKey = "company" | "job_seeker" | "professional" | "customer";
|
||||||
|
|
||||||
|
|
@ -76,11 +77,6 @@ function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCaptcha() {
|
|
||||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasswordVisibilityIcon(props: { visible: boolean }) {
|
function PasswordVisibilityIcon(props: { visible: boolean }) {
|
||||||
if (props.visible) {
|
if (props.visible) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -108,7 +104,8 @@ export default function LoginRoute() {
|
||||||
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||||||
const [showVerify, setShowVerify] = createSignal(false);
|
const [showVerify, setShowVerify] = createSignal(false);
|
||||||
const [showPassword, setShowPassword] = 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 [captchaInput, setCaptchaInput] = createSignal("");
|
||||||
const [error, setError] = createSignal("");
|
const [error, setError] = createSignal("");
|
||||||
const [submitting, setSubmitting] = createSignal(false);
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
|
|
@ -118,6 +115,22 @@ export default function LoginRoute() {
|
||||||
|
|
||||||
const otpCode = createMemo(() => otp().join(""));
|
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 =>
|
const formatRoleLabel = (value: string): string =>
|
||||||
String(value || "")
|
String(value || "")
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -233,12 +246,8 @@ export default function LoginRoute() {
|
||||||
setError("Password is required.");
|
setError("Password is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// DEV bypass: skip CAPTCHA validation in development
|
if (!captchaInput().trim()) {
|
||||||
const isDev = typeof import.meta !== 'undefined' && import.meta.env?.DEV;
|
setError("Enter the captcha answer to continue.");
|
||||||
if (!isDev && (!captchaInput().trim() || captchaInput().trim().toUpperCase() !== captcha().toUpperCase())) {
|
|
||||||
setError("Captcha does not match. Please try again.");
|
|
||||||
setCaptcha(makeCaptcha());
|
|
||||||
setCaptchaInput("");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
@ -250,6 +259,8 @@ export default function LoginRoute() {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: email().trim().toLowerCase(),
|
email: email().trim().toLowerCase(),
|
||||||
password: password(),
|
password: password(),
|
||||||
|
captcha_id: captchaId(),
|
||||||
|
captcha_answer: captchaInput().trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
@ -260,6 +271,11 @@ export default function LoginRoute() {
|
||||||
setError("Email not verified. Enter OTP sent to your inbox.");
|
setError("Email not verified. Enter OTP sent to your inbox.");
|
||||||
return;
|
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."));
|
setError(String(data?.error || data?.message || "Invalid login credentials."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -427,7 +443,6 @@ export default function LoginRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.__captchaCode = captcha();
|
|
||||||
(window as any).__loginVerifyOtp = verifyThenLogin;
|
(window as any).__loginVerifyOtp = verifyThenLogin;
|
||||||
(window as any).__setLoginOtp = setOtp;
|
(window as any).__setLoginOtp = setOtp;
|
||||||
(window as any).__loginOtp = otp;
|
(window as any).__loginOtp = otp;
|
||||||
|
|
@ -518,21 +533,17 @@ export default function LoginRoute() {
|
||||||
<button
|
<button
|
||||||
class="auth-captcha-refresh"
|
class="auth-captcha-refresh"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
aria-label="Refresh captcha"
|
||||||
const newCaptcha = makeCaptcha();
|
onClick={() => void loadCaptcha()}
|
||||||
setCaptcha(newCaptcha);
|
|
||||||
window.__captchaCode = newCaptcha;
|
|
||||||
setCaptchaInput("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
|
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
value={captchaInput()}
|
value={captchaInput()}
|
||||||
onInput={(e) => setCaptchaInput(e.currentTarget.value)}
|
onInput={(e) => setCaptchaInput(e.currentTarget.value)}
|
||||||
placeholder="Enter captcha"
|
placeholder="Enter answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { createMemo, createSignal, For, onMount, Show } from "solid-js";
|
||||||
import PublicBackground from "~/components/PublicBackground";
|
import PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from "~/components/PublicHeader";
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
||||||
|
import { fetchCaptcha } from "~/lib/api";
|
||||||
import {
|
import {
|
||||||
checkPasswordStrength,
|
checkPasswordStrength,
|
||||||
isPasswordStrong,
|
isPasswordStrong,
|
||||||
isValidCaptcha,
|
isValidCaptcha,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
isValidName,
|
isValidName,
|
||||||
validateRegisterForm,
|
|
||||||
} from "~/lib/form-validation";
|
} from "~/lib/form-validation";
|
||||||
|
|
||||||
export default function CompanySignupRoute() {
|
export default function CompanySignupRoute() {
|
||||||
|
|
@ -22,7 +22,8 @@ export default function CompanySignupRoute() {
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal("");
|
const [confirmPassword, setConfirmPassword] = createSignal("");
|
||||||
const [companyName, setCompanyName] = createSignal("");
|
const [companyName, setCompanyName] = createSignal("");
|
||||||
const [captcha, setCaptcha] = createSignal("");
|
const [captcha, setCaptcha] = createSignal("");
|
||||||
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
|
const [captchaId, setCaptchaId] = createSignal("");
|
||||||
|
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
|
||||||
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||||||
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
||||||
const [serverError, setServerError] = createSignal("");
|
const [serverError, setServerError] = createSignal("");
|
||||||
|
|
@ -48,23 +49,26 @@ export default function CompanySignupRoute() {
|
||||||
isValidEmail(email()) &&
|
isValidEmail(email()) &&
|
||||||
isPasswordStrong(passwordChecks()) &&
|
isPasswordStrong(passwordChecks()) &&
|
||||||
passwordChecks().match &&
|
passwordChecks().match &&
|
||||||
isValidCaptcha(captcha(), captchaCode())
|
isValidCaptcha(captcha())
|
||||||
);
|
);
|
||||||
|
|
||||||
function randomCaptcha(length = 6): string {
|
const refreshCaptcha = async () => {
|
||||||
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 = () => {
|
|
||||||
setCaptcha("");
|
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 checkEmailExists = async (emailValue: string) => {
|
||||||
const normalized = emailValue.trim().toLowerCase();
|
const normalized = emailValue.trim().toLowerCase();
|
||||||
if (!normalized || !isValidEmail(normalized)) {
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
|
@ -147,7 +151,7 @@ export default function CompanySignupRoute() {
|
||||||
if (!isPasswordStrong(checks)) {
|
if (!isPasswordStrong(checks)) {
|
||||||
validationErrors.password = "Password must be strong";
|
validationErrors.password = "Password must be strong";
|
||||||
}
|
}
|
||||||
if (!isValidCaptcha(captcha(), captchaCode())) {
|
if (!isValidCaptcha(captcha())) {
|
||||||
validationErrors.captcha = "Enter captcha to continue";
|
validationErrors.captcha = "Enter captcha to continue";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,13 +173,21 @@ export default function CompanySignupRoute() {
|
||||||
intent: "company",
|
intent: "company",
|
||||||
company_name: companyName().trim(),
|
company_name: companyName().trim(),
|
||||||
role_key: "COMPANY",
|
role_key: "COMPANY",
|
||||||
|
captcha_id: captchaId(),
|
||||||
|
captcha_answer: captcha().trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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."));
|
setServerError(String(data?.error || data?.message || "Unable to create account."));
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +202,7 @@ export default function CompanySignupRoute() {
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerError("Network error — please check your connection and try again.");
|
setServerError("Network error — please check your connection and try again.");
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -521,27 +533,27 @@ export default function CompanySignupRoute() {
|
||||||
type="button"
|
type="button"
|
||||||
class="auth-captcha-refresh"
|
class="auth-captcha-refresh"
|
||||||
aria-label="Refresh captcha"
|
aria-label="Refresh captcha"
|
||||||
onClick={refreshCaptcha}
|
onClick={() => void refreshCaptcha()}
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
|
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
|
||||||
<input
|
<input
|
||||||
id="captcha"
|
id="captcha"
|
||||||
class="input"
|
class="input"
|
||||||
value={captcha()}
|
value={captcha()}
|
||||||
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
|
onInput={(e) => setCaptcha(e.currentTarget.value)}
|
||||||
placeholder="Enter captcha"
|
placeholder="Enter answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
class="validation-note"
|
class="validation-note"
|
||||||
style={{
|
style={{
|
||||||
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
|
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{captcha() && captcha().toUpperCase() === captchaCode()
|
{isValidCaptcha(captcha())
|
||||||
? "✓ Captcha matched"
|
? "✓ Answer entered"
|
||||||
: "• Enter captcha to continue"}
|
: "• Enter captcha to continue"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
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 PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from "~/components/PublicHeader";
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
||||||
|
import { fetchCaptcha } from "~/lib/api";
|
||||||
import {
|
import {
|
||||||
checkPasswordStrength,
|
checkPasswordStrength,
|
||||||
isPasswordStrong,
|
isPasswordStrong,
|
||||||
|
|
@ -21,7 +22,8 @@ export default function CustomerSignupRoute() {
|
||||||
const [password, setPassword] = createSignal("");
|
const [password, setPassword] = createSignal("");
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal("");
|
const [confirmPassword, setConfirmPassword] = createSignal("");
|
||||||
const [captcha, setCaptcha] = createSignal("");
|
const [captcha, setCaptcha] = createSignal("");
|
||||||
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
|
const [captchaId, setCaptchaId] = createSignal("");
|
||||||
|
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
|
||||||
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||||||
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
||||||
const [serverError, setServerError] = createSignal("");
|
const [serverError, setServerError] = createSignal("");
|
||||||
|
|
@ -47,23 +49,26 @@ export default function CustomerSignupRoute() {
|
||||||
isValidEmail(email()) &&
|
isValidEmail(email()) &&
|
||||||
isPasswordStrong(passwordChecks()) &&
|
isPasswordStrong(passwordChecks()) &&
|
||||||
passwordChecks().match &&
|
passwordChecks().match &&
|
||||||
isValidCaptcha(captcha(), captchaCode())
|
isValidCaptcha(captcha())
|
||||||
);
|
);
|
||||||
|
|
||||||
function randomCaptcha(length = 6): string {
|
const refreshCaptcha = async () => {
|
||||||
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 = () => {
|
|
||||||
setCaptcha("");
|
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 checkEmailExists = async (emailValue: string) => {
|
||||||
const normalized = emailValue.trim().toLowerCase();
|
const normalized = emailValue.trim().toLowerCase();
|
||||||
if (!normalized || !isValidEmail(normalized)) {
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
|
@ -145,7 +150,7 @@ export default function CustomerSignupRoute() {
|
||||||
if (!isPasswordStrong(checks)) {
|
if (!isPasswordStrong(checks)) {
|
||||||
validationErrors.password = "Password must be strong";
|
validationErrors.password = "Password must be strong";
|
||||||
}
|
}
|
||||||
if (!isValidCaptcha(captcha(), captchaCode())) {
|
if (!isValidCaptcha(captcha())) {
|
||||||
validationErrors.captcha = "Enter captcha to continue";
|
validationErrors.captcha = "Enter captcha to continue";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,13 +171,21 @@ export default function CustomerSignupRoute() {
|
||||||
phone: "",
|
phone: "",
|
||||||
intent: "customer",
|
intent: "customer",
|
||||||
role_key: "CUSTOMER",
|
role_key: "CUSTOMER",
|
||||||
|
captcha_id: captchaId(),
|
||||||
|
captcha_answer: captcha().trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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."));
|
setServerError(String(data?.error || data?.message || "Unable to create account."));
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +200,7 @@ export default function CustomerSignupRoute() {
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerError("Network error — please check your connection and try again.");
|
setServerError("Network error — please check your connection and try again.");
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -519,27 +532,27 @@ export default function CustomerSignupRoute() {
|
||||||
type="button"
|
type="button"
|
||||||
class="auth-captcha-refresh"
|
class="auth-captcha-refresh"
|
||||||
aria-label="Refresh captcha"
|
aria-label="Refresh captcha"
|
||||||
onClick={refreshCaptcha}
|
onClick={() => void refreshCaptcha()}
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
|
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
|
||||||
<input
|
<input
|
||||||
id="captcha"
|
id="captcha"
|
||||||
class="input"
|
class="input"
|
||||||
value={captcha()}
|
value={captcha()}
|
||||||
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
|
onInput={(e) => setCaptcha(e.currentTarget.value)}
|
||||||
placeholder="Enter captcha"
|
placeholder="Enter answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
class="validation-note"
|
class="validation-note"
|
||||||
style={{
|
style={{
|
||||||
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
|
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{captcha() && captcha().toUpperCase() === captchaCode()
|
{isValidCaptcha(captcha())
|
||||||
? "✓ Captcha matched"
|
? "✓ Answer entered"
|
||||||
: "• Enter captcha to continue"}
|
: "• Enter captcha to continue"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createMemo, createSignal, For, onMount, Show } from "solid-js";
|
||||||
import PublicBackground from "~/components/PublicBackground";
|
import PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from "~/components/PublicHeader";
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
||||||
|
import { fetchCaptcha } from "~/lib/api";
|
||||||
import {
|
import {
|
||||||
checkPasswordStrength,
|
checkPasswordStrength,
|
||||||
isPasswordStrong,
|
isPasswordStrong,
|
||||||
|
|
@ -22,7 +23,8 @@ export default function JobSeekerSignupRoute() {
|
||||||
const [password, setPassword] = createSignal("");
|
const [password, setPassword] = createSignal("");
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal("");
|
const [confirmPassword, setConfirmPassword] = createSignal("");
|
||||||
const [captcha, setCaptcha] = createSignal("");
|
const [captcha, setCaptcha] = createSignal("");
|
||||||
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
|
const [captchaId, setCaptchaId] = createSignal("");
|
||||||
|
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
|
||||||
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||||||
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
||||||
const [serverError, setServerError] = createSignal("");
|
const [serverError, setServerError] = createSignal("");
|
||||||
|
|
@ -48,23 +50,26 @@ export default function JobSeekerSignupRoute() {
|
||||||
isValidEmail(email()) &&
|
isValidEmail(email()) &&
|
||||||
isPasswordStrong(passwordChecks()) &&
|
isPasswordStrong(passwordChecks()) &&
|
||||||
passwordChecks().match &&
|
passwordChecks().match &&
|
||||||
isValidCaptcha(captcha(), captchaCode())
|
isValidCaptcha(captcha())
|
||||||
);
|
);
|
||||||
|
|
||||||
function randomCaptcha(length = 6): string {
|
const refreshCaptcha = async () => {
|
||||||
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 = () => {
|
|
||||||
setCaptcha("");
|
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 checkEmailExists = async (emailValue: string) => {
|
||||||
const normalized = emailValue.trim().toLowerCase();
|
const normalized = emailValue.trim().toLowerCase();
|
||||||
if (!normalized || !isValidEmail(normalized)) {
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
|
@ -136,7 +141,6 @@ export default function JobSeekerSignupRoute() {
|
||||||
password: password(),
|
password: password(),
|
||||||
confirmPassword: confirmPassword(),
|
confirmPassword: confirmPassword(),
|
||||||
captcha: captcha(),
|
captcha: captcha(),
|
||||||
expectedCaptcha: captchaCode(),
|
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
});
|
});
|
||||||
setErrors(validation.errors);
|
setErrors(validation.errors);
|
||||||
|
|
@ -156,13 +160,21 @@ export default function JobSeekerSignupRoute() {
|
||||||
phone: "",
|
phone: "",
|
||||||
intent: "job_seeker",
|
intent: "job_seeker",
|
||||||
role_key: "JOB_SEEKER",
|
role_key: "JOB_SEEKER",
|
||||||
|
captcha_id: captchaId(),
|
||||||
|
captcha_answer: captcha().trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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."));
|
setServerError(String(data?.error || data?.message || "Unable to create account."));
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +189,7 @@ export default function JobSeekerSignupRoute() {
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerError("Network error — please check your connection and try again.");
|
setServerError("Network error — please check your connection and try again.");
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -509,27 +521,27 @@ export default function JobSeekerSignupRoute() {
|
||||||
type="button"
|
type="button"
|
||||||
class="auth-captcha-refresh"
|
class="auth-captcha-refresh"
|
||||||
aria-label="Refresh captcha"
|
aria-label="Refresh captcha"
|
||||||
onClick={refreshCaptcha}
|
onClick={() => void refreshCaptcha()}
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
|
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
|
||||||
<input
|
<input
|
||||||
id="captcha"
|
id="captcha"
|
||||||
class="input"
|
class="input"
|
||||||
value={captcha()}
|
value={captcha()}
|
||||||
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
|
onInput={(e) => setCaptcha(e.currentTarget.value)}
|
||||||
placeholder="Enter captcha"
|
placeholder="Enter answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
class="validation-note"
|
class="validation-note"
|
||||||
style={{
|
style={{
|
||||||
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
|
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{captcha() && captcha().toUpperCase() === captchaCode()
|
{isValidCaptcha(captcha())
|
||||||
? "✓ Captcha matched"
|
? "✓ Answer entered"
|
||||||
: "• Enter captcha to continue"}
|
: "• Enter captcha to continue"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createMemo, createSignal, For, onMount, Show } from "solid-js";
|
||||||
import PublicBackground from "~/components/PublicBackground";
|
import PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from "~/components/PublicHeader";
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
||||||
|
import { fetchCaptcha } from "~/lib/api";
|
||||||
import {
|
import {
|
||||||
checkPasswordStrength,
|
checkPasswordStrength,
|
||||||
isPasswordStrong,
|
isPasswordStrong,
|
||||||
|
|
@ -23,7 +24,8 @@ export default function ProfessionalSignupRoute() {
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal("");
|
const [confirmPassword, setConfirmPassword] = createSignal("");
|
||||||
const [profession, setProfession] = createSignal("");
|
const [profession, setProfession] = createSignal("");
|
||||||
const [captcha, setCaptcha] = createSignal("");
|
const [captcha, setCaptcha] = createSignal("");
|
||||||
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
|
const [captchaId, setCaptchaId] = createSignal("");
|
||||||
|
const [captchaChallenge, setCaptchaChallenge] = createSignal("");
|
||||||
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||||||
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
||||||
const [serverError, setServerError] = createSignal("");
|
const [serverError, setServerError] = createSignal("");
|
||||||
|
|
@ -50,7 +52,7 @@ export default function ProfessionalSignupRoute() {
|
||||||
isValidEmail(email()) &&
|
isValidEmail(email()) &&
|
||||||
isPasswordStrong(passwordChecks()) &&
|
isPasswordStrong(passwordChecks()) &&
|
||||||
passwordChecks().match &&
|
passwordChecks().match &&
|
||||||
isValidCaptcha(captcha(), captchaCode())
|
isValidCaptcha(captcha())
|
||||||
);
|
);
|
||||||
|
|
||||||
const professions = [
|
const professions = [
|
||||||
|
|
@ -66,20 +68,23 @@ export default function ProfessionalSignupRoute() {
|
||||||
{ value: "UGC_CONTENT_CREATOR", label: "UGC Content Creator" },
|
{ value: "UGC_CONTENT_CREATOR", label: "UGC Content Creator" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function randomCaptcha(length = 6): string {
|
const refreshCaptcha = async () => {
|
||||||
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 = () => {
|
|
||||||
setCaptcha("");
|
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 checkEmailExists = async (emailValue: string) => {
|
||||||
const normalized = emailValue.trim().toLowerCase();
|
const normalized = emailValue.trim().toLowerCase();
|
||||||
if (!normalized || !isValidEmail(normalized)) {
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
|
@ -153,7 +158,6 @@ export default function ProfessionalSignupRoute() {
|
||||||
password: password(),
|
password: password(),
|
||||||
confirmPassword: confirmPassword(),
|
confirmPassword: confirmPassword(),
|
||||||
captcha: captcha(),
|
captcha: captcha(),
|
||||||
expectedCaptcha: captchaCode(),
|
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
});
|
});
|
||||||
setErrors(validation.errors);
|
setErrors(validation.errors);
|
||||||
|
|
@ -173,13 +177,21 @@ export default function ProfessionalSignupRoute() {
|
||||||
phone: "",
|
phone: "",
|
||||||
intent: "professional",
|
intent: "professional",
|
||||||
profession: profession().toUpperCase(),
|
profession: profession().toUpperCase(),
|
||||||
|
captcha_id: captchaId(),
|
||||||
|
captcha_answer: captcha().trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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."));
|
setServerError(String(data?.error || data?.message || "Unable to create account."));
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +207,7 @@ export default function ProfessionalSignupRoute() {
|
||||||
setStep("verify");
|
setStep("verify");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerError("Network error — please check your connection and try again.");
|
setServerError("Network error — please check your connection and try again.");
|
||||||
refreshCaptcha();
|
void refreshCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -549,27 +561,27 @@ export default function ProfessionalSignupRoute() {
|
||||||
type="button"
|
type="button"
|
||||||
class="auth-captcha-refresh"
|
class="auth-captcha-refresh"
|
||||||
aria-label="Refresh captcha"
|
aria-label="Refresh captcha"
|
||||||
onClick={refreshCaptcha}
|
onClick={() => void refreshCaptcha()}
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
|
<CaptchaCanvas challenge={captchaChallenge()} class="auth-captcha-canvas" />
|
||||||
<input
|
<input
|
||||||
id="captcha"
|
id="captcha"
|
||||||
class="input"
|
class="input"
|
||||||
value={captcha()}
|
value={captcha()}
|
||||||
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
|
onInput={(e) => setCaptcha(e.currentTarget.value)}
|
||||||
placeholder="Enter captcha"
|
placeholder="Enter answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
class="validation-note"
|
class="validation-note"
|
||||||
style={{
|
style={{
|
||||||
color: captcha() && captcha().toUpperCase() === captchaCode() ? "#fd6116" : "#6e7591",
|
color: isValidCaptcha(captcha()) ? "#fd6116" : "#6e7591",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{captcha() && captcha().toUpperCase() === captchaCode()
|
{isValidCaptcha(captcha())
|
||||||
? "✓ Captcha matched"
|
? "✓ Answer entered"
|
||||||
: "• Enter captcha to continue"}
|
: "• Enter captcha to continue"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue