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:
@@ -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 LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||
import type { TurnstileRef } from "../turnstile";
|
||||
import CloudFlareTurnstile from "../turnstile";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
|
||||
export default function LoginForm({
|
||||
loading,
|
||||
@@ -39,6 +40,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_user_login_captcha;
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -52,14 +54,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
|
||||
@@ -70,6 +84,7 @@ export default function LoginForm({
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
localCaptcha.current?.reset();
|
||||
sliderCaptcha.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -84,7 +99,10 @@ export default function LoginForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("login.emailPlaceholder", "Enter your email...")}
|
||||
placeholder={t(
|
||||
"login.emailPlaceholder",
|
||||
"Enter your email..."
|
||||
)}
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
@@ -100,7 +118,10 @@ export default function LoginForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("login.passwordPlaceholder", "Enter your password...")}
|
||||
placeholder={t(
|
||||
"login.passwordPlaceholder",
|
||||
"Enter your password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -136,8 +157,8 @@ export default function LoginForm({
|
||||
<FormControl>
|
||||
<LocalCaptcha
|
||||
{...field}
|
||||
ref={localCaptcha}
|
||||
onCaptchaIdChange={setCaptchaId}
|
||||
ref={localCaptcha}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -145,6 +166,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")}
|
||||
|
||||
@@ -16,10 +16,11 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
import SendCode from "../send-code";
|
||||
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||
import type { TurnstileRef } from "../turnstile";
|
||||
import CloudFlareTurnstile from "../turnstile";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
|
||||
export default function RegisterForm({
|
||||
loading,
|
||||
@@ -41,6 +42,7 @@ export default function RegisterForm({
|
||||
|
||||
const isTurnstile = verify.captcha_type === "turnstile";
|
||||
const isLocal = verify.captcha_type === "local";
|
||||
const isSlider = verify.captcha_type === "slider";
|
||||
const captchaEnabled = verify.enable_user_register_captcha;
|
||||
|
||||
const handleCheckUser = async (email: string) => {
|
||||
@@ -78,8 +80,16 @@ export default function RegisterForm({
|
||||
: z.string().nullish(),
|
||||
captcha_code:
|
||||
captchaEnabled && isLocal
|
||||
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
|
||||
? 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(),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
@@ -94,6 +104,9 @@ export default function RegisterForm({
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
cf_token: "",
|
||||
captcha_code: "",
|
||||
slider_token: "",
|
||||
...initialValues,
|
||||
invite: localStorage.getItem("invite") || "",
|
||||
},
|
||||
@@ -101,6 +114,7 @@ export default function RegisterForm({
|
||||
|
||||
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
|
||||
@@ -111,6 +125,7 @@ export default function RegisterForm({
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
localCaptcha.current?.reset();
|
||||
sliderCaptcha.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,7 +145,10 @@ export default function RegisterForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("register.emailPlaceholder", "Enter your email...")}
|
||||
placeholder={t(
|
||||
"register.emailPlaceholder",
|
||||
"Enter your email..."
|
||||
)}
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
@@ -146,7 +164,10 @@ export default function RegisterForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("register.passwordPlaceholder", "Enter your password...")}
|
||||
placeholder={t(
|
||||
"register.passwordPlaceholder",
|
||||
"Enter your password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -163,7 +184,10 @@ export default function RegisterForm({
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t("register.repeatPasswordPlaceholder", "Enter password again...")}
|
||||
placeholder={t(
|
||||
"register.repeatPasswordPlaceholder",
|
||||
"Enter password again..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -182,7 +206,10 @@ export default function RegisterForm({
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t("register.codePlaceholder", "Enter code...")}
|
||||
placeholder={t(
|
||||
"register.codePlaceholder",
|
||||
"Enter code..."
|
||||
)}
|
||||
type="text"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
@@ -248,8 +275,8 @@ export default function RegisterForm({
|
||||
<FormControl>
|
||||
<LocalCaptcha
|
||||
{...field}
|
||||
ref={localCaptcha}
|
||||
onCaptchaIdChange={setCaptchaId}
|
||||
ref={localCaptcha}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -257,6 +284,20 @@ export default function RegisterForm({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{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("register.title", "Register")}
|
||||
|
||||
@@ -15,10 +15,11 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
import SendCode from "../send-code";
|
||||
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||
import type { TurnstileRef } from "../turnstile";
|
||||
import CloudFlareTurnstile from "../turnstile";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
|
||||
export default function ResetForm({
|
||||
loading,
|
||||
@@ -41,6 +42,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({
|
||||
@@ -57,14 +59,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
|
||||
@@ -75,6 +89,7 @@ export default function ResetForm({
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
localCaptcha.current?.reset();
|
||||
sliderCaptcha.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,7 +104,10 @@ export default function ResetForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("reset.emailPlaceholder", "Enter your email...")}
|
||||
placeholder={t(
|
||||
"reset.emailPlaceholder",
|
||||
"Enter your email..."
|
||||
)}
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
@@ -132,7 +150,10 @@ export default function ResetForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("reset.passwordPlaceholder", "Enter your new password...")}
|
||||
placeholder={t(
|
||||
"reset.passwordPlaceholder",
|
||||
"Enter your new password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -168,8 +189,8 @@ export default function ResetForm({
|
||||
<FormControl>
|
||||
<LocalCaptcha
|
||||
{...field}
|
||||
ref={localCaptcha}
|
||||
onCaptchaIdChange={setCaptchaId}
|
||||
ref={localCaptcha}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -177,6 +198,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 { generateCaptcha } from "@workspace/ui/services/common/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 generateCaptcha();
|
||||
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 generateCaptcha();
|
||||
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";
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
import SendCode from "../send-code";
|
||||
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||
import type { TurnstileRef } from "../turnstile";
|
||||
import CloudFlareTurnstile from "../turnstile";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
|
||||
export default function LoginForm({
|
||||
loading,
|
||||
@@ -39,6 +40,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_user_login_captcha;
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -54,16 +56,28 @@ 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 [mode, setMode] = useState<"password" | "code">("password");
|
||||
|
||||
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 LoginForm({
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
localCaptcha.current?.reset();
|
||||
sliderCaptcha.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,7 +119,10 @@ export default function LoginForm({
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder={t("register.areaCodePlaceholder", "Area code...")}
|
||||
placeholder={t(
|
||||
"register.areaCodePlaceholder",
|
||||
"Area code..."
|
||||
)}
|
||||
simple
|
||||
value={field.value}
|
||||
/>
|
||||
@@ -115,7 +133,10 @@ export default function LoginForm({
|
||||
/>
|
||||
<Input
|
||||
className="rounded-l-none"
|
||||
placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
|
||||
placeholder={t(
|
||||
"register.telephonePlaceholder",
|
||||
"Enter your telephone..."
|
||||
)}
|
||||
type="tel"
|
||||
{...field}
|
||||
/>
|
||||
@@ -137,7 +158,10 @@ export default function LoginForm({
|
||||
placeholder={
|
||||
mode === "code"
|
||||
? t("register.codePlaceholder", "Enter code...")
|
||||
: t("login.passwordPlaceholder", "Enter your password...")
|
||||
: t(
|
||||
"login.passwordPlaceholder",
|
||||
"Enter your password..."
|
||||
)
|
||||
}
|
||||
type={mode === "code" ? "text" : "password"}
|
||||
{...field}
|
||||
@@ -202,8 +226,8 @@ export default function LoginForm({
|
||||
<FormControl>
|
||||
<LocalCaptcha
|
||||
{...field}
|
||||
ref={localCaptcha}
|
||||
onCaptchaIdChange={setCaptchaId}
|
||||
ref={localCaptcha}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -211,6 +235,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")}
|
||||
|
||||
@@ -17,10 +17,11 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
import SendCode from "../send-code";
|
||||
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||
import type { TurnstileRef } from "../turnstile";
|
||||
import CloudFlareTurnstile from "../turnstile";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
|
||||
export default function RegisterForm({
|
||||
loading,
|
||||
@@ -41,6 +42,7 @@ export default function RegisterForm({
|
||||
|
||||
const isTurnstile = verify.captcha_type === "turnstile";
|
||||
const isLocal = verify.captcha_type === "local";
|
||||
const isSlider = verify.captcha_type === "slider";
|
||||
const captchaEnabled = verify.enable_user_register_captcha;
|
||||
|
||||
const formSchema = z
|
||||
@@ -57,8 +59,16 @@ export default function RegisterForm({
|
||||
: z.string().nullish(),
|
||||
captcha_code:
|
||||
captchaEnabled && isLocal
|
||||
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
|
||||
? 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(),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
@@ -73,6 +83,9 @@ export default function RegisterForm({
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
cf_token: "",
|
||||
captcha_code: "",
|
||||
slider_token: "",
|
||||
...initialValues,
|
||||
telephone_area_code: initialValues?.telephone_area_code || "1",
|
||||
invite: localStorage.getItem("invite") || "",
|
||||
@@ -81,6 +94,7 @@ export default function RegisterForm({
|
||||
|
||||
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
|
||||
@@ -91,6 +105,7 @@ export default function RegisterForm({
|
||||
} catch (_error) {
|
||||
turnstile.current?.reset();
|
||||
localCaptcha.current?.reset();
|
||||
sliderCaptcha.current?.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,7 +141,10 @@ export default function RegisterForm({
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder={t("register.areaCodePlaceholder", "Area code...")}
|
||||
placeholder={t(
|
||||
"register.areaCodePlaceholder",
|
||||
"Area code..."
|
||||
)}
|
||||
simple
|
||||
value={field.value}
|
||||
whitelist={enable_whitelist ? whitelist : []}
|
||||
@@ -138,7 +156,10 @@ export default function RegisterForm({
|
||||
/>
|
||||
<Input
|
||||
className="rounded-l-none"
|
||||
placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
|
||||
placeholder={t(
|
||||
"register.telephonePlaceholder",
|
||||
"Enter your telephone..."
|
||||
)}
|
||||
type="tel"
|
||||
{...field}
|
||||
/>
|
||||
@@ -155,7 +176,10 @@ export default function RegisterForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("register.passwordPlaceholder", "Enter your password...")}
|
||||
placeholder={t(
|
||||
"register.passwordPlaceholder",
|
||||
"Enter your password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -172,7 +196,10 @@ export default function RegisterForm({
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t("register.repeatPasswordPlaceholder", "Enter password again...")}
|
||||
placeholder={t(
|
||||
"register.repeatPasswordPlaceholder",
|
||||
"Enter password again..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -190,7 +217,10 @@ export default function RegisterForm({
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder={t("register.codePlaceholder", "Enter code...")}
|
||||
placeholder={t(
|
||||
"register.codePlaceholder",
|
||||
"Enter code..."
|
||||
)}
|
||||
type="text"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
@@ -259,8 +289,8 @@ export default function RegisterForm({
|
||||
<FormControl>
|
||||
<LocalCaptcha
|
||||
{...field}
|
||||
ref={localCaptcha}
|
||||
onCaptchaIdChange={setCaptchaId}
|
||||
ref={localCaptcha}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -268,6 +298,20 @@ export default function RegisterForm({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{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("register.title", "Register")}
|
||||
|
||||
@@ -16,10 +16,11 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
import SendCode from "../send-code";
|
||||
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||
import type { TurnstileRef } from "../turnstile";
|
||||
import CloudFlareTurnstile from "../turnstile";
|
||||
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||
|
||||
export default function ResetForm({
|
||||
loading,
|
||||
@@ -41,6 +42,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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,7 +119,10 @@ export default function ResetForm({
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder={t("register.areaCodePlaceholder", "Area code...")}
|
||||
placeholder={t(
|
||||
"register.areaCodePlaceholder",
|
||||
"Area code..."
|
||||
)}
|
||||
simple
|
||||
value={field.value}
|
||||
/>
|
||||
@@ -115,7 +133,10 @@ export default function ResetForm({
|
||||
/>
|
||||
<Input
|
||||
className="rounded-l-none"
|
||||
placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
|
||||
placeholder={t(
|
||||
"register.telephonePlaceholder",
|
||||
"Enter your telephone..."
|
||||
)}
|
||||
type="tel"
|
||||
{...field}
|
||||
/>
|
||||
@@ -133,7 +154,10 @@ export default function ResetForm({
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={t("register.codePlaceholder", "Enter code...")}
|
||||
placeholder={t(
|
||||
"register.codePlaceholder",
|
||||
"Enter code..."
|
||||
)}
|
||||
type="text"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
@@ -159,7 +183,10 @@ export default function ResetForm({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("reset.passwordPlaceholder", "Enter your new password...")}
|
||||
placeholder={t(
|
||||
"reset.passwordPlaceholder",
|
||||
"Enter your new password..."
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -195,8 +222,8 @@ export default function ResetForm({
|
||||
<FormControl>
|
||||
<LocalCaptcha
|
||||
{...field}
|
||||
ref={localCaptcha}
|
||||
onCaptchaIdChange={setCaptchaId}
|
||||
ref={localCaptcha}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -204,6 +231,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")}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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 {
|
||||
generateCaptcha,
|
||||
verifyCaptchaSlider,
|
||||
} from "@workspace/ui/services/common/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 generateCaptcha();
|
||||
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();
|
||||
|
||||
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 verifyCaptchaSlider({
|
||||
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,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
@@ -24,47 +37,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)}
|
||||
// onError={() => {
|
||||
// onChange();
|
||||
// turnstile.reset();
|
||||
// }}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@workspace/ui/components/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { queryUserSubscribe } from "@workspace/ui/services/user/user";
|
||||
import { getUserTrafficStats } from "@workspace/ui/services/user/traffic";
|
||||
import { queryUserSubscribe } from "@workspace/ui/services/user/user";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TrafficTrendChart from "./traffic-trend-chart";
|
||||
import TrafficRatioChart from "./traffic-ratio-chart";
|
||||
import TrafficStatsCards from "./traffic-stats-cards";
|
||||
import TrafficTrendChart from "./traffic-trend-chart";
|
||||
|
||||
export default function TrafficStatistics() {
|
||||
const { t } = useTranslation("traffic");
|
||||
const [days, setDays] = useState<7 | 30>(7);
|
||||
const [selectedSubscribeId, setSelectedSubscribeId] = useState<string | null>(null);
|
||||
const [selectedSubscribeId, setSelectedSubscribeId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// 查询用户订阅列表
|
||||
const { data: userSubscribe = [] } = useQuery({
|
||||
@@ -26,7 +39,8 @@ export default function TrafficStatistics() {
|
||||
});
|
||||
|
||||
// 使用 id_str 字段,避免 JavaScript 精度丢失
|
||||
const activeSubscribeId = selectedSubscribeId || (userSubscribe[0]?.id_str || null);
|
||||
const activeSubscribeId =
|
||||
selectedSubscribeId || userSubscribe[0]?.id_str || null;
|
||||
|
||||
// 查询流量统计数据
|
||||
const { data: trafficStats, isLoading } = useQuery({
|
||||
@@ -54,11 +68,13 @@ export default function TrafficStatistics() {
|
||||
{/* 订阅选择 */}
|
||||
{userSubscribe.length > 1 && (
|
||||
<Select
|
||||
value={activeSubscribeId || undefined}
|
||||
onValueChange={(value) => setSelectedSubscribeId(value)}
|
||||
value={activeSubscribeId || undefined}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-[200px]">
|
||||
<SelectValue placeholder={t("selectSubscription", "Select Subscription")} />
|
||||
<SelectValue
|
||||
placeholder={t("selectSubscription", "Select Subscription")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userSubscribe.map((sub) => (
|
||||
@@ -70,7 +86,10 @@ export default function TrafficStatistics() {
|
||||
</Select>
|
||||
)}
|
||||
{/* 时间范围切换 */}
|
||||
<Tabs value={String(days)} onValueChange={(value) => setDays(Number(value) as 7 | 30)}>
|
||||
<Tabs
|
||||
onValueChange={(value) => setDays(Number(value) as 7 | 30)}
|
||||
value={String(days)}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">{t("days7", "7 Days")}</TabsTrigger>
|
||||
<TabsTrigger value="30">{t("days30", "30 Days")}</TabsTrigger>
|
||||
@@ -87,7 +106,9 @@ export default function TrafficStatistics() {
|
||||
{/* 流量趋势图 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("trafficTrend", "Traffic Trend")}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t("trafficTrend", "Traffic Trend")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
@@ -107,17 +128,21 @@ export default function TrafficStatistics() {
|
||||
{/* 流量占比图 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("trafficRatio", "Upload/Download Ratio")}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t("trafficRatio", "Upload/Download Ratio")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex h-[300px] items-center justify-center">
|
||||
<Icon className="size-8 animate-spin" icon="uil:spinner" />
|
||||
</div>
|
||||
) : trafficStats && (trafficStats.total_upload > 0 || trafficStats.total_download > 0) ? (
|
||||
) : trafficStats &&
|
||||
(trafficStats.total_upload > 0 ||
|
||||
trafficStats.total_download > 0) ? (
|
||||
<TrafficRatioChart
|
||||
upload={trafficStats.total_upload}
|
||||
download={trafficStats.total_download}
|
||||
upload={trafficStats.total_upload}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
@@ -129,4 +154,4 @@ export default function TrafficStatistics() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
|
||||
import {
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
interface TrafficRatioChartProps {
|
||||
upload: number;
|
||||
download: number;
|
||||
}
|
||||
|
||||
export default function TrafficRatioChart({ upload, download }: TrafficRatioChartProps) {
|
||||
export default function TrafficRatioChart({
|
||||
upload,
|
||||
download,
|
||||
}: TrafficRatioChartProps) {
|
||||
const { t } = useTranslation("traffic");
|
||||
|
||||
const data = [
|
||||
@@ -31,20 +41,22 @@ export default function TrafficRatioChart({ upload, download }: TrafficRatioChar
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer height={300} width="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(1)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
data={data}
|
||||
dataKey="value"
|
||||
fill="#8884d8"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${(percent * 100).toFixed(1)}%`
|
||||
}
|
||||
labelLine={false}
|
||||
outerRadius={80}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
<Cell fill={COLORS[index % COLORS.length]} key={`cell-${index}`} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value: number) => formatTraffic(value)} />
|
||||
|
||||
@@ -38,7 +38,9 @@ export default function TrafficStatsCards({ stats }: TrafficStatsCardsProps) {
|
||||
<Card key={card.title}>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-sm">{card.title}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{card.title}
|
||||
</span>
|
||||
<span className="font-bold text-2xl">
|
||||
<Display type="traffic" value={card.value} />
|
||||
</span>
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { GetUserTrafficStatsResponse } from "@workspace/ui/services/user/tr
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LineChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
interface TrafficTrendChartProps {
|
||||
@@ -28,7 +28,8 @@ export default function TrafficTrendChart({ data }: TrafficTrendChartProps) {
|
||||
|
||||
// 格式化流量显示
|
||||
const formatTraffic = (value: number | string) => {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
const numValue =
|
||||
typeof value === "string" ? Number.parseFloat(value) : value;
|
||||
if (numValue >= 1024) {
|
||||
return `${(numValue / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -36,44 +37,52 @@ export default function TrafficTrendChart({ data }: TrafficTrendChartProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer height={300} width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
label={{ value: t("date", "Date"), position: "insideBottom", offset: -5 }}
|
||||
label={{
|
||||
value: t("date", "Date"),
|
||||
position: "insideBottom",
|
||||
offset: -5,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
label={{ value: t("traffic", "Traffic (MB)"), angle: -90, position: "insideLeft" }}
|
||||
label={{
|
||||
value: t("traffic", "Traffic (MB)"),
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | string) => formatTraffic(value)}
|
||||
labelStyle={{ color: "#000" }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
verticalAlign="bottom"
|
||||
wrapperStyle={{
|
||||
position: "absolute",
|
||||
width: "444px",
|
||||
height: "36px",
|
||||
left: "5px",
|
||||
bottom: "-5px"
|
||||
bottom: "-5px",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="upload"
|
||||
stroke="#10b981"
|
||||
name={t("upload", "Upload")}
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="download"
|
||||
stroke="#3b82f6"
|
||||
name={t("download", "Download")}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
Reference in New Issue
Block a user