feat: Authentication verification code

This commit is contained in:
EUForest 2026-03-10 19:03:39 +08:00
parent 4b4edd48e3
commit eac7b27f60
30 changed files with 805 additions and 130 deletions

View File

@ -14,7 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
"@lottiefiles/dotlottie-react": "^0.17.7", "@lottiefiles/dotlottie-react": "^0.17.15",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@stripe/react-stripe-js": "^5.4.0", "@stripe/react-stripe-js": "^5.4.0",
"@stripe/stripe-js": "^8.5.2", "@stripe/stripe-js": "^8.5.2",

View File

@ -1,4 +1,11 @@
{ {
"captcha": {
"clickToRefresh": "Click to refresh",
"noImage": "No Image",
"placeholder": "Enter captcha code...",
"refresh": "Refresh captcha",
"required": "Please enter captcha code"
},
"check": { "check": {
"description": "Verify your identity", "description": "Verify your identity",
"title": "Verify" "title": "Verify"

View File

@ -104,13 +104,20 @@
}, },
"userSecuritySettings": "User & Security", "userSecuritySettings": "User & Security",
"verify": { "verify": {
"description": "Configure Turnstile CAPTCHA and verification settings", "captchaType": "Captcha Type",
"enableLoginVerify": "Enable Verification on Login", "captchaTypeDescription": "Choose between local image captcha (offline) or Cloudflare Turnstile",
"enableLoginVerifyDescription": "When enabled, users must pass human verification during login", "captchaTypeLocal": "Local Image Captcha",
"enablePasswordVerify": "Enable Verification on Password Reset", "captchaTypePlaceholder": "Select captcha type",
"enablePasswordVerifyDescription": "When enabled, users must pass human verification during password reset", "captchaTypeTurnstile": "Cloudflare Turnstile",
"enableRegisterVerify": "Enable Verification on Registration", "description": "Configure captcha type and verification settings",
"enableRegisterVerifyDescription": "When enabled, users must pass human verification during registration", "enableAdminLoginCaptcha": "Enable Admin Authentication Captcha",
"enableAdminLoginCaptchaDescription": "When enabled, administrators must pass captcha verification during login or password reset",
"enableUserLoginCaptcha": "Enable User Login Captcha",
"enableUserLoginCaptchaDescription": "When enabled, users must pass captcha verification during login",
"enableUserRegisterCaptcha": "Enable User Registration Captcha",
"enableUserRegisterCaptchaDescription": "When enabled, users must pass captcha verification during registration",
"enableUserResetPasswordCaptcha": "Enable User Password Reset Captcha",
"enableUserResetPasswordCaptchaDescription": "When enabled, users must pass captcha verification during password reset",
"saveFailed": "Save Failed", "saveFailed": "Save Failed",
"saveSuccess": "Save Successful", "saveSuccess": "Save Successful",
"title": "Security Verification", "title": "Security Verification",

View File

@ -147,5 +147,6 @@
"address": "Address", "address": "Address",
"noNodesAvailable": "No nodes available", "noNodesAvailable": "No nodes available",
"nodeGroup": "Node Group", "nodeGroup": "Node Group",
"publicNodes": "Public Nodes" "publicNodes": "Public Nodes",
"subscriptionNodes": "Subscription Nodes"
} }

View File

@ -1,4 +1,11 @@
{ {
"captcha": {
"clickToRefresh": "点击刷新",
"noImage": "无图片",
"placeholder": "请输入验证码...",
"refresh": "刷新验证码",
"required": "请输入验证码"
},
"check": { "check": {
"description": "验证您的身份", "description": "验证您的身份",
"title": "验证" "title": "验证"

View File

@ -104,13 +104,20 @@
}, },
"userSecuritySettings": "用户与安全", "userSecuritySettings": "用户与安全",
"verify": { "verify": {
"description": "配置 Turnstile 验证码和验证设置", "captchaType": "验证码类型",
"enableLoginVerify": "登录验证", "captchaTypeDescription": "选择本地图形验证码(离线)或 Cloudflare Turnstile",
"enableLoginVerifyDescription": "启用后,用户登录时必须通过人机验证", "captchaTypeLocal": "本地图形验证码",
"enablePasswordVerify": "密码重置验证", "captchaTypePlaceholder": "选择验证码类型",
"enablePasswordVerifyDescription": "启用后,用户重置密码时必须通过人机验证", "captchaTypeTurnstile": "Cloudflare Turnstile",
"enableRegisterVerify": "注册验证", "description": "配置验证码类型和验证设置",
"enableRegisterVerifyDescription": "启用后,用户注册时必须通过人机验证", "enableAdminLoginCaptcha": "启用管理端认证验证码",
"enableAdminLoginCaptchaDescription": "启用后,管理员登录或重置密码时必须通过验证码验证",
"enableUserLoginCaptcha": "启用用户端登录验证码",
"enableUserLoginCaptchaDescription": "启用后,用户登录时必须通过验证码验证",
"enableUserRegisterCaptcha": "启用用户端注册验证码",
"enableUserRegisterCaptchaDescription": "启用后,用户注册时必须通过验证码验证",
"enableUserResetPasswordCaptcha": "启用用户重置密码验证码",
"enableUserResetPasswordCaptchaDescription": "启用后,用户重置密码时必须通过验证码验证",
"saveFailed": "保存失败", "saveFailed": "保存失败",
"saveSuccess": "保存成功", "saveSuccess": "保存成功",
"title": "安全验证", "title": "安全验证",

View File

@ -147,5 +147,6 @@
"address": "地址", "address": "地址",
"noNodesAvailable": "无可用节点", "noNodesAvailable": "无可用节点",
"nodeGroup": "节点组", "nodeGroup": "节点组",
"publicNodes": "公共节点" "publicNodes": "公共节点",
"subscriptionNodes": "套餐节点"
} }

View File

@ -10,12 +10,13 @@ import {
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { useGlobalStore } from "@/stores/global"; import { useGlobalStore } from "@/stores/global";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile"; import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({ export default function LoginForm({
loading, loading,
@ -33,14 +34,25 @@ export default function LoginForm({
const { t } = useTranslation("auth"); const { t } = useTranslation("auth");
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify } = common; const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_admin_login_captcha;
const formSchema = z.object({ const formSchema = z.object({
email: z.email(t("login.email", "Email")), email: z
.string()
.email(t("login.email", "Please enter a valid email address")),
password: z.string(), password: z.string(),
cf_token: cf_token:
verify.enable_login_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().optional(), : z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -48,11 +60,17 @@ export default function LoginForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -98,7 +116,7 @@ export default function LoginForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_login_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -116,6 +134,24 @@ export default function LoginForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")} {t("login.title", "Login")}

View File

@ -10,13 +10,14 @@ import {
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { useGlobalStore } from "@/stores/global"; import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code"; import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile"; import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({ export default function ResetForm({
loading, loading,
@ -35,15 +36,26 @@ export default function ResetForm({
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, auth } = common; const { verify, auth } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({ const formSchema = z.object({
email: z.email(t("reset.email", "Email")), email: z
.string()
.email(t("reset.email", "Please enter a valid email address")),
password: z.string(), password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(), code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().nullish(), : z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -51,11 +63,17 @@ export default function ResetForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -128,7 +146,7 @@ export default function ResetForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_reset_password_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -146,6 +164,24 @@ export default function ResetForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")} {t("reset.title", "Reset Password")}

View File

@ -0,0 +1,95 @@
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 { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
reset: () => void;
}
interface LocalCaptchaProps {
value?: string | null;
onChange?: (value: string) => void;
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 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(() => {
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>
</div>
);
}
);
LocalCaptcha.displayName = "LocalCaptcha";
export default LocalCaptcha;

View File

@ -11,6 +11,13 @@ import {
FormMessage, FormMessage,
} from "@workspace/ui/components/form"; } from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area"; import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -33,11 +40,13 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const verifySchema = z.object({ const verifySchema = z.object({
captcha_type: z.string().optional(),
turnstile_site_key: z.string().optional(), turnstile_site_key: z.string().optional(),
turnstile_secret: z.string().optional(), turnstile_secret: z.string().optional(),
enable_register_verify: z.boolean().optional(), enable_user_login_captcha: z.boolean().optional(),
enable_login_verify: z.boolean().optional(), enable_user_register_captcha: z.boolean().optional(),
enable_reset_password_verify: z.boolean().optional(), enable_admin_login_captcha: z.boolean().optional(),
enable_user_reset_password_captcha: z.boolean().optional(),
}); });
type VerifyFormData = z.infer<typeof verifySchema>; type VerifyFormData = z.infer<typeof verifySchema>;
@ -59,11 +68,13 @@ export default function VerifyConfig() {
const form = useForm<VerifyFormData>({ const form = useForm<VerifyFormData>({
resolver: zodResolver(verifySchema), resolver: zodResolver(verifySchema),
defaultValues: { defaultValues: {
captcha_type: "local",
turnstile_site_key: "", turnstile_site_key: "",
turnstile_secret: "", turnstile_secret: "",
enable_register_verify: false, enable_user_login_captcha: false,
enable_login_verify: false, enable_user_register_captcha: false,
enable_reset_password_verify: false, enable_admin_login_captcha: false,
enable_user_reset_password_captcha: false,
}, },
}); });
@ -126,26 +137,42 @@ export default function VerifyConfig() {
> >
<FormField <FormField
control={form.control} control={form.control}
name="turnstile_site_key" name="captcha_type"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")} {t("verify.captchaType", "Captcha Type")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<EnhancedInput <Select
onValueChange={field.onChange} onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value} value={field.value}
/> >
<SelectTrigger>
<SelectValue
placeholder={t(
"verify.captchaTypePlaceholder",
"Select captcha type"
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
{t("verify.captchaTypeLocal", "Local Image Captcha")}
</SelectItem>
<SelectItem value="turnstile">
{t(
"verify.captchaTypeTurnstile",
"Cloudflare Turnstile"
)}
</SelectItem>
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
"verify.turnstileSiteKeyDescription", "verify.captchaTypeDescription",
"Cloudflare Turnstile site key for frontend verification" "Choose between local image captcha (offline) or Cloudflare Turnstile"
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -153,45 +180,78 @@ export default function VerifyConfig() {
)} )}
/> />
<FormField {form.watch("captcha_type") === "turnstile" && (
control={form.control} <>
name="turnstile_secret" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="turnstile_site_key"
<FormLabel> render={({ field }) => (
{t("verify.turnstileSecret", "Turnstile Secret Key")} <FormItem>
</FormLabel> <FormLabel>
<FormControl> {t("verify.turnstileSiteKey", "Turnstile Site Key")}
<EnhancedInput </FormLabel>
onValueChange={field.onChange} <FormControl>
placeholder={t( <EnhancedInput
"verify.turnstileSecretPlaceholder", onValueChange={field.onChange}
"Enter Turnstile secret key" placeholder={t(
)} "verify.turnstileSiteKeyPlaceholder",
type="password" "Enter Turnstile site key"
value={field.value} )}
/> value={field.value}
</FormControl> />
<FormDescription> </FormControl>
{t( <FormDescription>
"verify.turnstileSecretDescription", {t(
"Cloudflare Turnstile secret key for backend verification" "verify.turnstileSiteKeyDescription",
)} "Cloudflare Turnstile site key for frontend verification"
</FormDescription> )}
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<FormField
control={form.control}
name="turnstile_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSecret", "Turnstile Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSecretPlaceholder",
"Enter Turnstile secret key"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSecretDescription",
"Cloudflare Turnstile secret key for backend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField <FormField
control={form.control} control={form.control}
name="enable_register_verify" name="enable_user_login_captcha"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
"verify.enableRegisterVerify", "verify.enableUserLoginCaptcha",
"Enable Verification on Registration" "Enable User Login Captcha"
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
@ -203,8 +263,8 @@ export default function VerifyConfig() {
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
"verify.enableRegisterVerifyDescription", "verify.enableUserLoginCaptchaDescription",
"When enabled, users must pass human verification during registration" "When enabled, users must pass captcha verification during login"
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -214,13 +274,13 @@ export default function VerifyConfig() {
<FormField <FormField
control={form.control} control={form.control}
name="enable_login_verify" name="enable_user_register_captcha"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
"verify.enableLoginVerify", "verify.enableUserRegisterCaptcha",
"Enable Verification on Login" "Enable User Registration Captcha"
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
@ -232,8 +292,8 @@ export default function VerifyConfig() {
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
"verify.enableLoginVerifyDescription", "verify.enableUserRegisterCaptchaDescription",
"When enabled, users must pass human verification during login" "When enabled, users must pass captcha verification during registration"
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -243,13 +303,13 @@ export default function VerifyConfig() {
<FormField <FormField
control={form.control} control={form.control}
name="enable_reset_password_verify" name="enable_user_reset_password_captcha"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
"verify.enablePasswordVerify", "verify.enableUserResetPasswordCaptcha",
"Enable Verification on Password Reset" "Enable User Password Reset Captcha"
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
@ -261,8 +321,37 @@ export default function VerifyConfig() {
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t( {t(
"verify.enablePasswordVerifyDescription", "verify.enableUserResetPasswordCaptchaDescription",
"When enabled, users must pass human verification during password reset" "When enabled, users must pass captcha verification during password reset"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_admin_login_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableAdminLoginCaptcha",
"Enable Admin Authentication Captcha"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"verify.enableAdminLoginCaptchaDescription",
"When enabled, administrators must pass captcha verification during login"
)} )}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />

View File

@ -477,7 +477,12 @@ function PreviewNodesDialog({ userId }: { userId: number }) {
{previewData.node_groups.map((group) => ( {previewData.node_groups.map((group) => (
<div key={group.id}> <div key={group.id}>
<h4 className="text-sm font-semibold mb-2"> <h4 className="text-sm font-semibold mb-2">
{group.name || (group.id === 0 ? t("publicNodes", "Public Nodes") : `${t("nodeGroup", "Node Group")} ${group.id}`)} {group.name ||
(group.id === -1
? t("subscriptionNodes", "Subscription Nodes")
: group.id === 0
? t("publicNodes", "Public Nodes")
: `${t("nodeGroup", "Node Group")} ${group.id}`)}
</h4> </h4>
{group.nodes && group.nodes.length > 0 ? ( {group.nodes && group.nodes.length > 0 ? (
<table className="w-full text-sm"> <table className="w-full text-sm">

View File

@ -49,9 +49,14 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
}, },
verify: { verify: {
turnstile_site_key: "", turnstile_site_key: "",
captcha_type: "turnstile",
enable_login_verify: false, enable_login_verify: false,
enable_register_verify: false, enable_register_verify: false,
enable_reset_password_verify: false, enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_user_reset_password_captcha: false,
enable_admin_login_captcha: false,
}, },
auth: { auth: {
mobile: { mobile: {

View File

@ -14,7 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
"@lottiefiles/dotlottie-react": "^0.17.7", "@lottiefiles/dotlottie-react": "^0.17.15",
"@stripe/react-stripe-js": "^5.4.0", "@stripe/react-stripe-js": "^5.4.0",
"@stripe/stripe-js": "^8.5.2", "@stripe/stripe-js": "^8.5.2",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",

Binary file not shown.

View File

@ -1,12 +1,21 @@
{ {
"authenticating": "Authenticating...", "authenticating": "Authenticating...",
"binding": "Binding account...", "binding": "Binding account...",
"captcha": {
"clickToRefresh": "Click to refresh",
"noImage": "No Image",
"placeholder": "Enter captcha code...",
"refresh": "Refresh captcha",
"required": "Please enter captcha code"
},
"get": "Get Code", "get": "Get Code",
"login": { "login": {
"codeLogin": "Login with Code", "codeLogin": "Login with Code",
"email": "Please enter a valid email address", "email": "Please enter a valid email address",
"emailPlaceholder": "Enter your email...",
"forgotPassword": "Forgot Password?", "forgotPassword": "Forgot Password?",
"passwordLogin": "Login with Password", "passwordLogin": "Login with Password",
"passwordPlaceholder": "Enter your password...",
"registerAccount": "Register Account", "registerAccount": "Register Account",
"success": "Login successful!", "success": "Login successful!",
"title": "Login" "title": "Login"
@ -17,19 +26,28 @@
}, },
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Privacy Policy",
"register": { "register": {
"areaCodePlaceholder": "Area code...",
"codePlaceholder": "Enter code...",
"email": "Please enter a valid email address", "email": "Please enter a valid email address",
"emailPlaceholder": "Enter your email...",
"existingAccount": "Already have an account?", "existingAccount": "Already have an account?",
"invite": "Invitation Code (Optional)", "invite": "Invitation Code (Optional)",
"message": "Registration is currently disabled", "message": "Registration is currently disabled",
"passwordMismatch": "Passwords do not match", "passwordMismatch": "Passwords do not match",
"passwordPlaceholder": "Enter your password...",
"repeatPasswordPlaceholder": "Enter password again...",
"success": "Registration successful!", "success": "Registration successful!",
"switchToLogin": "Login", "switchToLogin": "Login",
"telephonePlaceholder": "Enter your telephone...",
"title": "Register", "title": "Register",
"whitelist": "This email domain is not in the whitelist" "whitelist": "This email domain is not in the whitelist"
}, },
"reset": { "reset": {
"codePlaceholder": "Enter code...",
"email": "Please enter a valid email address", "email": "Please enter a valid email address",
"emailPlaceholder": "Enter your email...",
"existingAccount": "Remember your password?", "existingAccount": "Remember your password?",
"passwordPlaceholder": "Enter your new password...",
"success": "Password reset successful!", "success": "Password reset successful!",
"switchToLogin": "Login", "switchToLogin": "Login",
"title": "Reset Password" "title": "Reset Password"

View File

@ -1,12 +1,21 @@
{ {
"authenticating": "正在认证...", "authenticating": "正在认证...",
"binding": "正在绑定账号...", "binding": "正在绑定账号...",
"captcha": {
"clickToRefresh": "点击刷新",
"noImage": "无图片",
"placeholder": "请输入验证码...",
"refresh": "刷新验证码",
"required": "请输入验证码"
},
"get": "获取验证码", "get": "获取验证码",
"login": { "login": {
"codeLogin": "验证码登录", "codeLogin": "验证码登录",
"email": "请输入有效的邮箱地址", "email": "请输入有效的邮箱地址",
"emailPlaceholder": "请输入邮箱...",
"forgotPassword": "忘记密码?", "forgotPassword": "忘记密码?",
"passwordLogin": "密码登录", "passwordLogin": "密码登录",
"passwordPlaceholder": "请输入密码...",
"registerAccount": "注册账号", "registerAccount": "注册账号",
"success": "登录成功!", "success": "登录成功!",
"title": "登录" "title": "登录"
@ -17,19 +26,28 @@
}, },
"privacyPolicy": "隐私政策", "privacyPolicy": "隐私政策",
"register": { "register": {
"areaCodePlaceholder": "区号...",
"codePlaceholder": "请输入验证码...",
"email": "请输入有效的邮箱地址", "email": "请输入有效的邮箱地址",
"emailPlaceholder": "请输入邮箱...",
"existingAccount": "已有账号?", "existingAccount": "已有账号?",
"invite": "邀请码(可选)", "invite": "邀请码(可选)",
"message": "注册功能暂时不可用", "message": "注册功能暂时不可用",
"passwordMismatch": "两次密码输入不一致", "passwordMismatch": "两次密码输入不一致",
"passwordPlaceholder": "请输入密码...",
"repeatPasswordPlaceholder": "请再次输入密码...",
"success": "注册成功!", "success": "注册成功!",
"switchToLogin": "登录", "switchToLogin": "登录",
"telephonePlaceholder": "请输入手机号...",
"title": "注册", "title": "注册",
"whitelist": "该邮箱域名不在白名单中" "whitelist": "该邮箱域名不在白名单中"
}, },
"reset": { "reset": {
"codePlaceholder": "请输入验证码...",
"email": "请输入有效的邮箱地址", "email": "请输入有效的邮箱地址",
"emailPlaceholder": "请输入邮箱...",
"existingAccount": "记得密码了?", "existingAccount": "记得密码了?",
"passwordPlaceholder": "请输入新密码...",
"success": "密码重置成功!", "success": "密码重置成功!",
"switchToLogin": "登录", "switchToLogin": "登录",
"title": "重置密码" "title": "重置密码"

View File

@ -10,13 +10,14 @@ import {
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { useGlobalStore } from "@/stores/global"; import { useGlobalStore } from "@/stores/global";
import type { TurnstileRef } from "../turnstile"; import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile"; import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({ export default function LoginForm({
loading, loading,
@ -34,14 +35,23 @@ export default function LoginForm({
const { t } = useTranslation("auth"); const { t } = useTranslation("auth");
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify } = common; const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_login_captcha;
const formSchema = z.object({ const formSchema = z.object({
email: z.email(t("login.email", "Please enter a valid email address")), email: z.email(t("login.email", "Please enter a valid email address")),
password: z.string(), password: z.string(),
cf_token: cf_token:
verify.enable_login_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().optional(), : z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -49,11 +59,17 @@ export default function LoginForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -68,7 +84,7 @@ export default function LoginForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your email..." placeholder={t("login.emailPlaceholder", "Enter your email...")}
type="email" type="email"
{...field} {...field}
/> />
@ -84,7 +100,7 @@ export default function LoginForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your password..." placeholder={t("login.passwordPlaceholder", "Enter your password...")}
type="password" type="password"
{...field} {...field}
/> />
@ -93,7 +109,7 @@ export default function LoginForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_login_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -111,6 +127,24 @@ export default function LoginForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")} {t("login.title", "Login")}

View File

@ -11,7 +11,7 @@ import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown"; import { Markdown } from "@workspace/ui/composed/markdown";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
@ -19,6 +19,7 @@ import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code"; import SendCode from "../send-code";
import type { TurnstileRef } from "../turnstile"; import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile"; import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function RegisterForm({ export default function RegisterForm({
loading, loading,
@ -36,6 +37,11 @@ export default function RegisterForm({
const { t } = useTranslation("auth"); const { t } = useTranslation("auth");
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, auth, invite } = common; const { verify, auth, invite } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_register_captcha;
const handleCheckUser = async (email: string) => { const handleCheckUser = async (email: string) => {
try { try {
@ -67,9 +73,13 @@ export default function RegisterForm({
code: auth.email.enable_verify ? z.string() : z.string().nullish(), code: auth.email.enable_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(), invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().nullish(), : z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
}) })
.superRefine(({ password, repeat_password }, ctx) => { .superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) { if (password !== repeat_password) {
@ -90,11 +100,17 @@ export default function RegisterForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -114,7 +130,7 @@ export default function RegisterForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your email..." placeholder={t("register.emailPlaceholder", "Enter your email...")}
type="email" type="email"
{...field} {...field}
/> />
@ -130,7 +146,7 @@ export default function RegisterForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your password..." placeholder={t("register.passwordPlaceholder", "Enter your password...")}
type="password" type="password"
{...field} {...field}
/> />
@ -147,7 +163,7 @@ export default function RegisterForm({
<FormControl> <FormControl>
<Input <Input
disabled={loading} disabled={loading}
placeholder="Enter password again..." placeholder={t("register.repeatPasswordPlaceholder", "Enter password again...")}
type="password" type="password"
{...field} {...field}
/> />
@ -166,7 +182,7 @@ export default function RegisterForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
disabled={loading} disabled={loading}
placeholder="Enter code..." placeholder={t("register.codePlaceholder", "Enter code...")}
type="text" type="text"
{...field} {...field}
value={field.value as string} value={field.value as string}
@ -205,7 +221,7 @@ export default function RegisterForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_register_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -223,6 +239,24 @@ export default function RegisterForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")} {t("register.title", "Register")}

View File

@ -10,7 +10,7 @@ import {
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
@ -18,6 +18,7 @@ import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code"; import SendCode from "../send-code";
import type { TurnstileRef } from "../turnstile"; import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile"; import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({ export default function ResetForm({
loading, loading,
@ -36,6 +37,11 @@ export default function ResetForm({
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, auth } = common; const { verify, auth } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@ -44,9 +50,13 @@ export default function ResetForm({
password: z.string(), password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(), code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().nullish(), : z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -54,11 +64,17 @@ export default function ResetForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -73,7 +89,7 @@ export default function ResetForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your email..." placeholder={t("reset.emailPlaceholder", "Enter your email...")}
type="email" type="email"
{...field} {...field}
/> />
@ -91,7 +107,7 @@ export default function ResetForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
disabled={loading} disabled={loading}
placeholder="Enter code..." placeholder={t("reset.codePlaceholder", "Enter code...")}
type="text" type="text"
{...field} {...field}
value={field.value as string} value={field.value as string}
@ -116,7 +132,7 @@ export default function ResetForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your new password..." placeholder={t("reset.passwordPlaceholder", "Enter your new password...")}
type="password" type="password"
{...field} {...field}
/> />
@ -125,7 +141,7 @@ export default function ResetForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_reset_password_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -143,6 +159,24 @@ export default function ResetForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")} {t("reset.title", "Reset Password")}

View File

@ -0,0 +1,95 @@
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 { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
reset: () => void;
}
interface LocalCaptchaProps {
value?: string | null;
onChange?: (value: string) => void;
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 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(() => {
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>
</div>
);
}
);
LocalCaptcha.displayName = "LocalCaptcha";
export default LocalCaptcha;

View File

@ -19,6 +19,7 @@ import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code"; import SendCode from "../send-code";
import type { TurnstileRef } from "../turnstile"; import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile"; import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({ export default function LoginForm({
loading, loading,
@ -34,6 +35,11 @@ export default function LoginForm({
const { t } = useTranslation("auth"); const { t } = useTranslation("auth");
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify } = common; const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_login_captcha;
const formSchema = z.object({ const formSchema = z.object({
telephone_area_code: z.string(), telephone_area_code: z.string(),
@ -41,9 +47,13 @@ export default function LoginForm({
telephone_code: z.string().optional(), telephone_code: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
cf_token: cf_token:
verify.enable_login_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().optional(), : z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -53,11 +63,17 @@ export default function LoginForm({
const [mode, setMode] = useState<"password" | "code">("password"); const [mode, setMode] = useState<"password" | "code">("password");
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -88,7 +104,7 @@ export default function LoginForm({
); );
} }
}} }}
placeholder="Area code..." placeholder={t("register.areaCodePlaceholder", "Area code...")}
simple simple
value={field.value} value={field.value}
/> />
@ -99,7 +115,7 @@ export default function LoginForm({
/> />
<Input <Input
className="rounded-l-none" className="rounded-l-none"
placeholder="Enter your telephone..." placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
type="tel" type="tel"
{...field} {...field}
/> />
@ -119,7 +135,9 @@ export default function LoginForm({
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
placeholder={ placeholder={
mode === "code" ? "Enter code..." : "Enter password..." mode === "code"
? t("register.codePlaceholder", "Enter code...")
: t("login.passwordPlaceholder", "Enter your password...")
} }
type={mode === "code" ? "text" : "password"} type={mode === "code" ? "text" : "password"}
{...field} {...field}
@ -157,7 +175,7 @@ export default function LoginForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_login_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -175,6 +193,24 @@ export default function LoginForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")} {t("login.title", "Login")}

View File

@ -12,7 +12,7 @@ import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown"; import { Markdown } from "@workspace/ui/composed/markdown";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
@ -20,6 +20,7 @@ import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code"; import SendCode from "../send-code";
import type { TurnstileRef } from "../turnstile"; import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile"; import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function RegisterForm({ export default function RegisterForm({
loading, loading,
@ -36,6 +37,11 @@ export default function RegisterForm({
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, auth, invite } = common; const { verify, auth, invite } = common;
const { enable_whitelist, whitelist } = auth.mobile; const { enable_whitelist, whitelist } = auth.mobile;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_register_captcha;
const formSchema = z const formSchema = z
.object({ .object({
@ -46,9 +52,13 @@ export default function RegisterForm({
code: z.string(), code: z.string(),
invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(), invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().nullish(), : z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
}) })
.superRefine(({ password, repeat_password }, ctx) => { .superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) { if (password !== repeat_password) {
@ -70,11 +80,17 @@ export default function RegisterForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -110,7 +126,7 @@ export default function RegisterForm({
); );
} }
}} }}
placeholder="Area code..." placeholder={t("register.areaCodePlaceholder", "Area code...")}
simple simple
value={field.value} value={field.value}
whitelist={enable_whitelist ? whitelist : []} whitelist={enable_whitelist ? whitelist : []}
@ -122,7 +138,7 @@ export default function RegisterForm({
/> />
<Input <Input
className="rounded-l-none" className="rounded-l-none"
placeholder="Enter your telephone..." placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
type="tel" type="tel"
{...field} {...field}
/> />
@ -139,7 +155,7 @@ export default function RegisterForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your password..." placeholder={t("register.passwordPlaceholder", "Enter your password...")}
type="password" type="password"
{...field} {...field}
/> />
@ -156,7 +172,7 @@ export default function RegisterForm({
<FormControl> <FormControl>
<Input <Input
disabled={loading} disabled={loading}
placeholder="Enter password again..." placeholder={t("register.repeatPasswordPlaceholder", "Enter password again...")}
type="password" type="password"
{...field} {...field}
/> />
@ -174,7 +190,7 @@ export default function RegisterForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
disabled={loading} disabled={loading}
placeholder="Enter code..." placeholder={t("register.codePlaceholder", "Enter code...")}
type="text" type="text"
{...field} {...field}
value={field.value as string} value={field.value as string}
@ -216,7 +232,7 @@ export default function RegisterForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_register_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -234,6 +250,24 @@ export default function RegisterForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")} {t("register.title", "Register")}

View File

@ -11,7 +11,7 @@ import { Input } from "@workspace/ui/components/input";
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select"; import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
@ -19,6 +19,7 @@ import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code"; import SendCode from "../send-code";
import type { TurnstileRef } from "../turnstile"; import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile"; import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({ export default function ResetForm({
loading, loading,
@ -36,6 +37,11 @@ export default function ResetForm({
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, auth } = common; const { verify, auth } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({ const formSchema = z.object({
telephone_area_code: z.string(), telephone_area_code: z.string(),
@ -43,9 +49,13 @@ export default function ResetForm({
password: z.string(), password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(), code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string() ? z.string()
: z.string().nullish(), : z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@ -53,11 +63,17 @@ export default function ResetForm({
}); });
const turnstile = useRef<TurnstileRef>(null); const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
try { try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data); onSubmit(data);
} catch (_error) { } catch (_error) {
turnstile.current?.reset(); turnstile.current?.reset();
localCaptcha.current?.reset();
} }
}); });
@ -88,7 +104,7 @@ export default function ResetForm({
); );
} }
}} }}
placeholder="Area code..." placeholder={t("register.areaCodePlaceholder", "Area code...")}
simple simple
value={field.value} value={field.value}
/> />
@ -99,7 +115,7 @@ export default function ResetForm({
/> />
<Input <Input
className="rounded-l-none" className="rounded-l-none"
placeholder="Enter your telephone..." placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
type="tel" type="tel"
{...field} {...field}
/> />
@ -117,7 +133,7 @@ export default function ResetForm({
<FormControl> <FormControl>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="Enter code..." placeholder={t("register.codePlaceholder", "Enter code...")}
type="text" type="text"
{...field} {...field}
value={field.value as string} value={field.value as string}
@ -143,7 +159,7 @@ export default function ResetForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter your new password..." placeholder={t("reset.passwordPlaceholder", "Enter your new password...")}
type="password" type="password"
{...field} {...field}
/> />
@ -152,7 +168,7 @@ export default function ResetForm({
</FormItem> </FormItem>
)} )}
/> />
{verify.enable_reset_password_verify && ( {captchaEnabled && isTurnstile && (
<FormField <FormField
control={form.control} control={form.control}
name="cf_token" name="cf_token"
@ -170,6 +186,24 @@ export default function ResetForm({
)} )}
/> />
)} )}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />} {loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")} {t("reset.title", "Reset Password")}

View File

@ -49,9 +49,14 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
}, },
verify: { verify: {
turnstile_site_key: "", turnstile_site_key: "",
captcha_type: "turnstile",
enable_login_verify: false, enable_login_verify: false,
enable_register_verify: false, enable_register_verify: false,
enable_reset_password_verify: false, enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_user_reset_password_captcha: false,
enable_admin_login_captcha: false,
}, },
auth: { auth: {
mobile: { mobile: {

View File

@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "shadcn-ui-monorepo", "name": "shadcn-ui-monorepo",
@ -30,7 +29,7 @@
"name": "ppanel-admin-web", "name": "ppanel-admin-web",
"dependencies": { "dependencies": {
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
"@lottiefiles/dotlottie-react": "^0.17.7", "@lottiefiles/dotlottie-react": "^0.17.15",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@stripe/react-stripe-js": "^5.4.0", "@stripe/react-stripe-js": "^5.4.0",
"@stripe/stripe-js": "^8.5.2", "@stripe/stripe-js": "^8.5.2",
@ -70,7 +69,7 @@
"name": "ppanel-user-web", "name": "ppanel-user-web",
"dependencies": { "dependencies": {
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
"@lottiefiles/dotlottie-react": "^0.17.7", "@lottiefiles/dotlottie-react": "^0.17.15",
"@stripe/react-stripe-js": "^5.4.0", "@stripe/react-stripe-js": "^5.4.0",
"@stripe/stripe-js": "^8.5.2", "@stripe/stripe-js": "^8.5.2",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
@ -548,9 +547,9 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.4" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.4" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@lottiefiles/dotlottie-react": ["@lottiefiles/dotlottie-react@0.17.7", "", { "dependencies": { "@lottiefiles/dotlottie-web": "0.56.0" }, "peerDependencies": { "react": "^17 || ^18 || ^19" } }, "sha512-A6wO3zqkDx/t0ULfctcr1Bmb1f1hc4zUV3NcbKQOsBGAOIx1vABV/fRabFYElvbJl9lmOR24yMh//Z0fvvJV+Q=="], "@lottiefiles/dotlottie-react": ["@lottiefiles/dotlottie-react@0.17.15", "", { "dependencies": { "@lottiefiles/dotlottie-web": "0.63.0" }, "peerDependencies": { "react": "^17 || ^18 || ^19" } }, "sha512-4wYAjsJhM28eUvJ/gT3KRM6fcyT7EM9n7PDrP71LaBTacc6bSN43qFTSJc1Li3QxUiraz23p0Q8EJBzXo8DsRw=="],
"@lottiefiles/dotlottie-web": ["@lottiefiles/dotlottie-web@0.56.0", "", {}, "sha512-bWHRIGzjZs3Hjkz0JRsCMX2ya9a1tGU4atdrlfM3UoN0iamsDE64kSCMfGuchCwGAxg0xEh84CkF+SVV1NU9ow=="], "@lottiefiles/dotlottie-web": ["@lottiefiles/dotlottie-web@0.63.0", "", {}, "sha512-oYIkvu6E4n8fZH7ciQsVqamlUDeBnd6JbNYa1UWC/npkNzEHqM5saL3vk/nNorqdfjYwdcdmhLtYbnuwVy+3/Q=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],

View File

@ -0,0 +1,17 @@
// @ts-nocheck
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Generate captcha POST /v1/auth/admin/captcha/generate */
export async function adminGenerateCaptcha(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GenerateCaptchaResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/auth/admin/captcha/generate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
...(options || {}),
}
);
}

View File

@ -5,6 +5,7 @@
import * as ads from "./ads"; import * as ads from "./ads";
import * as announcement from "./announcement"; import * as announcement from "./announcement";
import * as application from "./application"; import * as application from "./application";
import * as auth from "./auth";
import * as authMethod from "./authMethod"; import * as authMethod from "./authMethod";
import * as console from "./console"; import * as console from "./console";
import * as coupon from "./coupon"; import * as coupon from "./coupon";
@ -24,6 +25,7 @@ export default {
ads, ads,
announcement, announcement,
application, application,
auth,
authMethod, authMethod,
console, console,
coupon, coupon,

View File

@ -163,3 +163,17 @@ export async function telephoneResetPassword(
} }
); );
} }
/** Generate captcha POST /v1/auth/captcha/generate */
export async function generateCaptcha(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GenerateCaptchaResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/auth/captcha/generate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
...(options || {}),
}
);
}

View File

@ -1111,9 +1111,14 @@ declare namespace API {
type VeifyConfig = { type VeifyConfig = {
turnstile_site_key: string; turnstile_site_key: string;
captcha_type: string;
enable_login_verify: boolean; enable_login_verify: boolean;
enable_register_verify: boolean; enable_register_verify: boolean;
enable_reset_password_verify: boolean; enable_reset_password_verify: boolean;
enable_user_login_captcha: boolean;
enable_user_register_captcha: boolean;
enable_user_reset_password_captcha: boolean;
enable_admin_login_captcha: boolean;
}; };
type VerifyCodeConfig = { type VerifyCodeConfig = {