dev #58
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { generateReferralCode } from "@sase/shared";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { captcha } from "better-auth/plugins";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
@@ -165,6 +166,9 @@ export function createAuth(
|
||||
},
|
||||
},
|
||||
session: {
|
||||
// "Beni hatırla" işaretliyse oturum 30 gün korunur; işaretli değilse
|
||||
// better-auth çerezi oturum çerezi yapar (tarayıcı kapanınca silinir).
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 gün
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5, // 5 minutes
|
||||
@@ -198,6 +202,19 @@ export function createAuth(
|
||||
...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
||||
"http://localhost:4000",
|
||||
],
|
||||
plugins: [
|
||||
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
|
||||
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
|
||||
...(process.env.TURNSTILE_SECRET_KEY
|
||||
? [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY,
|
||||
endpoints: ["/sign-in/email", "/sign-up/email"],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
return authInstance;
|
||||
|
||||
@@ -5,6 +5,8 @@ export const contactMessageSchema = z.object({
|
||||
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
|
||||
subject: z.string().trim().max(200, "Konu çok uzun").optional().default(""),
|
||||
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
|
||||
// Cloudflare Turnstile token'ı (captcha aktifse zorunlu, sunucuda doğrulanır)
|
||||
turnstileToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ContactMessage = z.infer<typeof contactMessageSchema>;
|
||||
|
||||
@@ -25,6 +25,11 @@ export class ContactService {
|
||||
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz form verisi");
|
||||
}
|
||||
|
||||
const ok = await this.verifyTurnstile(parsed.data.turnstileToken);
|
||||
if (!ok) {
|
||||
throw new BadRequestException("Doğrulama başarısız, lütfen tekrar deneyin.");
|
||||
}
|
||||
|
||||
const { name, email, message } = parsed.data;
|
||||
const subject = parsed.data.subject?.trim() || "Yeni mesaj";
|
||||
|
||||
@@ -47,4 +52,23 @@ export class ContactService {
|
||||
this.logger.log(`Contact form submitted by ${email}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Cloudflare Turnstile doğrulaması. Secret tanımlı değilse atlanır (true döner).
|
||||
private async verifyTurnstile(token: string | undefined): Promise<boolean> {
|
||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||
if (!secret) return true;
|
||||
if (!token) return false;
|
||||
try {
|
||||
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ secret, response: token }),
|
||||
});
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data.success === true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Turnstile verification failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ async function bootstrap() {
|
||||
"'self'",
|
||||
"https://t.sase.tr",
|
||||
"https://connect.facebook.net",
|
||||
"https://challenges.cloudflare.com",
|
||||
"'sha256-T5FzBQBMINFjZ4WLy58SeZ+J7xXzjnQEGlg618CQnhA='",
|
||||
],
|
||||
styleSrc: ["'self'", "https:", "'unsafe-inline'"],
|
||||
@@ -45,9 +46,10 @@ async function bootstrap() {
|
||||
"https://t.sase.tr",
|
||||
"https://www.facebook.com",
|
||||
"https://connect.facebook.net",
|
||||
"https://challenges.cloudflare.com",
|
||||
],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
frameSrc: ["https://challenges.cloudflare.com"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
106
apps/web/src/components/turnstile.tsx
Normal file
106
apps/web/src/components/turnstile.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
|
||||
// Cloudflare Turnstile site key (public). sase.tr/dev.sase.tr/localhost domainleri
|
||||
// için kayıtlı. Gerekirse VITE_TURNSTILE_SITE_KEY ile override edilebilir.
|
||||
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || "0x4AAAAAADW2whBc2Nj9nJk3";
|
||||
const SCRIPT_ID = "cf-turnstile-script";
|
||||
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
|
||||
interface TurnstileApi {
|
||||
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id: string) => void;
|
||||
remove: (id: string) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi;
|
||||
}
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
|
||||
function loadTurnstile(): Promise<void> {
|
||||
if (typeof window === "undefined") return Promise.resolve();
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.getElementById(SCRIPT_ID);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve());
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Turnstile yüklenemedi"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
export interface TurnstileHandle {
|
||||
/** Widget'ı sıfırlar — token tek kullanımlık olduğu için başarısız submit sonrası çağrılır. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface TurnstileProps {
|
||||
onVerify: (token: string) => void;
|
||||
onExpire?: () => void;
|
||||
theme?: "light" | "dark" | "auto";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile widget'ı. Çözüm tamamlanınca `onVerify(token)` çağrılır;
|
||||
* token form gönderiminde sunucuya iletilir ve orada doğrulanır.
|
||||
*/
|
||||
export const Turnstile = forwardRef<TurnstileHandle, TurnstileProps>(function Turnstile(
|
||||
{ onVerify, onExpire, theme = "auto", className },
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const callbacksRef = useRef({ onVerify, onExpire });
|
||||
callbacksRef.current = { onVerify, onExpire };
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadTurnstile()
|
||||
.then(() => {
|
||||
if (cancelled || !containerRef.current || !window.turnstile || widgetIdRef.current) return;
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: SITE_KEY,
|
||||
theme,
|
||||
callback: (token: string) => callbacksRef.current.onVerify(token),
|
||||
"expired-callback": () => callbacksRef.current.onExpire?.(),
|
||||
"error-callback": () => callbacksRef.current.onExpire?.(),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
} catch {}
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return <div ref={containerRef} className={className} />;
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
@@ -7,7 +8,7 @@ import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/login")({
|
||||
component: LoginPage,
|
||||
@@ -16,16 +17,27 @@ export const Route = createFileRoute("/_auth/login")({
|
||||
function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const turnstileRef = useRef<TurnstileHandle>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
startAction("login", { method: "email" });
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await signIn.email({ email, password });
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
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("");
|
||||
try {
|
||||
const { exists } = await api.post<{ exists: boolean }>("/users/check-email", { email });
|
||||
if (!exists) {
|
||||
@@ -123,15 +135,27 @@ function LoginPage() {
|
||||
|
||||
{/* Remember me + Forgot password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="size-4 rounded border-input accent-primary" />
|
||||
Beni hatırla
|
||||
<label htmlFor="rememberMe" className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
Beni 30 gün hatırla
|
||||
</label>
|
||||
<Link to="/forgot-password" className="text-sm text-muted-foreground hover:underline">
|
||||
Şifremi Unuttum
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
onVerify={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken("")}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -119,7 +127,7 @@ function ContactForm() {
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Bize nasıl yardımcı olabiliriz?"
|
||||
placeholder="Size nasıl yardımcı olabiliriz?"
|
||||
aria-invalid={!!errors.message}
|
||||
disabled={loading}
|
||||
className={textareaClass}
|
||||
@@ -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 ? (
|
||||
<>
|
||||
@@ -146,7 +160,7 @@ function ContactForm() {
|
||||
function ContactPage() {
|
||||
usePageMeta({
|
||||
title: "İletişim — Sase.tr",
|
||||
description: "Sase.tr destek ve iletişim. info@sase.tr",
|
||||
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
|
||||
canonical: "https://sase.tr/contact",
|
||||
});
|
||||
|
||||
@@ -168,20 +182,6 @@ function ContactPage() {
|
||||
|
||||
{/* İletişim bilgileri */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">E-posta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Genel sorular ve destek talepleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
|
||||
@@ -16,6 +16,9 @@ export const envSchema = z.object({
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
// Cloudflare Turnstile (captcha) — tanımlı değilse captcha atlanır
|
||||
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||||
|
||||
MINIO_ENDPOINT: z.string(),
|
||||
MINIO_ACCESS_KEY: z.string(),
|
||||
MINIO_SECRET_KEY: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user