feat: enhance player login process with captcha validation and improve wallet display logic
Some checks failed
lotteryfront CI / build (push) Has been cancelled
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:
@@ -62,6 +62,32 @@ export function HallWalletStrip() {
|
||||
|
||||
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
||||
const isCreditPlayer = isCreditFundingPlayer(balance);
|
||||
const balanceMinor = Number(balance?.balance ?? 0);
|
||||
const headlineMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||
const transferInLotteryMinor = isCreditPlayer ? availableMinor : balanceMinor;
|
||||
// #region agent log
|
||||
if (typeof window !== "undefined" && balance && !loading) {
|
||||
fetch("http://127.0.0.1:7696/ingest/e56128e6-898b-4d61-b06d-2aefe0923744", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7fd0fc" },
|
||||
body: JSON.stringify({
|
||||
sessionId: "7fd0fc",
|
||||
runId: "hall-wallet-display-post-fix",
|
||||
hypothesisId: "H3",
|
||||
location: "hall-wallet-strip.tsx:render",
|
||||
message: "hall wallet headline amounts",
|
||||
data: {
|
||||
isCreditPlayer,
|
||||
availableMinor,
|
||||
balanceMinor,
|
||||
headlineMinor,
|
||||
mismatchWithWalletPage: !isCreditPlayer && headlineMinor !== balanceMinor,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
// #endregion
|
||||
const mainMinor =
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
@@ -102,7 +128,7 @@ export function HallWalletStrip() {
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
) : (
|
||||
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
||||
{formatMinorAsCurrency(availableMinor, currency)}
|
||||
{formatMinorAsCurrency(headlineMinor, currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -117,7 +143,7 @@ export function HallWalletStrip() {
|
||||
triggerLabel={t("wallet.transferIn")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
currency={currency}
|
||||
lotteryMinor={availableMinor}
|
||||
lotteryMinor={transferInLotteryMinor}
|
||||
mainMinor={mainMinor}
|
||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||
/>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
/**
|
||||
* 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头;
|
||||
* 若有 Token 无 `profile`,补拉 `GET /player/me` 供顶栏展示。
|
||||
* 模块加载时已在 {@link lottery-auth} / store 初始态同步恢复 Bearer;
|
||||
* 此处再调 `restoreBearerToken` 兜底,并在有 Token 无 `profile` 时补拉 `GET /player/me`。
|
||||
*/
|
||||
export function HydratePlayerAuth(): null {
|
||||
const restoreBearerToken = usePlayerSessionStore(
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -42,9 +42,7 @@ export function WalletLogsScreen() {
|
||||
}
|
||||
try {
|
||||
const balance = await getWalletBalance({ currency });
|
||||
setCreditMode(
|
||||
balance.credit_line_mode === true || balance.funding_mode === "credit",
|
||||
);
|
||||
setCreditMode(isCreditFundingPlayer(balance));
|
||||
const nextLogs = await getWalletLogs({
|
||||
page: targetPage,
|
||||
size: WALLET_LOGS_PAGE_SIZE,
|
||||
|
||||
Reference in New Issue
Block a user