feat: enhance player login process with captcha validation and improve wallet display logic
Some checks failed
lotteryfront CI / build (push) Has been cancelled

- Added captcha validation to the player login screen, requiring users to input a captcha code for authentication.
- Implemented a new API call to fetch captcha images, improving security during login.
- Updated wallet display logic to ensure accurate representation of available and total balances based on player funding modes.
- Enhanced documentation in AGENTS.md to clarify changes in credit flow and login requirements.
This commit is contained in:
2026-06-17 15:27:06 +08:00
parent 9711909893
commit 2f1793e0cf
13 changed files with 211 additions and 20 deletions

View File

@@ -3,12 +3,12 @@
import { Loader2 } from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getPlayerMe } from "@/api/player";
import { postPlayerAuthLogin } from "@/api/player-auth";
import { getPlayerAuthCaptcha, postPlayerAuthLogin } from "@/api/player-auth";
import { getPublicCurrencies } from "@/api/currency";
import { LanguageSwitcher } from "@/components/language-switcher";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ import {
validatePlayerLoginUsername,
} from "@/lib/player-input-validation";
import { LotteryApiBizError } from "@/types/api/errors";
import "@/i18n";
function stripSearchParamFromBrowserUrl(name: string): void {
if (typeof window === "undefined") return;
@@ -32,6 +33,10 @@ function stripSearchParamFromBrowserUrl(name: string): void {
export function PlayerLoginScreen(): React.ReactElement {
const { t } = useTranslation("entry");
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const router = useRouter();
const searchParams = useSearchParams();
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
@@ -41,8 +46,43 @@ export function PlayerLoginScreen(): React.ReactElement {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [captchaCode, setCaptchaCode] = useState("");
const [captchaKey, setCaptchaKey] = useState<string | null>(null);
const [captchaSrc, setCaptchaSrc] = useState<string | null>(null);
const [loadingCaptcha, setLoadingCaptcha] = useState(false);
const [loading, setLoading] = useState(false);
const loadCaptcha = useCallback(async () => {
setLoadingCaptcha(true);
try {
const data = await getPlayerAuthCaptcha();
setCaptchaKey(data.captcha_key);
setCaptchaSrc(`data:image/svg+xml;base64,${data.image_base64}`);
setCaptchaCode("");
} catch {
toast.error(tRef.current("login.captchaLoadFailed"));
setCaptchaKey(null);
setCaptchaSrc(null);
} finally {
setLoadingCaptcha(false);
}
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
if (cancelled) {
return;
}
await loadCaptcha();
})();
return () => {
cancelled = true;
};
}, [loadCaptcha]);
useEffect(() => {
if (sessionExpiredHandled.current) return;
if (searchParams.get("session") !== "expired") return;
@@ -60,6 +100,17 @@ export function PlayerLoginScreen(): React.ReactElement {
return;
}
if (!captchaKey || !captchaSrc) {
toast.error(t("login.captchaRequired"));
void loadCaptcha();
return;
}
if (!captchaCode.trim()) {
toast.error(t("login.captchaRequired"));
return;
}
const usernameIssue = validatePlayerLoginUsername(username);
if (usernameIssue === "invalid_charset") {
toast.error(
@@ -83,6 +134,8 @@ export function PlayerLoginScreen(): React.ReactElement {
const data = await postPlayerAuthLogin({
username: username.trim(),
password,
captcha_key: captchaKey,
captcha_code: captchaCode.trim(),
});
setBearerToken(data.access_token);
const me = await getPlayerMe();
@@ -96,6 +149,7 @@ export function PlayerLoginScreen(): React.ReactElement {
router.replace("/hall");
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("login.failed"));
void loadCaptcha();
} finally {
setLoading(false);
}
@@ -128,6 +182,7 @@ export function PlayerLoginScreen(): React.ReactElement {
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
disabled={loading}
/>
</div>
<div className="space-y-1">
@@ -138,8 +193,47 @@ export function PlayerLoginScreen(): React.ReactElement {
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
disabled={loading}
/>
</div>
<div className="space-y-1">
<Label htmlFor="login-captcha">{t("login.captcha")}</Label>
<div className="flex flex-wrap items-center gap-3">
<Input
id="login-captcha"
name="captcha"
autoComplete="off"
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
placeholder={t("login.captchaPlaceholder")}
maxLength={32}
disabled={loading}
className="min-w-0 flex-1 sm:max-w-[12rem]"
/>
<button
type="button"
className="flex h-10 min-w-[156px] shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border border-input bg-muted/40 px-2 transition-colors hover:bg-muted/60 disabled:pointer-events-none disabled:opacity-50"
onClick={() => void loadCaptcha()}
disabled={loadingCaptcha || loading}
aria-label={loadingCaptcha ? t("login.captchaLoading") : t("login.captchaRefresh")}
>
{captchaSrc ? (
// eslint-disable-next-line @next/next/no-img-element -- captcha SVG data URL from API
<img
src={captchaSrc}
alt=""
width={160}
height={48}
className="pointer-events-none block"
/>
) : (
<span className="px-2 text-xs text-muted-foreground">
{loadingCaptcha ? t("login.captchaLoading") : t("login.captchaFetch")}
</span>
)}
</button>
</div>
</div>
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={loading}>
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{t("login.submit")}