turnstile is working - keep SSL turned of on dev
This commit is contained in:
@@ -54,14 +54,11 @@ DEFAULT_FROM_EMAIL=
|
|||||||
USE_S3=False
|
USE_S3=False
|
||||||
|
|
||||||
# RustFS (docker-compose default — S3-compatible MinIO successor)
|
# RustFS (docker-compose default — S3-compatible MinIO successor)
|
||||||
AWS_S3_ENDPOINT_URL=http://rustfs:9000
|
AWS_S3_ENDPOINT_URL=https://s3.vontor.cz
|
||||||
AWS_S3_CUSTOM_DOMAIN=localhost:9000/vontor
|
AWS_S3_CUSTOM_DOMAIN=s3.vontor.cz
|
||||||
AWS_STORAGE_BUCKET_NAME=vontor
|
AWS_STORAGE_BUCKET_NAME=vontor-cz
|
||||||
AWS_ACCESS_KEY_ID=rustfsadmin
|
AWS_ACCESS_KEY_ID=pO70oxXGV4R6OSHxNmzv
|
||||||
AWS_SECRET_ACCESS_KEY=rustfsadmin
|
AWS_SECRET_ACCESS_KEY=1gY19XzWBOWiIkDKvCQF8Xkc72mFX4iILkBBV0ML
|
||||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
|
||||||
RUSTFS_SECRET_KEY=rustfsadmin
|
|
||||||
RUSTFS_BUCKET_NAME=vontor
|
|
||||||
|
|
||||||
# AWS S3 (swap in for production — clear AWS_S3_ENDPOINT_URL)
|
# AWS S3 (swap in for production — clear AWS_S3_ENDPOINT_URL)
|
||||||
# AWS_STORAGE_BUCKET_NAME=my-bucket
|
# AWS_STORAGE_BUCKET_NAME=my-bucket
|
||||||
@@ -80,4 +77,7 @@ RUSTFS_BUCKET_NAME=vontor
|
|||||||
# -- ČSOB API --
|
# -- ČSOB API --
|
||||||
|
|
||||||
CSOB_MERCHANT_ID=A6680stogb
|
CSOB_MERCHANT_ID=A6680stogb
|
||||||
CSOB_API_URL=https://iapi.iplatebnibrana.csob.cz/ # PŘI PRODUKCI VYMĚNIT ZA REALNE API!!!!
|
CSOB_API_URL=https://iapi.iplatebnibrana.csob.cz/ # PŘI PRODUKCI VYMĚNIT ZA REALNE API!!!!
|
||||||
|
|
||||||
|
# -- TURNSTILE CAPTCHA --
|
||||||
|
SECRET_KEY_TURNSTILE=xxx
|
||||||
@@ -25,6 +25,7 @@ from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
||||||
|
from vontor_cz.turnstile import verify_turnstile
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -52,6 +53,11 @@ class CookieTokenObtainPairView(TokenObtainPairView):
|
|||||||
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
turnstile_token = request.data.get("turnstile_token", "")
|
||||||
|
remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", ""))
|
||||||
|
if not verify_turnstile(turnstile_token, remote_ip):
|
||||||
|
return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
try:
|
try:
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -310,6 +316,11 @@ class UserRegistrationViewSet(ModelViewSet):
|
|||||||
http_method_names = ['post']
|
http_method_names = ['post']
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
turnstile_token = request.data.get("turnstile_token", "")
|
||||||
|
remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", ""))
|
||||||
|
if not verify_turnstile(turnstile_token, remote_ip):
|
||||||
|
return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view
|
|||||||
from .models import ContactMe
|
from .models import ContactMe
|
||||||
from .serializer import ContactMeSerializer
|
from .serializer import ContactMeSerializer
|
||||||
from .tasks import send_contact_me_email_task, send_newly_added_items_to_store_email_task_last_week
|
from .tasks import send_contact_me_email_task, send_newly_added_items_to_store_email_task_last_week
|
||||||
|
from vontor_cz.turnstile import verify_turnstile
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(tags=["advertisement", "public"])
|
@extend_schema(tags=["advertisement", "public"])
|
||||||
@@ -26,6 +27,11 @@ class ContactMePublicView(APIView):
|
|||||||
if honeypot:
|
if honeypot:
|
||||||
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
turnstile_token = request.data.get("turnstile_token", "")
|
||||||
|
remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", ""))
|
||||||
|
if not verify_turnstile(turnstile_token, remote_ip):
|
||||||
|
return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if not email or not message:
|
if not email or not message:
|
||||||
return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|||||||
@@ -958,6 +958,9 @@ GOPAY_GATEWAY_URL = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com
|
|||||||
# New: absolute URL that GoPay calls (publicly reachable)
|
# New: absolute URL that GoPay calls (publicly reachable)
|
||||||
GOPAY_NOTIFICATION_URL = os.getenv("GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook")
|
GOPAY_NOTIFICATION_URL = os.getenv("GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook")
|
||||||
|
|
||||||
|
# --- Cloudflare Turnstile ---
|
||||||
|
CLOUDFLARE_TURNSTILE_SECRET_KEY = os.getenv("SECRET_KEY_TURNSTILE", "")
|
||||||
|
|
||||||
# -------------------------------------DOWNLOADER LIMITS------------------------------------
|
# -------------------------------------DOWNLOADER LIMITS------------------------------------
|
||||||
DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap
|
DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap
|
||||||
DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024
|
DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024
|
||||||
|
|||||||
33
backend/vontor_cz/turnstile.py
Normal file
33
backend/vontor_cz/turnstile.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_turnstile(token: str, remote_ip: str | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Verify a Cloudflare Turnstile token against the siteverify API.
|
||||||
|
Returns True if valid, False otherwise.
|
||||||
|
If CLOUDFLARE_TURNSTILE_SECRET_KEY is not configured, skips verification (dev bypass).
|
||||||
|
"""
|
||||||
|
secret = getattr(settings, "CLOUDFLARE_TURNSTILE_SECRET_KEY", "")
|
||||||
|
if not secret:
|
||||||
|
logger.debug("Turnstile: no secret key configured, skipping verification.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
payload = {"secret": secret, "response": token}
|
||||||
|
if remote_ip:
|
||||||
|
payload["remoteip"] = remote_ip
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(SITEVERIFY_URL, data=payload, timeout=5)
|
||||||
|
result = resp.json()
|
||||||
|
if not result.get("success"):
|
||||||
|
logger.warning("Turnstile verification failed: %s", result.get("error-codes"))
|
||||||
|
return bool(result.get("success"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Turnstile: siteverify request failed: %s", e)
|
||||||
|
return False
|
||||||
@@ -21,10 +21,14 @@
|
|||||||
<meta name="twitter:title" content="Vontor.cz – Creative Tech & Design" />
|
<meta name="twitter:title" content="Vontor.cz – Creative Tech & Design" />
|
||||||
<meta name="twitter:description" content="Engineering + design portfolio: backend, frontend, infrastructure, drone and automation." />
|
<meta name="twitter:description" content="Engineering + design portfolio: backend, frontend, infrastructure, drone and automation." />
|
||||||
|
|
||||||
|
<!-- ADsense -->
|
||||||
|
<meta name="google-adsense-account" content="ca-pub-9041142141778319">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import styles from "./contact-me.module.css"
|
|||||||
import { LuMousePointerClick } from "react-icons/lu";
|
import { LuMousePointerClick } from "react-icons/lu";
|
||||||
import { publicApi } from "@/api/publicClient";
|
import { publicApi } from "@/api/publicClient";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTurnstile } from "@/hooks/useTurnstile";
|
||||||
|
|
||||||
export default function ContactMeForm() {
|
export default function ContactMeForm() {
|
||||||
const { t } = useTranslation("home");
|
const { t } = useTranslation("home");
|
||||||
@@ -12,21 +13,33 @@ export default function ContactMeForm() {
|
|||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [message, setMessage] = useState("")
|
const [message, setMessage] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [envelopeFading, setEnvelopeFading] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const openingRef = useRef<HTMLDivElement>(null)
|
const openingRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { containerRef, token: turnstileToken, enabled: turnstileEnabled, reset: resetTurnstile } = useTurnstile()
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!turnstileToken) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError("")
|
setError("")
|
||||||
try {
|
try {
|
||||||
await publicApi.post("/api/advertisement/contact-me/", { email, message, hp: "" })
|
await publicApi.post("/api/advertisement/contact-me/", { email, message, hp: "", turnstile_token: turnstileToken ?? "" })
|
||||||
setSuccess(true)
|
|
||||||
setEmail("")
|
setEmail("")
|
||||||
setMessage("")
|
setMessage("")
|
||||||
|
// Step 1: slide form into envelope (content drops down behind cover, 1s transition)
|
||||||
|
setContentMoveUp(false)
|
||||||
|
setOpeningBehind(false)
|
||||||
|
// Step 2: close flap after form has slid in
|
||||||
|
setTimeout(() => setOpened(false), 1100)
|
||||||
|
// Step 3: fade out envelope after flap finishes closing
|
||||||
|
setTimeout(() => setEnvelopeFading(true), 2300)
|
||||||
|
// Step 4: show success panel
|
||||||
|
setTimeout(() => setSuccess(true), 2700)
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("contact.sendError"))
|
setError(t("contact.sendError"))
|
||||||
|
resetTurnstile()
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -54,73 +67,74 @@ export default function ContactMeForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNewMessage() {
|
||||||
|
setSuccess(false)
|
||||||
|
setEnvelopeFading(false)
|
||||||
|
setOpened(true)
|
||||||
|
setContentMoveUp(true)
|
||||||
|
setOpeningBehind(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className={styles["success-panel"]}>
|
||||||
|
<div style={{ fontSize: "2.5rem" }}>✓</div>
|
||||||
|
<p style={{ color: "var(--c-other)", fontWeight: 700, margin: 0 }}>{t("contact.messageSent")}</p>
|
||||||
|
<p style={{ color: "color-mix(in hsl, var(--c-text), transparent 35%)", fontSize: "0.85rem", margin: 0 }}>
|
||||||
|
{t("contact.replyIn24h")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleNewMessage}
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5rem", background: "none", border: "1px solid var(--c-lines)",
|
||||||
|
color: "var(--c-text)", padding: "0.4em 1.2em", borderRadius: "0.5em",
|
||||||
|
cursor: "pointer", fontSize: "0.82rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("contact.newMessage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["contact-me"]}>
|
<div className={[styles["contact-me"], envelopeFading ? styles["envelope-fadeout"] : ""].filter(Boolean).join(" ")}>
|
||||||
<div
|
<div
|
||||||
ref={openingRef}
|
ref={openingRef}
|
||||||
className={
|
className={[
|
||||||
[
|
styles.opening,
|
||||||
styles.opening,
|
opened ? styles["rotate-opening"] : "",
|
||||||
opened ? styles["rotate-opening"] : "",
|
openingBehind ? styles["opening-behind"] : ""
|
||||||
openingBehind ? styles["opening-behind"] : ""
|
].filter(Boolean).join(" ")}
|
||||||
].filter(Boolean).join(" ")
|
|
||||||
}
|
|
||||||
onClick={toggleOpen}
|
onClick={toggleOpen}
|
||||||
onTransitionEnd={handleTransitionEnd}
|
onTransitionEnd={handleTransitionEnd}
|
||||||
>
|
>
|
||||||
<LuMousePointerClick />
|
<LuMousePointerClick />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={[styles.content, contentMoveUp ? styles["content-moveup"] : ""].filter(Boolean).join(" ")}>
|
||||||
className={[
|
<form onSubmit={handleSubmit}>
|
||||||
styles.content,
|
<input
|
||||||
contentMoveUp ? styles["content-moveup"] : ''
|
type="email"
|
||||||
].filter(Boolean).join(' ')}
|
name="email"
|
||||||
>
|
placeholder={t("contact.emailPlaceholder")}
|
||||||
{success ? (
|
required
|
||||||
<div style={{
|
value={email}
|
||||||
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
onChange={e => setEmail(e.target.value)}
|
||||||
height: "100%", gap: "0.75rem", padding: "1.5rem", textAlign: "center",
|
/>
|
||||||
}}>
|
<textarea
|
||||||
<div style={{ fontSize: "2.5rem" }}>✓</div>
|
name="message"
|
||||||
<p style={{ color: "var(--c-other)", fontWeight: 700, margin: 0 }}>{t("contact.messageSent")}</p>
|
placeholder={t("contact.messagePlaceholder")}
|
||||||
<p style={{ color: "color-mix(in hsl, var(--c-text), transparent 35%)", fontSize: "0.85rem", margin: 0 }}>
|
required
|
||||||
{t("contact.replyIn24h")}
|
value={message}
|
||||||
</p>
|
onChange={e => setMessage(e.target.value)}
|
||||||
<button
|
/>
|
||||||
onClick={() => setSuccess(false)}
|
{error && (
|
||||||
style={{
|
<p style={{ color: "#ff6b6b", fontSize: "0.8rem", margin: "0", textAlign: "center" }}>{error}</p>
|
||||||
marginTop: "0.5rem", background: "none", border: "1px solid var(--c-lines)",
|
)}
|
||||||
color: "var(--c-text)", padding: "0.4em 1.2em", borderRadius: "0.5em",
|
{turnstileEnabled && <div style={{ display: "flex", justifyContent: "center" }}><div ref={containerRef} /></div>}
|
||||||
cursor: "pointer", fontSize: "0.82rem",
|
<input type="submit" value={loading ? t("contact.sendingButton") : t("contact.sendButton")} disabled={loading || !turnstileToken} />
|
||||||
}}
|
</form>
|
||||||
>
|
|
||||||
{t("contact.newMessage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
placeholder={t("contact.emailPlaceholder")}
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
name="message"
|
|
||||||
placeholder={t("contact.messagePlaceholder")}
|
|
||||||
required
|
|
||||||
value={message}
|
|
||||||
onChange={e => setMessage(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p style={{ color: "#ff6b6b", fontSize: "0.8rem", margin: "0", textAlign: "center" }}>{error}</p>
|
|
||||||
)}
|
|
||||||
<input type="submit" value={loading ? t("contact.sendingButton") : t("contact.sendButton")} disabled={loading} />
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cover}></div>
|
<div className={styles.cover}></div>
|
||||||
|
|||||||
@@ -126,6 +126,35 @@
|
|||||||
background: linear-gradient(135deg, var(--c-boxes), var(--c-background-light));
|
background: linear-gradient(135deg, var(--c-boxes), var(--c-background-light));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes envelopeFadeOut {
|
||||||
|
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
to { opacity: 0; transform: scale(0.88) translateY(-12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.envelope-fadeout {
|
||||||
|
animation: envelopeFadeOut 0.4s ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes successFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-panel {
|
||||||
|
margin: 15em auto 3em;
|
||||||
|
width: 30em;
|
||||||
|
max-width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
animation: successFadeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0% { transform: translateX(0); }
|
0% { transform: translateX(0); }
|
||||||
25% { transform: translateX(-2px) rotate(-8deg); }
|
25% { transform: translateX(-2px) rotate(-8deg); }
|
||||||
@@ -147,7 +176,8 @@
|
|||||||
|
|
||||||
|
|
||||||
@media only screen and (max-width: 990px){
|
@media only screen and (max-width: 990px){
|
||||||
.contact-me {
|
.contact-me,
|
||||||
|
.success-panel {
|
||||||
width: min(30em, 100%);
|
width: min(30em, 100%);
|
||||||
margin: 11rem auto 1.5rem;
|
margin: 11rem auto 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface AuthContextType {
|
|||||||
user: CustomUser | null;
|
user: CustomUser | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (payload: CustomTokenObtainPair) => Promise<void>;
|
login: (payload: CustomTokenObtainPair & { turnstile_token?: string }) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function login(payload: CustomTokenObtainPair) {
|
async function login(payload: CustomTokenObtainPair & { turnstile_token?: string }) {
|
||||||
await apiAccountLoginCreate(payload);
|
await apiAccountLoginCreate(payload as CustomTokenObtainPair);
|
||||||
localStorage.setItem(AUTH_FLAG, "true");
|
localStorage.setItem(AUTH_FLAG, "true");
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
}
|
}
|
||||||
|
|||||||
53
frontend/src/hooks/useTurnstile.ts
Normal file
53
frontend/src/hooks/useTurnstile.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string;
|
||||||
|
const USE_TURNSTILE = import.meta.env.VITE_USE_TURNSTILE !== "false";
|
||||||
|
|
||||||
|
export function useTurnstile() {
|
||||||
|
const [token, setToken] = useState<string | null>(USE_TURNSTILE ? null : "disabled");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!USE_TURNSTILE) return;
|
||||||
|
|
||||||
|
const renderWidget = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
widgetIdRef.current = (window as any).turnstile.render(containerRef.current, {
|
||||||
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
|
callback: (t: string) => setToken(t),
|
||||||
|
"expired-callback": () => setToken(null),
|
||||||
|
"error-callback": () => setToken(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((window as any).turnstile) {
|
||||||
|
renderWidget();
|
||||||
|
} else {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if ((window as any).turnstile) {
|
||||||
|
clearInterval(interval);
|
||||||
|
renderWidget();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (widgetIdRef.current !== null) {
|
||||||
|
(window as any).turnstile?.remove(widgetIdRef.current);
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
if (!USE_TURNSTILE) return;
|
||||||
|
if (widgetIdRef.current !== null) {
|
||||||
|
(window as any).turnstile?.reset(widgetIdRef.current);
|
||||||
|
}
|
||||||
|
setToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { containerRef, token, enabled: USE_TURNSTILE, reset };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import Button from "@/components/ui/Button";
|
|||||||
import Spinner from "@/components/ui/Spinner";
|
import Spinner from "@/components/ui/Spinner";
|
||||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||||
import { applyServerErrors } from "@/utils/formErrors";
|
import { applyServerErrors } from "@/utils/formErrors";
|
||||||
|
import { useTurnstile } from "@/hooks/useTurnstile";
|
||||||
|
|
||||||
interface LoginForm {
|
interface LoginForm {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -27,15 +28,18 @@ export default function LoginPage() {
|
|||||||
const { register, handleSubmit, formState, clearErrors } = form;
|
const { register, handleSubmit, formState, clearErrors } = form;
|
||||||
const { errors, isSubmitting } = formState;
|
const { errors, isSubmitting } = formState;
|
||||||
const [rootError, setRootError] = useState<string | undefined>();
|
const [rootError, setRootError] = useState<string | undefined>();
|
||||||
|
const { containerRef, token: turnstileToken, enabled: turnstileEnabled, reset: resetTurnstile } = useTurnstile();
|
||||||
|
|
||||||
async function onSubmit(values: LoginForm) {
|
async function onSubmit(values: LoginForm) {
|
||||||
|
if (!turnstileToken) return;
|
||||||
setRootError(undefined);
|
setRootError(undefined);
|
||||||
clearErrors();
|
clearErrors();
|
||||||
try {
|
try {
|
||||||
await login(values);
|
await login({ ...values, turnstile_token: turnstileToken ?? "" });
|
||||||
navigate("/social/feed");
|
navigate("/social/feed");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRootError(applyServerErrors(form, err) ?? t("login.errors.generic"));
|
setRootError(applyServerErrors(form, err) ?? t("login.errors.generic"));
|
||||||
|
resetTurnstile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ export default function LoginPage() {
|
|||||||
setRootError(undefined);
|
setRootError(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = isSubmitting;
|
const disabled = isSubmitting || !turnstileToken;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
@@ -88,8 +92,10 @@ export default function LoginPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" fullWidth loading={disabled} disabled={disabled}>
|
{turnstileEnabled && <div ref={containerRef} className="flex justify-center" />}
|
||||||
{disabled ? (
|
|
||||||
|
<Button type="submit" fullWidth loading={isSubmitting} disabled={disabled}>
|
||||||
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size={16} /> {t("login.submitting")}
|
<Spinner size={16} /> {t("login.submitting")}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Checkbox from "@/components/ui/Checkbox";
|
|||||||
import Spinner from "@/components/ui/Spinner";
|
import Spinner from "@/components/ui/Spinner";
|
||||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||||
import { applyServerErrors } from "@/utils/formErrors";
|
import { applyServerErrors } from "@/utils/formErrors";
|
||||||
|
import { useTurnstile } from "@/hooks/useTurnstile";
|
||||||
|
|
||||||
interface RegisterForm {
|
interface RegisterForm {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -38,6 +39,7 @@ export default function RegisterPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [rootError, setRootError] = useState<string | undefined>();
|
const [rootError, setRootError] = useState<string | undefined>();
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const { containerRef, token: turnstileToken, enabled: turnstileEnabled, reset: resetTurnstile } = useTurnstile();
|
||||||
|
|
||||||
const form = useForm<RegisterForm>({
|
const form = useForm<RegisterForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -58,6 +60,7 @@ export default function RegisterPage() {
|
|||||||
const { errors, isSubmitting } = formState;
|
const { errors, isSubmitting } = formState;
|
||||||
|
|
||||||
async function onSubmit(values: RegisterForm) {
|
async function onSubmit(values: RegisterForm) {
|
||||||
|
if (!turnstileToken) return;
|
||||||
setRootError(undefined);
|
setRootError(undefined);
|
||||||
// Wipe any stale server errors before the new request — RHF only
|
// Wipe any stale server errors before the new request — RHF only
|
||||||
// re-validates fields that have rules, so server errors on optional
|
// re-validates fields that have rules, so server errors on optional
|
||||||
@@ -70,12 +73,13 @@ export default function RegisterPage() {
|
|||||||
// Cast to the orval-generated type — fields may be re-typed as optional
|
// Cast to the orval-generated type — fields may be re-typed as optional
|
||||||
// once the schema is regenerated via `npm run api:gen`.
|
// once the schema is regenerated via `npm run api:gen`.
|
||||||
await apiAccountRegisterCreate(
|
await apiAccountRegisterCreate(
|
||||||
payload as Parameters<typeof apiAccountRegisterCreate>[0],
|
{ ...payload, turnstile_token: turnstileToken ?? "" } as Parameters<typeof apiAccountRegisterCreate>[0],
|
||||||
);
|
);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => navigate("/social/login"), 1500);
|
setTimeout(() => navigate("/social/login"), 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRootError(applyServerErrors(form, err) ?? t("register.errors.generic"));
|
setRootError(applyServerErrors(form, err) ?? t("register.errors.generic"));
|
||||||
|
resetTurnstile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +243,9 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<Button type="submit" fullWidth loading={isSubmitting} disabled={isSubmitting}>
|
{turnstileEnabled && <div ref={containerRef} className="flex justify-center" />}
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth loading={isSubmitting} disabled={isSubmitting || !turnstileToken}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size={16} /> {t("register.submitting")}
|
<Spinner size={16} /> {t("register.submitting")}
|
||||||
|
|||||||
Reference in New Issue
Block a user