diff --git a/apps/admin/dist.zip b/apps/admin/dist.zip new file mode 100644 index 0000000..18a8a68 Binary files /dev/null and b/apps/admin/dist.zip differ diff --git a/apps/admin/public/assets/locales/en-US/auth.json b/apps/admin/public/assets/locales/en-US/auth.json index 06eebf3..6d17ad7 100644 --- a/apps/admin/public/assets/locales/en-US/auth.json +++ b/apps/admin/public/assets/locales/en-US/auth.json @@ -4,7 +4,21 @@ "noImage": "No Image", "placeholder": "Enter captcha code...", "refresh": "Refresh captcha", - "required": "Please enter captcha code" + "required": "Please enter captcha code", + "sliderRequired": "Please complete the slider verification", + "slider": { + "clickToVerify": "Click to verify", + "fail": "Try again", + "hint": "Drag the piece to fit the puzzle", + "success": "Verified", + "title": "Security Verification" + }, + "turnstile": { + "cancel": "Cancel", + "clickToVerify": "Click to verify", + "success": "Verified", + "title": "Security Verification" + } }, "check": { "description": "Verify your identity", diff --git a/apps/admin/public/assets/locales/en-US/nodes.json b/apps/admin/public/assets/locales/en-US/nodes.json index 67b4fe6..aea54c1 100644 --- a/apps/admin/public/assets/locales/en-US/nodes.json +++ b/apps/admin/public/assets/locales/en-US/nodes.json @@ -7,11 +7,11 @@ "confirmDeleteTitle": "Delete this node?", "copied": "Copied", "copy": "Copy", - "create": "Create", + "create": "Create Landing Node", "created": "Created", "delete": "Delete", "deleted": "Deleted", - "drawerCreateTitle": "Create Node", + "drawerCreateTitle": "Create Landing Node", "drawerEditTitle": "Edit Node", "edit": "Edit", "enabled": "Enabled", diff --git a/apps/admin/public/assets/locales/en-US/system.json b/apps/admin/public/assets/locales/en-US/system.json index 0aa0cc1..36a3985 100644 --- a/apps/admin/public/assets/locales/en-US/system.json +++ b/apps/admin/public/assets/locales/en-US/system.json @@ -126,9 +126,10 @@ "userSecuritySettings": "User & Security", "verify": { "captchaType": "Captcha Type", - "captchaTypeDescription": "Choose between local image captcha (offline) or Cloudflare Turnstile", + "captchaTypeDescription": "Choose between local image captcha, local slider captcha (offline) or Cloudflare Turnstile", "captchaTypeLocal": "Local Image Captcha", "captchaTypePlaceholder": "Select captcha type", + "captchaTypeSlider": "Local Slider Captcha", "captchaTypeTurnstile": "Cloudflare Turnstile", "description": "Configure captcha type and verification settings", "enableAdminLoginCaptcha": "Enable Admin Authentication Captcha", diff --git a/apps/admin/public/assets/locales/zh-CN/auth.json b/apps/admin/public/assets/locales/zh-CN/auth.json index 86abb3c..79fe69b 100644 --- a/apps/admin/public/assets/locales/zh-CN/auth.json +++ b/apps/admin/public/assets/locales/zh-CN/auth.json @@ -4,7 +4,21 @@ "noImage": "无图片", "placeholder": "请输入验证码...", "refresh": "刷新验证码", - "required": "请输入验证码" + "required": "请输入验证码", + "sliderRequired": "请完成滑块验证", + "slider": { + "clickToVerify": "点击进行验证", + "fail": "请重试", + "hint": "拖动拼图块到对应位置", + "success": "验证成功", + "title": "安全验证" + }, + "turnstile": { + "cancel": "取消", + "clickToVerify": "点击进行验证", + "success": "验证成功", + "title": "安全验证" + } }, "check": { "description": "验证您的身份", diff --git a/apps/admin/public/assets/locales/zh-CN/group.json b/apps/admin/public/assets/locales/zh-CN/group.json index ad5c6fb..47447fa 100644 --- a/apps/admin/public/assets/locales/zh-CN/group.json +++ b/apps/admin/public/assets/locales/zh-CN/group.json @@ -198,4 +198,3 @@ "nodeGroupUsedBySubscribe": "该节点组已被订阅商品设置为默认节点组,不能设为过期节点组", "expiredGroupForCalculationDescription": "过期专用节点组不能参与分组计算" } - diff --git a/apps/admin/public/assets/locales/zh-CN/nodes.json b/apps/admin/public/assets/locales/zh-CN/nodes.json index 3c67650..78abe4e 100644 --- a/apps/admin/public/assets/locales/zh-CN/nodes.json +++ b/apps/admin/public/assets/locales/zh-CN/nodes.json @@ -7,11 +7,11 @@ "confirmDeleteTitle": "删除此节点?", "copied": "已复制", "copy": "复制", - "create": "创建", + "create": "创建落地节点", "created": "已创建", "delete": "删除", "deleted": "已删除", - "drawerCreateTitle": "创建节点", + "drawerCreateTitle": "创建落地节点", "drawerEditTitle": "编辑节点", "edit": "编辑", "enabled": "已启用", diff --git a/apps/admin/public/assets/locales/zh-CN/system.json b/apps/admin/public/assets/locales/zh-CN/system.json index 840abcf..3726c59 100644 --- a/apps/admin/public/assets/locales/zh-CN/system.json +++ b/apps/admin/public/assets/locales/zh-CN/system.json @@ -126,9 +126,10 @@ "userSecuritySettings": "用户与安全", "verify": { "captchaType": "验证码类型", - "captchaTypeDescription": "选择本地图形验证码(离线)或 Cloudflare Turnstile", + "captchaTypeDescription": "选择本地图形验证码、本地滑块验证码(均可离线)或 Cloudflare Turnstile", "captchaTypeLocal": "本地图形验证码", "captchaTypePlaceholder": "选择验证码类型", + "captchaTypeSlider": "本地滑块验证码", "captchaTypeTurnstile": "Cloudflare Turnstile", "description": "配置验证码类型和验证设置", "enableAdminLoginCaptcha": "启用管理端认证验证码", diff --git a/apps/admin/public/assets/locales/zh-CN/user.json b/apps/admin/public/assets/locales/zh-CN/user.json index 100fe13..33c0e64 100644 --- a/apps/admin/public/assets/locales/zh-CN/user.json +++ b/apps/admin/public/assets/locales/zh-CN/user.json @@ -34,7 +34,6 @@ "deleteDescription": "此操作无法撤销。", "deleteSubscriptionDescription": "此操作无法撤销。", "deleteSuccess": "删除成功", - "isDeleted": "状态", "deviceGroup": "设备组", "deviceLimit": "IP限制", "deviceNo": "设备编号", diff --git a/apps/admin/src/sections/auth/email/auth-form.tsx b/apps/admin/src/sections/auth/email/auth-form.tsx index 30e6f83..cced730 100644 --- a/apps/admin/src/sections/auth/email/auth-form.tsx +++ b/apps/admin/src/sections/auth/email/auth-form.tsx @@ -2,10 +2,10 @@ import { useNavigate } from "@tanstack/react-router"; import { - resetPassword, - userLogin, - userRegister, -} from "@workspace/ui/services/common/auth"; + adminLogin, + adminResetPassword, +} from "@workspace/ui/services/admin/auth"; +import { userRegister } from "@workspace/ui/services/common/auth"; import type { ReactNode } from "react"; import { useState, useTransition } from "react"; import { useTranslation } from "react-i18next"; @@ -42,7 +42,7 @@ export default function EmailAuthForm() { try { switch (type) { case "login": { - const login = await userLogin(params); + const login = await adminLogin(params); toast.success(t("login.success", "Login successful!")); onLogin(login.data.data?.token); break; @@ -54,7 +54,7 @@ export default function EmailAuthForm() { break; } case "reset": - await resetPassword(params); + await adminResetPassword(params); toast.success(t("reset.success", "Password reset successful!")); setType("login"); break; diff --git a/apps/admin/src/sections/auth/email/login-form.tsx b/apps/admin/src/sections/auth/email/login-form.tsx index f402ecf..f92658d 100644 --- a/apps/admin/src/sections/auth/email/login-form.tsx +++ b/apps/admin/src/sections/auth/email/login-form.tsx @@ -15,8 +15,9 @@ import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; import { useGlobalStore } from "@/stores/global"; -import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile"; import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha"; +import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha"; +import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile"; export default function LoginForm({ loading, @@ -38,6 +39,7 @@ export default function LoginForm({ const isTurnstile = verify.captcha_type === "turnstile"; const isLocal = verify.captcha_type === "local"; + const isSlider = verify.captcha_type === "slider"; const captchaEnabled = verify.enable_admin_login_captcha; const formSchema = z.object({ @@ -53,14 +55,26 @@ export default function LoginForm({ captchaEnabled && isLocal ? z.string().min(1, t("captcha.required", "Please enter captcha code")) : z.string().optional(), + slider_token: + captchaEnabled && isSlider + ? z + .string() + .min(1, t("captcha.sliderRequired", "Please complete the slider")) + : z.string().optional(), }); const form = useForm>({ resolver: zodResolver(formSchema), - defaultValues: initialValues, + defaultValues: { + cf_token: "", + captcha_code: "", + slider_token: "", + ...initialValues, + }, }); const turnstile = useRef(null); const localCaptcha = useRef(null); + const sliderCaptcha = useRef(null); const handleSubmit = form.handleSubmit((data) => { try { // Add captcha_id for local captcha @@ -71,6 +85,7 @@ export default function LoginForm({ } catch (_error) { turnstile.current?.reset(); localCaptcha.current?.reset(); + sliderCaptcha.current?.reset(); } }); @@ -143,8 +158,8 @@ export default function LoginForm({ @@ -152,6 +167,20 @@ export default function LoginForm({ )} /> )} + {captchaEnabled && isSlider && ( + ( + + + + + + + )} + /> + )} + return ( +
+ onChange?.(e.target.value)} + placeholder={t("captcha.placeholder", "Enter captcha code...")} + value={value || ""} + /> +
+ {loading ? ( +
+ +
+ ) : captchaImage ? ( + captcha + ) : ( +
+ {t("captcha.noImage", "No Image")} +
+ )}
- ); - } -); + +
+ ); +}; LocalCaptcha.displayName = "LocalCaptcha"; diff --git a/apps/admin/src/sections/auth/slider-captcha.tsx b/apps/admin/src/sections/auth/slider-captcha.tsx new file mode 100644 index 0000000..ed53c3e --- /dev/null +++ b/apps/admin/src/sections/auth/slider-captcha.tsx @@ -0,0 +1,358 @@ +import { Button } from "@workspace/ui/components/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog"; +import { Icon } from "@workspace/ui/composed/icon"; +import { + adminGenerateCaptcha, + adminVerifyCaptchaSlider, +} from "@workspace/ui/services/admin/auth"; +import { useCallback, useImperativeHandle, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +export interface SliderCaptchaRef { + reset: () => void; +} + +interface SliderCaptchaProps { + value?: string; + onChange?: (value: string) => void; +} + +interface TrailPoint { + x: number; + y: number; + t: number; +} + +const BLOCK_SIZE = 100; +const BG_NATURAL_WIDTH = 560; +const BG_NATURAL_HEIGHT = 280; + +const SliderCaptcha = ({ + onChange, + ref, +}: SliderCaptchaProps & { ref?: RefObject }) => { + const { t } = useTranslation("auth"); + const containerRef = useRef(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([]); + + const getContainerSize = () => { + const el = containerRef.current; + if (!el) return { w: BG_NATURAL_WIDTH, h: BG_NATURAL_HEIGHT }; + return { w: el.clientWidth, h: el.clientHeight }; + }; + + const fetchCaptcha = useCallback(async () => { + setLoading(true); + setStatus("idle"); + setBlockPos({ x: 0, y: 50 }); + trail.current = []; + try { + const res = await adminGenerateCaptcha(); + const data = res.data?.data; + if (data) { + setCaptchaId(data.id); + setBgImage(data.image); + setBlockImage(data.block_image ?? ""); + } + } catch (e) { + console.error("Failed to generate slider captcha:", e); + } finally { + setLoading(false); + } + }, []); + + const handleOpen = () => { + if (verified) return; + setOpen(true); + fetchCaptcha(); + }; + + useImperativeHandle(ref, () => ({ + reset: () => { + setVerified(false); + onChange?.(""); + setOpen(false); + trail.current = []; + }, + })); + + const onPointerDown = (e: React.PointerEvent) => { + if (status === "success" || loading) return; + dragging.current = true; + dragStartTime.current = Date.now(); + trail.current = []; + startPointer.current = { x: e.clientX, y: e.clientY }; + startBlock.current = { ...blockPos }; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + e.preventDefault(); + + const { w, h } = getContainerSize(); + const _scaleX = BG_NATURAL_WIDTH / w; + const _scaleY = BG_NATURAL_HEIGHT / h; + trail.current.push({ + x: Math.round(startBlock.current.x), + y: Math.round(startBlock.current.y), + t: 0, + }); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging.current) return; + const { w, h } = getContainerSize(); + const scaleX = BG_NATURAL_WIDTH / w; + const scaleY = BG_NATURAL_HEIGHT / h; + const dx = (e.clientX - startPointer.current.x) * scaleX; + const dy = (e.clientY - startPointer.current.y) * scaleY; + const newX = Math.max( + 0, + Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE) + ); + const newY = Math.max( + 0, + Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE) + ); + setBlockPos({ x: newX, y: newY }); + + trail.current.push({ + x: Math.round(newX), + y: Math.round(newY), + t: Date.now() - dragStartTime.current, + }); + }; + + const onPointerUp = async (e: React.PointerEvent) => { + if (!dragging.current) return; + dragging.current = false; + const { w, h } = getContainerSize(); + const scaleX = BG_NATURAL_WIDTH / w; + const scaleY = BG_NATURAL_HEIGHT / h; + const dx = (e.clientX - startPointer.current.x) * scaleX; + const dy = (e.clientY - startPointer.current.y) * scaleY; + const finalX = Math.round( + Math.max( + 0, + Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE) + ) + ); + const finalY = Math.round( + Math.max( + 0, + Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE) + ) + ); + setBlockPos({ x: finalX, y: finalY }); + + trail.current.push({ + x: finalX, + y: finalY, + t: Date.now() - dragStartTime.current, + }); + + try { + const res = await adminVerifyCaptchaSlider({ + id: captchaId, + x: finalX, + y: finalY, + trail: JSON.stringify(trail.current), + }); + const token = res.data?.data?.token; + if (token) { + setStatus("success"); + setTimeout(() => { + setVerified(true); + onChange?.(token); + setOpen(false); + }, 600); + } else { + triggerFail(); + } + } catch { + triggerFail(); + } + }; + + const triggerFail = () => { + setStatus("fail"); + setShaking(true); + setTimeout(() => { + setShaking(false); + fetchCaptcha(); + }, 800); + }; + + const blockLeftPct = (blockPos.x / BG_NATURAL_WIDTH) * 100; + const blockTopPct = (blockPos.y / BG_NATURAL_HEIGHT) * 100; + const blockSizeWPct = (BLOCK_SIZE / BG_NATURAL_WIDTH) * 100; + const blockSizeHPct = (BLOCK_SIZE / BG_NATURAL_HEIGHT) * 100; + + return ( + <> + {/* Trigger button */} + + + {/* Slider dialog */} + { + if (!o) setOpen(false); + }} + open={open} + > + + + + {t("captcha.slider.title", "Security Verification")} + + + +
+
+ {loading ? ( +
+ +
+ ) : bgImage ? ( + <> + captcha background + {blockImage && ( + captcha block + )} + {status !== "idle" && ( +
+ {status === "success" + ? t("captcha.slider.success", "Verified") + : t("captcha.slider.fail", "Try again")} +
+ )} + + ) : null} +
+
+ +

+ {t("captcha.slider.hint", "Drag the piece to fit the puzzle")} +

+ + +
+
+ + + + ); +}; + +SliderCaptcha.displayName = "SliderCaptcha"; + +export default SliderCaptcha; diff --git a/apps/admin/src/sections/auth/turnstile.tsx b/apps/admin/src/sections/auth/turnstile.tsx index 329b708..fe35209 100644 --- a/apps/admin/src/sections/auth/turnstile.tsx +++ b/apps/admin/src/sections/auth/turnstile.tsx @@ -1,5 +1,18 @@ +import { Button } from "@workspace/ui/components/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog"; +import { Icon } from "@workspace/ui/composed/icon"; import { useTheme } from "next-themes"; -import { type RefObject, useEffect, useImperativeHandle } from "react"; +import { + type RefObject, + useEffect, + useImperativeHandle, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import Turnstile, { useTurnstile } from "react-turnstile"; @@ -23,43 +36,119 @@ const CloudFlareTurnstile = function CloudFlareTurnstile({ const { common } = useGlobalStore(); const { verify } = common; const { resolvedTheme } = useTheme(); - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation("auth"); const locale = i18n.language; const turnstile = useTurnstile(); + const [open, setOpen] = useState(false); + const [verified, setVerified] = useState(false); useImperativeHandle( ref, () => ({ - reset: () => turnstile.reset(), + reset: () => { + setVerified(false); + onChange(""); + turnstile.reset(); + }, }), - [turnstile] + [turnstile, onChange] ); useEffect(() => { if (value === "") { + setVerified(false); turnstile.reset(); } }, [turnstile, value]); + const handleOpen = () => { + if (verified) return; + setOpen(true); + }; + + if (!verify.turnstile_site_key) return null; + return ( - verify.turnstile_site_key && ( - { - onChange(); - turnstile.reset(); + <> + {/* Trigger button */} + + + {/* Turnstile dialog */} + { + if (!o) setOpen(false); }} - onTimeout={() => { - onChange(); - turnstile.reset(); - }} - onVerify={(token) => onChange(token)} - sitekey={verify.turnstile_site_key} - theme={resolvedTheme as "light" | "dark"} - /> - ) + open={open} + > + + + + {t("captcha.turnstile.title", "Security Verification")} + + + { + 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"} + /> + + + + ); }; diff --git a/apps/admin/src/sections/group/average-mode-tab.tsx b/apps/admin/src/sections/group/average-mode-tab.tsx index 4151bbf..98e374a 100644 --- a/apps/admin/src/sections/group/average-mode-tab.tsx +++ b/apps/admin/src/sections/group/average-mode-tab.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { Badge } from "@workspace/ui/components/badge"; import { Button } from "@workspace/ui/components/button"; import { @@ -11,17 +12,16 @@ import { } from "@workspace/ui/components/card"; import { Input } from "@workspace/ui/components/input"; import { Label } from "@workspace/ui/components/label"; -import { Loader2 } from "lucide-react"; -import { useEffect, useState, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; import { getGroupConfig, getNodeGroupList, getRecalculationStatus, recalculateGroup, } from "@workspace/ui/services/admin/group"; +import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; export default function AverageModeTab() { const { t } = useTranslation("group"); @@ -147,7 +147,9 @@ export default function AverageModeTab() { {/* Configuration Card */} - {t("averageModeConfig", "Average Mode Configuration")} + + {t("averageModeConfig", "Average Mode Configuration")} + {t( "averageModeDescription", @@ -162,15 +164,18 @@ export default function AverageModeTab() { {t("availableNodeGroups", "Available Node Groups")} -

- {t("nodeGroupCountAutoCalculated", "Auto-calculated from actual node groups")} +

+ {t( + "nodeGroupCountAutoCalculated", + "Auto-calculated from actual node groups" + )}

@@ -180,7 +185,9 @@ export default function AverageModeTab() { {/* Recalculation Card */} - {t("groupRecalculation", "Group Recalculation")} + + {t("groupRecalculation", "Group Recalculation")} + {t( "groupRecalculationDescription", @@ -192,7 +199,7 @@ export default function AverageModeTab() { {/* Current Status */}
- + {t("currentStatus", "Current Status")} {loadingStatus ? ( @@ -224,14 +231,20 @@ export default function AverageModeTab() { )} {status?.state === "completed" && ( -
- {t("recalculationCompleted", "Recalculation completed successfully")} +
+ {t( + "recalculationCompleted", + "Recalculation completed successfully" + )}
)} {status?.state === "failed" && ( -
- {t("recalculationFailed", "Recalculation failed. Please try again.")} +
+ {t( + "recalculationFailed", + "Recalculation failed. Please try again." + )}
)}
@@ -239,8 +252,8 @@ export default function AverageModeTab() { {/* Recalculate Button */}
@@ -129,18 +137,25 @@ export default function BindNodeGroupsDialog({
) : (
- + handleUpdateEnabled(e.target.checked)} - disabled={saving} className="h-4 w-4" + disabled={saving} + id="enabled" + onChange={(e) => handleUpdateEnabled(e.target.checked)} + type="checkbox" />
{/* Mode Selection */} {config.enabled && (
-