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
@@ -10,12 +10,13 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({
loading,
@@ -33,14 +34,25 @@ export default function LoginForm({
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
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({
email: z.email(t("login.email", "Email")),
email: z
.string()
.email(t("login.email", "Please enter a valid email address")),
password: z.string(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: 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>>({
resolver: zodResolver(formSchema),
@@ -48,11 +60,17 @@ export default function LoginForm({
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
}
});
@@ -98,7 +116,7 @@ export default function LoginForm({
</FormItem>
)}
/>
{verify.enable_login_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
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">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -10,13 +10,14 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({
loading,
@@ -35,15 +36,26 @@ export default function ResetForm({
const { common } = useGlobalStore();
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({
email: z.email(t("reset.email", "Email")),
email: z
.string()
.email(t("reset.email", "Please enter a valid email address")),
password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: 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>>({
resolver: zodResolver(formSchema),
@@ -51,11 +63,17 @@ export default function ResetForm({
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
}
});
@@ -128,7 +146,7 @@ export default function ResetForm({
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
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">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
@@ -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;
@@ -11,6 +11,13 @@ import {
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
@@ -33,11 +40,13 @@ import { toast } from "sonner";
import { z } from "zod";
const verifySchema = z.object({
captcha_type: z.string().optional(),
turnstile_site_key: z.string().optional(),
turnstile_secret: z.string().optional(),
enable_register_verify: z.boolean().optional(),
enable_login_verify: z.boolean().optional(),
enable_reset_password_verify: z.boolean().optional(),
enable_user_login_captcha: z.boolean().optional(),
enable_user_register_captcha: z.boolean().optional(),
enable_admin_login_captcha: z.boolean().optional(),
enable_user_reset_password_captcha: z.boolean().optional(),
});
type VerifyFormData = z.infer<typeof verifySchema>;
@@ -59,11 +68,13 @@ export default function VerifyConfig() {
const form = useForm<VerifyFormData>({
resolver: zodResolver(verifySchema),
defaultValues: {
captcha_type: "local",
turnstile_site_key: "",
turnstile_secret: "",
enable_register_verify: false,
enable_login_verify: false,
enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_admin_login_captcha: false,
enable_user_reset_password_captcha: false,
},
});
@@ -126,26 +137,42 @@ export default function VerifyConfig() {
>
<FormField
control={form.control}
name="turnstile_site_key"
name="captcha_type"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
{t("verify.captchaType", "Captcha Type")}
</FormLabel>
<FormControl>
<EnhancedInput
<Select
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
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>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
"verify.captchaTypeDescription",
"Choose between local image captcha (offline) or Cloudflare Turnstile"
)}
</FormDescription>
<FormMessage />
@@ -153,45 +180,78 @@ export default function VerifyConfig() {
)}
/>
<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>
)}
/>
{form.watch("captcha_type") === "turnstile" && (
<>
<FormField
control={form.control}
name="turnstile_site_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
)}
</FormDescription>
<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
control={form.control}
name="enable_register_verify"
name="enable_user_login_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableRegisterVerify",
"Enable Verification on Registration"
"verify.enableUserLoginCaptcha",
"Enable User Login Captcha"
)}
</FormLabel>
<FormControl>
@@ -203,8 +263,8 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enableRegisterVerifyDescription",
"When enabled, users must pass human verification during registration"
"verify.enableUserLoginCaptchaDescription",
"When enabled, users must pass captcha verification during login"
)}
</FormDescription>
<FormMessage />
@@ -214,13 +274,13 @@ export default function VerifyConfig() {
<FormField
control={form.control}
name="enable_login_verify"
name="enable_user_register_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableLoginVerify",
"Enable Verification on Login"
"verify.enableUserRegisterCaptcha",
"Enable User Registration Captcha"
)}
</FormLabel>
<FormControl>
@@ -232,8 +292,8 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enableLoginVerifyDescription",
"When enabled, users must pass human verification during login"
"verify.enableUserRegisterCaptchaDescription",
"When enabled, users must pass captcha verification during registration"
)}
</FormDescription>
<FormMessage />
@@ -243,13 +303,13 @@ export default function VerifyConfig() {
<FormField
control={form.control}
name="enable_reset_password_verify"
name="enable_user_reset_password_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enablePasswordVerify",
"Enable Verification on Password Reset"
"verify.enableUserResetPasswordCaptcha",
"Enable User Password Reset Captcha"
)}
</FormLabel>
<FormControl>
@@ -261,8 +321,37 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enablePasswordVerifyDescription",
"When enabled, users must pass human verification during password reset"
"verify.enableUserResetPasswordCaptchaDescription",
"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>
<FormMessage />
+6 -1
View File
@@ -477,7 +477,12 @@ function PreviewNodesDialog({ userId }: { userId: number }) {
{previewData.node_groups.map((group) => (
<div key={group.id}>
<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>
{group.nodes && group.nodes.length > 0 ? (
<table className="w-full text-sm">
+5
View File
@@ -49,9 +49,14 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
},
verify: {
turnstile_site_key: "",
captcha_type: "turnstile",
enable_login_verify: false,
enable_register_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: {
mobile: {