turnstile is working - keep SSL turned of on dev

This commit is contained in:
David Bruno Vontor
2026-06-11 11:35:36 +02:00
parent 192143bfb6
commit 5ca62497b1
12 changed files with 245 additions and 79 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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();
} }

View 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 };
}

View File

@@ -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")}
</> </>

View File

@@ -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")}