merge: 同步 upstream/main 新功能到定制版本

- feat: Add slider verification code (bd67997)
- fix bug: Inventory cannot be zero (1f7a6ee)
- fix: resolve merge conflicts and lint errors
This commit is contained in:
2026-03-23 21:50:10 -07:00
parent 3806264343
commit d6616c5859
63 changed files with 3111 additions and 1130 deletions
@@ -2,10 +2,10 @@
import { useNavigate } from "@tanstack/react-router";
import {
resetPassword,
userLogin,
userRegister,
} from "@workspace/ui/services/common/auth";
adminLogin,
adminResetPassword,
} from "@workspace/ui/services/admin/auth";
import { userRegister } from "@workspace/ui/services/common/auth";
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
@@ -42,7 +42,7 @@ export default function EmailAuthForm() {
try {
switch (type) {
case "login": {
const login = await userLogin(params);
const login = await adminLogin(params);
toast.success(t("login.success", "Login successful!"));
onLogin(login.data.data?.token);
break;
@@ -54,7 +54,7 @@ export default function EmailAuthForm() {
break;
}
case "reset":
await resetPassword(params);
await adminResetPassword(params);
toast.success(t("reset.success", "Password reset successful!"));
setType("login");
break;
@@ -15,8 +15,9 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function LoginForm({
loading,
@@ -38,6 +39,7 @@ export default function LoginForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_admin_login_captcha;
const formSchema = z.object({
@@ -53,14 +55,26 @@ export default function LoginForm({
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -71,6 +85,7 @@ export default function LoginForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -143,8 +158,8 @@ export default function LoginForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -152,6 +167,20 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -15,9 +15,10 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function ResetForm({
loading,
@@ -40,6 +41,7 @@ export default function ResetForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({
@@ -56,14 +58,26 @@ export default function ResetForm({
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -74,6 +88,7 @@ export default function ResetForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -173,8 +188,8 @@ export default function ResetForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -182,6 +197,20 @@ export default function ResetForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
+74 -69
View File
@@ -2,7 +2,7 @@ import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { adminGenerateCaptcha } from "@workspace/ui/services/admin/auth";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
@@ -15,80 +15,85 @@ interface LocalCaptchaProps {
onCaptchaIdChange?: (id: string) => void;
}
const LocalCaptcha = forwardRef<LocalCaptchaRef, LocalCaptchaProps>(
({ value, onChange, onCaptchaIdChange }, ref) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const LocalCaptcha = ({
value,
onChange,
onCaptchaIdChange,
ref,
}: LocalCaptchaProps & { ref?: RefObject<LocalCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await adminGenerateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await adminGenerateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
};
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
useEffect(() => {
fetchCaptcha();
}, []);
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
}, []);
},
}));
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
},
}));
return (
<div className="flex gap-2">
<Input
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
className="flex-1"
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
src={captchaImage}
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
onClick={fetchCaptcha}
title={t("captcha.clickToRefresh", "Click to refresh")}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-xs text-muted-foreground">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={fetchCaptcha}
disabled={loading}
title={t("captcha.refresh", "Refresh captcha")}
>
<Icon icon="mdi:refresh" />
</Button>
return (
<div className="flex gap-2">
<Input
className="flex-1"
onChange={(e) => onChange?.(e.target.value)}
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
height={40}
onClick={fetchCaptcha}
src={captchaImage}
title={t("captcha.clickToRefresh", "Click to refresh")}
width={120}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground text-xs">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
);
}
);
<Button
disabled={loading}
onClick={fetchCaptcha}
size="icon"
title={t("captcha.refresh", "Refresh captcha")}
type="button"
variant="outline"
>
<Icon icon="mdi:refresh" />
</Button>
</div>
);
};
LocalCaptcha.displayName = "LocalCaptcha";
@@ -0,0 +1,358 @@
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Icon } from "@workspace/ui/composed/icon";
import {
adminGenerateCaptcha,
adminVerifyCaptchaSlider,
} from "@workspace/ui/services/admin/auth";
import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface SliderCaptchaRef {
reset: () => void;
}
interface SliderCaptchaProps {
value?: string;
onChange?: (value: string) => void;
}
interface TrailPoint {
x: number;
y: number;
t: number;
}
const BLOCK_SIZE = 100;
const BG_NATURAL_WIDTH = 560;
const BG_NATURAL_HEIGHT = 280;
const SliderCaptcha = ({
onChange,
ref,
}: SliderCaptchaProps & { ref?: RefObject<SliderCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [captchaId, setCaptchaId] = useState("");
const [bgImage, setBgImage] = useState("");
const [blockImage, setBlockImage] = useState("");
const [loading, setLoading] = useState(false);
const [verified, setVerified] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "fail">("idle");
const [blockPos, setBlockPos] = useState({ x: 0, y: 50 });
const [shaking, setShaking] = useState(false);
const dragging = useRef(false);
const startPointer = useRef({ x: 0, y: 0 });
const startBlock = useRef({ x: 0, y: 50 });
const dragStartTime = useRef(0);
const trail = useRef<TrailPoint[]>([]);
const getContainerSize = () => {
const el = containerRef.current;
if (!el) return { w: BG_NATURAL_WIDTH, h: BG_NATURAL_HEIGHT };
return { w: el.clientWidth, h: el.clientHeight };
};
const fetchCaptcha = useCallback(async () => {
setLoading(true);
setStatus("idle");
setBlockPos({ x: 0, y: 50 });
trail.current = [];
try {
const res = await adminGenerateCaptcha();
const data = res.data?.data;
if (data) {
setCaptchaId(data.id);
setBgImage(data.image);
setBlockImage(data.block_image ?? "");
}
} catch (e) {
console.error("Failed to generate slider captcha:", e);
} finally {
setLoading(false);
}
}, []);
const handleOpen = () => {
if (verified) return;
setOpen(true);
fetchCaptcha();
};
useImperativeHandle(ref, () => ({
reset: () => {
setVerified(false);
onChange?.("");
setOpen(false);
trail.current = [];
},
}));
const onPointerDown = (e: React.PointerEvent) => {
if (status === "success" || loading) return;
dragging.current = true;
dragStartTime.current = Date.now();
trail.current = [];
startPointer.current = { x: e.clientX, y: e.clientY };
startBlock.current = { ...blockPos };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
e.preventDefault();
const { w, h } = getContainerSize();
const _scaleX = BG_NATURAL_WIDTH / w;
const _scaleY = BG_NATURAL_HEIGHT / h;
trail.current.push({
x: Math.round(startBlock.current.x),
y: Math.round(startBlock.current.y),
t: 0,
});
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging.current) return;
const { w, h } = getContainerSize();
const scaleX = BG_NATURAL_WIDTH / w;
const scaleY = BG_NATURAL_HEIGHT / h;
const dx = (e.clientX - startPointer.current.x) * scaleX;
const dy = (e.clientY - startPointer.current.y) * scaleY;
const newX = Math.max(
0,
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
);
const newY = Math.max(
0,
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
);
setBlockPos({ x: newX, y: newY });
trail.current.push({
x: Math.round(newX),
y: Math.round(newY),
t: Date.now() - dragStartTime.current,
});
};
const onPointerUp = async (e: React.PointerEvent) => {
if (!dragging.current) return;
dragging.current = false;
const { w, h } = getContainerSize();
const scaleX = BG_NATURAL_WIDTH / w;
const scaleY = BG_NATURAL_HEIGHT / h;
const dx = (e.clientX - startPointer.current.x) * scaleX;
const dy = (e.clientY - startPointer.current.y) * scaleY;
const finalX = Math.round(
Math.max(
0,
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
)
);
const finalY = Math.round(
Math.max(
0,
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
)
);
setBlockPos({ x: finalX, y: finalY });
trail.current.push({
x: finalX,
y: finalY,
t: Date.now() - dragStartTime.current,
});
try {
const res = await adminVerifyCaptchaSlider({
id: captchaId,
x: finalX,
y: finalY,
trail: JSON.stringify(trail.current),
});
const token = res.data?.data?.token;
if (token) {
setStatus("success");
setTimeout(() => {
setVerified(true);
onChange?.(token);
setOpen(false);
}, 600);
} else {
triggerFail();
}
} catch {
triggerFail();
}
};
const triggerFail = () => {
setStatus("fail");
setShaking(true);
setTimeout(() => {
setShaking(false);
fetchCaptcha();
}, 800);
};
const blockLeftPct = (blockPos.x / BG_NATURAL_WIDTH) * 100;
const blockTopPct = (blockPos.y / BG_NATURAL_HEIGHT) * 100;
const blockSizeWPct = (BLOCK_SIZE / BG_NATURAL_WIDTH) * 100;
const blockSizeHPct = (BLOCK_SIZE / BG_NATURAL_HEIGHT) * 100;
return (
<>
{/* Trigger button */}
<button
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
verified
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-input bg-background hover:bg-muted"
}`}
onClick={handleOpen}
type="button"
>
<span
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
verified ? "bg-green-500" : "bg-primary"
}`}
>
{verified ? (
<Icon className="text-white text-xs" icon="mdi:check" />
) : (
<>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</>
)}
</span>
<span className={verified ? "font-medium" : "text-muted-foreground"}>
{verified
? t("captcha.slider.success", "Verified")
: t("captcha.slider.clickToVerify", "Click to verify")}
</span>
{verified && (
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
)}
</button>
{/* Slider dialog */}
<Dialog
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
open={open}
>
<DialogContent className="select-none p-6 sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("captcha.slider.title", "Security Verification")}
</DialogTitle>
</DialogHeader>
<div
className={`relative w-full overflow-hidden rounded-md bg-muted ${
shaking ? "animate-[shake_0.4s_ease-in-out]" : ""
}`}
ref={containerRef}
style={{
paddingTop: `${(BG_NATURAL_HEIGHT / BG_NATURAL_WIDTH) * 100}%`,
}}
>
<div className="absolute inset-0">
{loading ? (
<div className="flex h-full items-center justify-center">
<Icon className="animate-spin text-2xl" icon="mdi:loading" />
</div>
) : bgImage ? (
<>
<img
alt="captcha background"
className="absolute inset-0 h-full w-full"
draggable={false}
height={BG_NATURAL_HEIGHT}
src={bgImage}
width={BG_NATURAL_WIDTH}
/>
{blockImage && (
<img
alt="captcha block"
className="absolute cursor-grab active:cursor-grabbing"
draggable={false}
height={BG_NATURAL_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
src={blockImage}
style={{
filter:
status === "success"
? "drop-shadow(0 0 3px rgba(74,222,128,0.9))"
: status === "fail"
? "drop-shadow(0 0 3px rgba(248,113,113,0.9))"
: "drop-shadow(0 0 2px rgba(255,255,255,0.7))",
left: `${blockLeftPct}%`,
top: `${blockTopPct}%`,
width: `${blockSizeWPct}%`,
height: `${blockSizeHPct}%`,
touchAction: "none",
}}
width={BG_NATURAL_WIDTH}
/>
)}
{status !== "idle" && (
<div
className={`absolute inset-0 flex items-center justify-center font-medium text-sm ${
status === "success"
? "bg-green-500/20 text-green-700"
: "bg-red-500/20 text-red-700"
}`}
>
{status === "success"
? t("captcha.slider.success", "Verified")
: t("captcha.slider.fail", "Try again")}
</div>
)}
</>
) : null}
</div>
</div>
<p className="text-center text-muted-foreground text-xs">
{t("captcha.slider.hint", "Drag the piece to fit the puzzle")}
</p>
<Button
className="w-full"
disabled={loading}
onClick={fetchCaptcha}
size="sm"
type="button"
variant="ghost"
>
<Icon icon="mdi:refresh" />
{t("captcha.clickToRefresh", "Refresh")}
</Button>
</DialogContent>
</Dialog>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
`}</style>
</>
);
};
SliderCaptcha.displayName = "SliderCaptcha";
export default SliderCaptcha;
+110 -21
View File
@@ -1,5 +1,18 @@
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Icon } from "@workspace/ui/composed/icon";
import { useTheme } from "next-themes";
import { type RefObject, useEffect, useImperativeHandle } from "react";
import {
type RefObject,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import Turnstile, { useTurnstile } from "react-turnstile";
@@ -23,43 +36,119 @@ const CloudFlareTurnstile = function CloudFlareTurnstile({
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const { i18n, t } = useTranslation("auth");
const locale = i18n.language;
const turnstile = useTurnstile();
const [open, setOpen] = useState(false);
const [verified, setVerified] = useState(false);
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
reset: () => {
setVerified(false);
onChange("");
turnstile.reset();
},
}),
[turnstile]
[turnstile, onChange]
);
useEffect(() => {
if (value === "") {
setVerified(false);
turnstile.reset();
}
}, [turnstile, value]);
const handleOpen = () => {
if (verified) return;
setOpen(true);
};
if (!verify.turnstile_site_key) return null;
return (
verify.turnstile_site_key && (
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange();
turnstile.reset();
<>
{/* Trigger button */}
<button
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
verified
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-input bg-background hover:bg-muted"
}`}
onClick={handleOpen}
type="button"
>
<span
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
verified ? "bg-green-500" : "bg-primary"
}`}
>
{verified ? (
<Icon className="text-white text-xs" icon="mdi:check" />
) : (
<>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</>
)}
</span>
<span className={verified ? "font-medium" : "text-muted-foreground"}>
{verified
? t("captcha.turnstile.success", "Verified")
: t("captcha.turnstile.clickToVerify", "Click to verify")}
</span>
{verified && (
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
)}
</button>
{/* Turnstile dialog */}
<Dialog
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
onVerify={(token) => onChange(token)}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
)
open={open}
>
<DialogContent className="flex w-auto flex-col items-center gap-4 p-6">
<DialogHeader>
<DialogTitle>
{t("captcha.turnstile.title", "Security Verification")}
</DialogTitle>
</DialogHeader>
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange("");
turnstile.reset();
}}
onTimeout={() => {
onChange("");
turnstile.reset();
}}
onVerify={(token) => {
setVerified(true);
onChange(token);
setTimeout(() => setOpen(false), 400);
}}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
<Button
className="w-full"
onClick={() => setOpen(false)}
size="sm"
type="button"
variant="ghost"
>
{t("captcha.turnstile.cancel", "Cancel")}
</Button>
</DialogContent>
</Dialog>
</>
);
};