Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/admin/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecaptcha } from "@/hooks/use-recaptcha";
import { useToast } from "@/hooks/use-toast";
import { FormInput } from "@/components/registration/form-input";
import { Button } from "@/components/registration/button";
import { Card } from "@/components/registration/card";
import { StickyAlert } from "@/components/registration/sticky-alert";
import { RecaptchaNotice } from "@/components/registration/recaptcha-notice";
import { DotPattern } from "@/components/registration/dot-pattern";
import { Spinner } from "@/components/ui/spinner";
import { UserPlus, LogIn } from "lucide-react";
import Link from "next/link";

export default function AdminRegisterPage() {
const { isAuthenticated, isLoading } = useAuth();
const { executeRecaptcha } = useRecaptcha();
const { toast } = useToast();
const router = useRouter();

Expand Down Expand Up @@ -157,6 +160,7 @@ export default function AdminRegisterPage() {
setAlert(null);

try {
const recaptchaToken = await executeRecaptcha("admin_register");
const response = await fetch('/api/admin/register', {
method: 'POST',
headers: {
Expand All @@ -167,6 +171,7 @@ export default function AdminRegisterPage() {
email: formData.email.trim(),
password: formData.password,
adminCode: formData.adminCode.trim(),
recaptcha_token: recaptchaToken,
}),
});

Expand Down Expand Up @@ -451,6 +456,7 @@ export default function AdminRegisterPage() {
<LogIn className="w-[16px] h-[16px]" />
<span>Login</span>
</Link>
<RecaptchaNotice className="mt-[16px]" />
</div>
</Card>
</div>
Expand Down
17 changes: 16 additions & 1 deletion app/api/admin/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createUserWithEmailAndPassword } from "firebase/auth";
import dbConnect from "@/lib/db";
import User, { IUser } from "@/models/User";
import { getAuth } from "@/lib/firebase-admin";
import { verifyRecaptcha } from "@/lib/recaptcha";

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
Expand All @@ -21,7 +22,21 @@ const validatePassword = (password: string) => {
export async function POST(request: Request) {
try {
const body = await request.json();
const { name, email, password, adminCode } = body;
const { name, email, password, adminCode, recaptcha_token } = body;

// reCAPTCHA v3 background score check — reject likely-bot traffic.
const captcha = await verifyRecaptcha(recaptcha_token, "admin_register");
if (!captcha.ok) {
console.warn("[admin/register] reCAPTCHA rejected:", captcha.reason, captcha.score);
return NextResponse.json(
{
success: false,
message: "Security check failed. Please try again.",
error: { code: "recaptcha_failed", message: "reCAPTCHA verification failed" },
},
{ status: 400 }
);
}

const errors: Record<string, string> = {};

Expand Down
16 changes: 15 additions & 1 deletion app/api/evaluator/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Evaluator from "@/models/Evaluator";
import User from "@/models/User";
import { getAuth } from "@/lib/firebase-admin";
import { checkRateLimit } from "@/lib/rate-limit";
import { verifyRecaptcha } from "@/lib/recaptcha";

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -54,7 +55,20 @@ export async function POST(request: NextRequest) {
const uid = decodedToken.uid;
const email = decodedToken.email || "";
const body = await request.json();
const { name, evaluatorCode } = body;
const { name, evaluatorCode, recaptcha_token } = body;

// reCAPTCHA v3 background score check. The Firebase user was created
// client-side before this call, so clean it up if the check fails.
const captcha = await verifyRecaptcha(recaptcha_token, "evaluator_register");
if (!captcha.ok) {
console.warn("[evaluator/register] reCAPTCHA rejected:", captcha.reason, captcha.score);
try {
await getAuth().deleteUser(uid);
} catch (deleteError) {
console.error(`Failed to delete user ${uid} after reCAPTCHA failure:`, deleteError);
}
return createErrorResponse("Security check failed. Please try again.", "RECAPTCHA_FAILED", 400);
}

if (!name) {
return createErrorResponse("Name is required", "VALIDATION_ERROR", 400);
Expand Down
17 changes: 16 additions & 1 deletion app/api/user/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FirebaseError } from "firebase/app";
import dbConnect from "@/lib/db";
import User from "@/models/User";
import { getAuth } from "@/lib/firebase-admin";
import { verifyRecaptcha } from "@/lib/recaptcha";

const ADMIN_EMAIL_DOMAIN = process.env.ADMIN_EMAIL_DOMAIN;
const SECRET_CODE = process.env.SECRET_CODE;
Expand All @@ -27,7 +28,7 @@ async function authenticateUser(email: string, password: string, isAdminAttempt:

export async function POST(request: Request) {
try {
const { email, password } = await request.json();
const { email, password, recaptcha_token } = await request.json();

if (!email || !password) {
return NextResponse.json(
Expand All @@ -36,6 +37,20 @@ export async function POST(request: Request) {
);
}

// reCAPTCHA v3 background score check — reject likely-bot traffic.
const captcha = await verifyRecaptcha(recaptcha_token, "login");
if (!captcha.ok) {
console.warn("[login] reCAPTCHA rejected:", captcha.reason, captcha.score);
return NextResponse.json(
{
success: false,
message: "Security check failed. Please try again.",
error: { code: "recaptcha_failed", message: "reCAPTCHA verification failed" },
},
{ status: 400 }
);
}

const isAdminAttempt = ADMIN_EMAIL_DOMAIN && SECRET_CODE &&
email.endsWith(ADMIN_EMAIL_DOMAIN) &&
password === SECRET_CODE;
Expand Down
17 changes: 17 additions & 0 deletions app/api/user/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { cloudinaryV2 } from "@/c";
import dbConnect from "@/lib/db";
import User, { IUser } from "@/models/User";
import { verifyRecaptcha } from "@/lib/recaptcha";

// Configure route
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -85,6 +86,22 @@ export async function POST(request: Request) {

const formData = await request.formData();

// reCAPTCHA v3 background score check — reject likely-bot registrations
// before doing any Firebase/Cloudinary/DB work.
const recaptchaToken = formData.get("recaptcha_token") as string | null;
const captcha = await verifyRecaptcha(recaptchaToken, "register");
if (!captcha.ok) {
console.warn("[register] reCAPTCHA rejected:", captcha.reason, captcha.score);
return NextResponse.json(
{
success: false,
message: "Security check failed. Please try again.",
error: { code: "recaptcha_failed", message: "reCAPTCHA verification failed" },
},
{ status: 400 },
);
}

// Helper to convert File to base64
const fileToBase64 = async (file: File): Promise<string> => {
const arrayBuffer = await file.arrayBuffer();
Expand Down
11 changes: 10 additions & 1 deletion app/evaluator/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecaptcha } from "@/hooks/use-recaptcha";
import { FormSection } from "@/components/registration/form-section";
import { FormInput } from "@/components/registration/form-input";
import { Button } from "@/components/registration/button";
import { StickyAlert } from "@/components/registration/sticky-alert";
import { RecaptchaNotice } from "@/components/registration/recaptcha-notice";
import { DotPattern } from "@/components/registration/dot-pattern";
import { Spinner } from "@/components/ui/spinner";
import { ShieldPlus } from "lucide-react";
Expand All @@ -16,6 +18,7 @@ import { auth } from "@/Firebase";

export default function EvaluatorRegisterPage() {
const { isAuthenticated, isLoading, user } = useAuth();
const { executeRecaptcha } = useRecaptcha();
const [formData, setFormData] = useState({
name: "",
email: "",
Expand Down Expand Up @@ -63,6 +66,9 @@ export default function EvaluatorRegisterPage() {
}

try {
// reCAPTCHA v3 background token — scored server-side, no user interaction.
const recaptchaToken = await executeRecaptcha("evaluator_register");

// Create user in Firebase directly to avoid /api/registration requirements
const userCredential = await createUserWithEmailAndPassword(
auth,
Expand All @@ -81,7 +87,8 @@ export default function EvaluatorRegisterPage() {
},
body: JSON.stringify({
name: formData.name,
evaluatorCode: formData.evaluatorCode
evaluatorCode: formData.evaluatorCode,
recaptcha_token: recaptchaToken
})
});

Expand Down Expand Up @@ -219,6 +226,8 @@ export default function EvaluatorRegisterPage() {
← Back to Main Registration
</button>
</div>

<RecaptchaNotice />
</form>
</FormSection>
</div>
Expand Down
6 changes: 6 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,9 @@ input[type="number"] { -moz-appearance: textfield; }
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--border-hairline);
}

/* Hide the floating reCAPTCHA v3 badge. Per Google's terms, the required
disclosure is shown inline on each auth form instead (see RecaptchaNotice). */
.grecaptcha-badge {
visibility: hidden;
}
8 changes: 7 additions & 1 deletion app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecaptcha } from "@/hooks/use-recaptcha";
import { FormSection } from "@/components/registration/form-section";
import { FormInput } from "@/components/registration/form-input";
import { Button } from "@/components/registration/button";
import { StickyAlert } from "@/components/registration/sticky-alert";
import { RecaptchaNotice } from "@/components/registration/recaptcha-notice";
import { DotPattern } from "@/components/registration/dot-pattern";
import { Spinner } from "@/components/ui/spinner";
import { ArrowRight, LogIn } from "lucide-react";

export default function LoginPage() {
const { login, isAuthenticated, isLoading } = useAuth();
const { executeRecaptcha } = useRecaptcha();
const [loginData, setLoginData] = useState({ email: "", password: "" });
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
Expand All @@ -27,7 +30,8 @@ export default function LoginPage() {
setIsSubmitting(true);
setError("");
try {
await login(loginData.email, loginData.password);
const recaptchaToken = await executeRecaptcha("login");
await login(loginData.email, loginData.password, recaptchaToken);
router.push("/dashboard");
} catch (err: any) {
setError(err?.message || "Login failed. Please try again.");
Expand Down Expand Up @@ -116,6 +120,8 @@ export default function LoginPage() {
Register here
</button>
</div>

<RecaptchaNotice className="pt-1" />
</form>
</FormSection>
</div>
Expand Down
6 changes: 3 additions & 3 deletions components/providers/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ interface AuthContextType {
// session — without waiting for /api/user/profile to also come back.
firebaseUser: FirebaseUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
login: (email: string, password: string, recaptchaToken?: string | null) => Promise<void>;
register: (formData: FormData) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
Expand Down Expand Up @@ -155,15 +155,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return () => unsubscribe();
}, []);

const login = useCallback(async (email: string, password: string) => {
const login = useCallback(async (email: string, password: string, recaptchaToken?: string | null) => {
try {
setLoading(true); // Start loading immediately
const response = await fetch(API_ENDPOINTS.login, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, recaptcha_token: recaptchaToken ?? null }),
});

const data = await response.json();
Expand Down
34 changes: 34 additions & 0 deletions components/registration/recaptcha-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Inline reCAPTCHA disclosure.
*
* The floating reCAPTCHA badge is hidden via CSS (see globals.css). Google's
* terms require that, when the badge is hidden, this disclosure is shown to
* users wherever reCAPTCHA is active. Drop this near each auth form's submit.
*/
export function RecaptchaNotice({ className = "" }: { className?: string }) {
return (
<p
className={`text-[11px] leading-[1.5] text-ink-muted text-center ${className}`}
>
This site is protected by reCAPTCHA and the Google{" "}
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-brand/80 hover:text-brand underline underline-offset-2"
>
Privacy Policy
</a>{" "}
and{" "}
<a
href="https://policies.google.com/terms"
target="_blank"
rel="noopener noreferrer"
className="text-brand/80 hover:text-brand underline underline-offset-2"
>
Terms of Service
</a>{" "}
apply.
</p>
);
}
10 changes: 9 additions & 1 deletion components/registration/registration-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecaptcha } from "@/hooks/use-recaptcha";
import { useToast } from "@/hooks/use-toast";
import {
LogIn,
Expand Down Expand Up @@ -32,6 +33,7 @@ import { FormSelect } from "./form-select";
import { FormFileUpload } from "./form-file-upload";
import { FormPhoneInput, isValidPhoneNumber } from "./form-phone-input";
import { FormSection } from "./form-section";
import { RecaptchaNotice } from "./recaptcha-notice";
import { Button } from "./button";
import { Card } from "./card";
import { StickyAlert } from "./sticky-alert";
Expand Down Expand Up @@ -69,6 +71,7 @@ export function RegistrationContainer({
}: RegistrationContainerProps) {
const router = useRouter();
const { register } = useAuth();
const { executeRecaptcha } = useRecaptcha();
const { toast } = useToast();
const [authMode, setAuthMode] = useState<"login" | "register">("register");
const [alert, setAlert] = useState<{
Expand Down Expand Up @@ -590,6 +593,10 @@ export function RegistrationContainer({
if (registerData.referralCode)
formData.append("referral_code", registerData.referralCode);

// reCAPTCHA v3 background token — scored server-side, no user interaction.
const recaptchaToken = await executeRecaptcha("register");
if (recaptchaToken) formData.append("recaptcha_token", recaptchaToken);

await register(formData);

// Clear localStorage on successful registration
Expand Down Expand Up @@ -1592,10 +1599,11 @@ export function RegistrationContainer({
</FormSection>

{/* ===================== FOOTER HINT ===================== */}
<div className="flex flex-wrap items-center justify-center gap-2 text-center">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<div className="font-mono text-[10.5px] uppercase tracking-[0.22em] text-ink-muted">
// data auto-saved locally · safe to refresh
</div>
<RecaptchaNotice />
</div>

{/* ===================== CODE OF CONDUCT MODAL ===================== */}
Expand Down
Loading
Loading