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:
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user