feat: Cloudflare Turnstile (register + contact) + captcha altyapısı

- Turnstile widget bileşeni (public site key gömülü, VITE_TURNSTILE_SITE_KEY ile override)
- register: signUp.email'e x-captcha-response header'ı
- contact: token body'de; ContactService Cloudflare siteverify ile doğrular
  (TURNSTILE_SECRET_KEY yoksa atlanır), contact.dto'ya turnstileToken
- @sase/config: TURNSTILE_SECRET_KEY env (opsiyonel)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 02:14:06 +03:00
parent 1c79c18aff
commit a09182fb98
6 changed files with 173 additions and 4 deletions

View File

@@ -1,3 +1,4 @@
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { api } from "@/lib/api-client";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
@@ -9,7 +10,7 @@ import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Check, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
export const Route = createFileRoute("/_auth/register")({
component: RegisterPage,
@@ -28,6 +29,8 @@ function RegisterPage() {
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showRefCode, setShowRefCode] = useState(!!ref);
const [captchaToken, setCaptchaToken] = useState("");
const turnstileRef = useRef<TurnstileHandle>(null);
const passwordRules = [
{ ok: password.length >= 8, label: "8+ karakter" },
{ ok: /[A-Z]/.test(password), label: "1 büyük harf" },
@@ -51,9 +54,20 @@ function RegisterPage() {
// better-auth returns { error } rather than throwing (see login.tsx).
// The old code awaited without checking error, so failed signups still
// reported success, fired user_signed_up, and redirected to the dashboard.
const { error } = await signUp.email({ name, email, password, callbackURL: redirectUrl });
const { error } = await signUp.email({
name,
email,
password,
callbackURL: redirectUrl,
fetchOptions: captchaToken
? { headers: { "x-captcha-response": captchaToken } }
: undefined,
});
if (error) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
let exists = false;
try {
({ exists } = await api.post<{ exists: boolean }>("/users/check-email", { email }));
@@ -230,6 +244,12 @@ function RegisterPage() {
Referans kodun var mı?
</button>
)}
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : "Ücretsiz Başla"}
</Button>

View File

@@ -1,11 +1,12 @@
import { SiteHeader } from "@/components/site-header";
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { type FormEvent, useState } from "react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
export const Route = createFileRoute("/contact")({
@@ -31,7 +32,9 @@ function ContactForm() {
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -48,7 +51,7 @@ function ContactForm() {
setErrors({});
setLoading(true);
try {
await api.post("/contact", parsed.data);
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
@@ -56,7 +59,12 @@ function ContactForm() {
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
@@ -127,6 +135,12 @@ function ContactForm() {
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>