You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
666 lines
22 KiB
666 lines
22 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Alert, Button, Checkbox, Input, Modal, Segmented } from "antd";
|
|
import { useI18n } from "@/i18n";
|
|
import { useLoginModalStore } from "@/store/loginModalStore";
|
|
import { useUserStore } from "@/store/userStore";
|
|
|
|
type LoginMode = "code" | "wechat" | "password";
|
|
type CodePurpose = "login" | "wechat-bind";
|
|
|
|
interface ApiResponse {
|
|
data?: unknown;
|
|
message?: unknown;
|
|
status?: unknown;
|
|
}
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const getMessage = (payload: ApiResponse, fallback: string): string =>
|
|
typeof payload.message === "string" && payload.message.trim()
|
|
? payload.message
|
|
: fallback;
|
|
|
|
const getTokenFromResponse = (payload: ApiResponse): string | null => {
|
|
if (isRecord(payload.data) && typeof payload.data.token === "string") {
|
|
return payload.data.token.trim() || null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
async function readJsonResponse(response: Response, fallbackMessage: string): Promise<ApiResponse> {
|
|
const payload = (await response.json()) as ApiResponse;
|
|
if (!response.ok || payload.status !== "0000") {
|
|
throw new Error(getMessage(payload, fallbackMessage));
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export function LoginModal() {
|
|
const { t } = useI18n();
|
|
const { isOpen, reason, closeLoginModal } = useLoginModalStore();
|
|
const fetchUserInfo = useUserStore((state) => state.fetchUserInfo);
|
|
|
|
const wxPollTimerRef = useRef<number | null>(null);
|
|
|
|
const [mode, setMode] = useState<LoginMode>("code");
|
|
const [phone, setPhone] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [code, setCode] = useState("");
|
|
const [inviteCode, setInviteCode] = useState("");
|
|
const [countdown, setCountdown] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSendingCode, setIsSendingCode] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [agreementChecked, setAgreementChecked] = useState(false);
|
|
|
|
const [captchaOpen, setCaptchaOpen] = useState(false);
|
|
const [captchaId, setCaptchaId] = useState("");
|
|
const [captchaImage, setCaptchaImage] = useState("");
|
|
const [captchaValue, setCaptchaValue] = useState("");
|
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
|
const [captchaError, setCaptchaError] = useState("");
|
|
const [codeTargetPhone, setCodeTargetPhone] = useState("");
|
|
const [codePurpose, setCodePurpose] = useState<CodePurpose>("login");
|
|
|
|
const [wxSceneCode, setWxSceneCode] = useState("");
|
|
const [wxQrUrl, setWxQrUrl] = useState("");
|
|
const [wxQrError, setWxQrError] = useState("");
|
|
const [wxQrLoading, setWxQrLoading] = useState(false);
|
|
const [wxNeedBindPhone, setWxNeedBindPhone] = useState(false);
|
|
const [wxRegisterToken, setWxRegisterToken] = useState("");
|
|
const [wxBindPhone, setWxBindPhone] = useState("");
|
|
const [wxBindCode, setWxBindCode] = useState("");
|
|
const [wxBindInviteCode, setWxBindInviteCode] = useState("");
|
|
|
|
const stopWxPoll = useCallback((): void => {
|
|
if (wxPollTimerRef.current !== null) {
|
|
window.clearTimeout(wxPollTimerRef.current);
|
|
wxPollTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const clearWxState = useCallback((): void => {
|
|
stopWxPoll();
|
|
setWxSceneCode("");
|
|
setWxQrUrl("");
|
|
setWxQrError("");
|
|
setWxQrLoading(false);
|
|
setWxNeedBindPhone(false);
|
|
setWxRegisterToken("");
|
|
setWxBindPhone("");
|
|
setWxBindCode("");
|
|
setWxBindInviteCode("");
|
|
}, [stopWxPoll]);
|
|
|
|
const completeLoginWithToken = useCallback(
|
|
async (token: string): Promise<void> => {
|
|
const userInfo = await fetchUserInfo(token);
|
|
if (!userInfo) {
|
|
throw new Error(t("auth.loginUserInfoFailed"));
|
|
}
|
|
stopWxPoll();
|
|
closeLoginModal();
|
|
window.location.reload();
|
|
},
|
|
[closeLoginModal, fetchUserInfo, stopWxPoll, t]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
stopWxPoll();
|
|
setCaptchaOpen(false);
|
|
return;
|
|
}
|
|
setError("");
|
|
}, [isOpen, stopWxPoll]);
|
|
|
|
useEffect(() => {
|
|
return () => stopWxPoll();
|
|
}, [stopWxPoll]);
|
|
|
|
useEffect(() => {
|
|
if (countdown <= 0) return;
|
|
const timer = window.setTimeout(() => setCountdown((value) => value - 1), 1000);
|
|
return () => window.clearTimeout(timer);
|
|
}, [countdown]);
|
|
|
|
const loadWxQrCode = useCallback(async (): Promise<void> => {
|
|
stopWxPoll();
|
|
setWxQrError("");
|
|
setWxQrLoading(true);
|
|
setWxSceneCode("");
|
|
setWxQrUrl("");
|
|
setWxNeedBindPhone(false);
|
|
setWxRegisterToken("");
|
|
|
|
try {
|
|
const payload = await readJsonResponse(
|
|
await fetch("/api/auth/wxgh-qrcode", {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
}),
|
|
t("auth.wechatQrLoadFailed")
|
|
);
|
|
const data = payload.data;
|
|
if (!isRecord(data)) {
|
|
throw new Error(t("auth.wechatQrLoadFailed"));
|
|
}
|
|
|
|
const scene = data.sceneCode ?? data.scene;
|
|
const qrUrl = data.qrCodeUrl ?? data.qrUrl ?? data.url ?? data.qr_code;
|
|
if (typeof scene !== "string" || !scene.trim() || typeof qrUrl !== "string" || !qrUrl.trim()) {
|
|
throw new Error(t("auth.wechatQrLoadFailed"));
|
|
}
|
|
|
|
setWxSceneCode(scene.trim());
|
|
setWxQrUrl(qrUrl.trim());
|
|
} catch (nextError) {
|
|
setWxQrError(nextError instanceof Error ? nextError.message : t("auth.wechatQrLoadFailed"));
|
|
} finally {
|
|
setWxQrLoading(false);
|
|
}
|
|
}, [stopWxPoll, t]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || mode !== "wechat") {
|
|
clearWxState();
|
|
return;
|
|
}
|
|
|
|
void loadWxQrCode();
|
|
}, [clearWxState, isOpen, loadWxQrCode, mode]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || mode !== "wechat" || !wxSceneCode || wxNeedBindPhone) {
|
|
stopWxPoll();
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const tick = async (): Promise<void> => {
|
|
if (cancelled) return;
|
|
try {
|
|
const params = new URLSearchParams({ sceneCode: wxSceneCode });
|
|
const payload = await readJsonResponse(
|
|
await fetch(`/api/auth/check-wxgh-login?${params.toString()}`, {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
}),
|
|
t("auth.wechatPollFailed")
|
|
);
|
|
if (cancelled) return;
|
|
|
|
const data = payload.data;
|
|
if (!isRecord(data)) {
|
|
wxPollTimerRef.current = window.setTimeout(() => void tick(), 2000);
|
|
return;
|
|
}
|
|
|
|
if (data.needBindPhone === true) {
|
|
const registerToken = typeof data.registerToken === "string" ? data.registerToken.trim() : "";
|
|
if (!registerToken) {
|
|
throw new Error(t("auth.wechatMissingBindToken"));
|
|
}
|
|
stopWxPoll();
|
|
setWxRegisterToken(registerToken);
|
|
setWxNeedBindPhone(true);
|
|
return;
|
|
}
|
|
|
|
const token = typeof data.token === "string" ? data.token.trim() : "";
|
|
if (token) {
|
|
stopWxPoll();
|
|
await completeLoginWithToken(token);
|
|
return;
|
|
}
|
|
|
|
wxPollTimerRef.current = window.setTimeout(() => void tick(), 2000);
|
|
} catch (nextError) {
|
|
stopWxPoll();
|
|
if (!cancelled) {
|
|
setWxQrError(nextError instanceof Error ? nextError.message : t("auth.wechatPollFailed"));
|
|
}
|
|
}
|
|
};
|
|
|
|
stopWxPoll();
|
|
void tick();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
stopWxPoll();
|
|
};
|
|
}, [completeLoginWithToken, isOpen, mode, stopWxPoll, t, wxNeedBindPhone, wxSceneCode]);
|
|
|
|
const loadCaptcha = async (): Promise<void> => {
|
|
setCaptchaError("");
|
|
const response = await fetch("/api/captcha/gen", {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
const payload = await readJsonResponse(response, t("auth.captchaLoadFailed"));
|
|
if (!isRecord(payload.data)) {
|
|
throw new Error(t("auth.captchaLoadFailed"));
|
|
}
|
|
|
|
const nextCaptchaId = payload.data.id;
|
|
const nextCaptchaImage = payload.data.data;
|
|
if (typeof nextCaptchaId !== "string" || typeof nextCaptchaImage !== "string") {
|
|
throw new Error(t("auth.captchaLoadFailed"));
|
|
}
|
|
|
|
setCaptchaId(nextCaptchaId);
|
|
setCaptchaImage(nextCaptchaImage);
|
|
setCaptchaValue("");
|
|
};
|
|
|
|
const handleSendCode = async (targetPhone: string, purpose: CodePurpose): Promise<void> => {
|
|
if (!targetPhone.trim() || countdown > 0 || captchaLoading || isSendingCode) return;
|
|
|
|
setError("");
|
|
setCaptchaError("");
|
|
setCodeTargetPhone(targetPhone.trim());
|
|
setCodePurpose(purpose);
|
|
setCaptchaOpen(true);
|
|
try {
|
|
await loadCaptcha();
|
|
} catch (nextError) {
|
|
setCaptchaError(nextError instanceof Error ? nextError.message : t("auth.captchaLoadFailed"));
|
|
}
|
|
};
|
|
|
|
const handleCaptchaOk = async (): Promise<void> => {
|
|
const trimmedCaptchaValue = captchaValue.trim();
|
|
if (!captchaId || !trimmedCaptchaValue || captchaLoading || !codeTargetPhone) return;
|
|
|
|
setCaptchaLoading(true);
|
|
setCaptchaError("");
|
|
try {
|
|
const verifyResponse = await fetch("/api/captcha/verify", {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ id: captchaId, value: trimmedCaptchaValue }),
|
|
});
|
|
const verifyPayload = await readJsonResponse(verifyResponse, t("auth.captchaInvalid"));
|
|
const verifyData = verifyPayload.data;
|
|
if (isRecord(verifyData) && verifyData.err !== 0) {
|
|
throw new Error(getMessage(verifyPayload, t("auth.captchaInvalid")));
|
|
}
|
|
|
|
setIsSendingCode(true);
|
|
const params = new URLSearchParams({
|
|
phone: codeTargetPhone,
|
|
usage: "LOGIN",
|
|
captchaId,
|
|
captchaValue: trimmedCaptchaValue,
|
|
});
|
|
await readJsonResponse(
|
|
await fetch(`/api/auth/code?${params.toString()}`, {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
}),
|
|
t("auth.captchaSendFailed")
|
|
);
|
|
|
|
setCountdown(60);
|
|
setCaptchaOpen(false);
|
|
setCaptchaId("");
|
|
setCaptchaImage("");
|
|
setCaptchaValue("");
|
|
if (codePurpose === "wechat-bind") {
|
|
setWxBindCode("");
|
|
}
|
|
} catch (nextError) {
|
|
setCaptchaError(nextError instanceof Error ? nextError.message : t("auth.captchaSendFailed"));
|
|
void loadCaptcha().catch(() => undefined);
|
|
} finally {
|
|
setCaptchaLoading(false);
|
|
setIsSendingCode(false);
|
|
}
|
|
};
|
|
|
|
const handleLogin = async (): Promise<void> => {
|
|
if (isLoading || mode === "wechat") return;
|
|
setError("");
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const endpoint = mode === "password" ? "/api/auth/login" : "/api/auth/login-by-code";
|
|
const body =
|
|
mode === "password"
|
|
? { username: phone.trim(), password }
|
|
: { phone: phone.trim(), code: code.trim(), inviteCode: inviteCode.trim() || undefined };
|
|
|
|
const payload = await readJsonResponse(
|
|
await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
}),
|
|
t("auth.loginFailed")
|
|
);
|
|
|
|
const token = getTokenFromResponse(payload);
|
|
if (!token) {
|
|
throw new Error(t("auth.loginMissingToken"));
|
|
}
|
|
await completeLoginWithToken(token);
|
|
} catch (nextError) {
|
|
setError(nextError instanceof Error ? nextError.message : t("auth.loginFailed"));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleWxBindPhone = async (): Promise<void> => {
|
|
if (isLoading || !wxRegisterToken || !wxBindPhone.trim() || !wxBindCode.trim()) return;
|
|
|
|
setError("");
|
|
setIsLoading(true);
|
|
try {
|
|
const payload = await readJsonResponse(
|
|
await fetch("/api/auth/wxgh-register-by-phone", {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
registerToken: wxRegisterToken,
|
|
phone: wxBindPhone.trim(),
|
|
code: wxBindCode.trim(),
|
|
inviteCode: wxBindInviteCode.trim() || undefined,
|
|
}),
|
|
}),
|
|
t("auth.bindPhoneFailed")
|
|
);
|
|
|
|
const token = getTokenFromResponse(payload);
|
|
if (!token) {
|
|
throw new Error(t("auth.loginMissingToken"));
|
|
}
|
|
await completeLoginWithToken(token);
|
|
} catch (nextError) {
|
|
setError(nextError instanceof Error ? nextError.message : t("auth.bindPhoneFailed"));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const isSubmitDisabled =
|
|
isLoading ||
|
|
mode === "wechat" ||
|
|
!phone.trim() ||
|
|
(mode === "password" ? !password : !code.trim()) ||
|
|
!agreementChecked;
|
|
|
|
const isWxBindSubmitDisabled =
|
|
isLoading || !wxRegisterToken || !wxBindPhone.trim() || !wxBindCode.trim();
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
open={isOpen}
|
|
title={reason === "unauthorized" ? t("auth.loginExpired") : t("auth.loginTitle")}
|
|
onCancel={() => {
|
|
stopWxPoll();
|
|
closeLoginModal();
|
|
}}
|
|
footer={null}
|
|
centered
|
|
width={420}
|
|
destroyOnHidden
|
|
mask={{ closable: false }}
|
|
>
|
|
<div className="flex flex-col gap-4 pt-1">
|
|
{reason === "unauthorized" ? (
|
|
<Alert type="warning" showIcon title={t("auth.reloginRequired")} />
|
|
) : null}
|
|
{error ? <Alert type="error" showIcon title={error} /> : null}
|
|
|
|
<Segmented
|
|
block
|
|
value={mode}
|
|
onChange={(value) => {
|
|
setMode(value as LoginMode);
|
|
setError("");
|
|
}}
|
|
options={[
|
|
{ label: t("auth.codeLogin"), value: "code" },
|
|
{ label: t("auth.wechatLogin"), value: "wechat" },
|
|
{ label: t("auth.passwordLogin"), value: "password" },
|
|
]}
|
|
/>
|
|
|
|
{mode === "wechat" ? (
|
|
wxNeedBindPhone ? (
|
|
<>
|
|
<Input
|
|
value={wxBindPhone}
|
|
onChange={(event) => setWxBindPhone(event.target.value)}
|
|
placeholder={t("auth.phonePlaceholder")}
|
|
autoComplete="tel"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={wxBindCode}
|
|
onChange={(event) => setWxBindCode(event.target.value)}
|
|
placeholder={t("auth.smsCodePlaceholder")}
|
|
autoComplete="one-time-code"
|
|
onPressEnter={() => {
|
|
if (!isWxBindSubmitDisabled) void handleWxBindPhone();
|
|
}}
|
|
/>
|
|
<Button
|
|
className="shrink-0"
|
|
onClick={() => void handleSendCode(wxBindPhone, "wechat-bind")}
|
|
loading={isSendingCode}
|
|
disabled={!wxBindPhone.trim() || countdown > 0}
|
|
>
|
|
{countdown > 0 ? `${countdown}s` : t("auth.sendCode")}
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
value={wxBindInviteCode}
|
|
onChange={(event) => setWxBindInviteCode(event.target.value)}
|
|
placeholder={t("auth.inviteCodePlaceholder")}
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
block
|
|
onClick={() => void handleWxBindPhone()}
|
|
loading={isLoading}
|
|
disabled={isWxBindSubmitDisabled}
|
|
>
|
|
{t("auth.bindPhoneConfirm")}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-3">
|
|
{wxQrError ? <Alert type="error" showIcon title={wxQrError} className="w-full" /> : null}
|
|
{wxQrUrl ? (
|
|
<img
|
|
src={wxQrUrl}
|
|
alt={t("auth.wechatScanAlt")}
|
|
className="h-48 w-48 rounded-lg border border-neutral-200 bg-white object-contain p-2"
|
|
/>
|
|
) : (
|
|
<div className="flex h-48 w-48 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-sm text-neutral-500">
|
|
{wxQrLoading ? t("auth.loading") : t("auth.wechatNoQr")}
|
|
</div>
|
|
)}
|
|
<div className="text-sm text-neutral-500">{t("auth.wechatScanTip")}</div>
|
|
<Button onClick={() => void loadWxQrCode()} loading={wxQrLoading}>
|
|
{t("auth.wechatReloadQr")}
|
|
</Button>
|
|
<p className="text-center text-xs leading-5 text-neutral-500">
|
|
登录即代表同意
|
|
<a
|
|
href="https://www.popi.art/content/userTerms"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
《用户协议》
|
|
</a>
|
|
和
|
|
<a
|
|
href="https://www.popi.art/content/privacyPolicy"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
《隐私政策》
|
|
</a>
|
|
</p>
|
|
</div>
|
|
)
|
|
) : (
|
|
<>
|
|
<Input
|
|
value={phone}
|
|
onChange={(event) => setPhone(event.target.value)}
|
|
placeholder={t("auth.phonePlaceholder")}
|
|
autoComplete="tel"
|
|
/>
|
|
|
|
{mode === "password" ? (
|
|
<Input.Password
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
placeholder={t("auth.passwordPlaceholder")}
|
|
autoComplete="current-password"
|
|
onPressEnter={() => {
|
|
if (!isSubmitDisabled) void handleLogin();
|
|
}}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={code}
|
|
onChange={(event) => setCode(event.target.value)}
|
|
placeholder={t("auth.smsCodePlaceholder")}
|
|
autoComplete="one-time-code"
|
|
onPressEnter={() => {
|
|
if (!isSubmitDisabled) void handleLogin();
|
|
}}
|
|
/>
|
|
<Button
|
|
className="shrink-0"
|
|
onClick={() => void handleSendCode(phone, "login")}
|
|
loading={isSendingCode}
|
|
disabled={!phone.trim() || countdown > 0}
|
|
>
|
|
{countdown > 0 ? `${countdown}s` : t("auth.sendCode")}
|
|
</Button>
|
|
</div>
|
|
|
|
<Input
|
|
value={inviteCode}
|
|
onChange={(event) => setInviteCode(event.target.value)}
|
|
placeholder={t("auth.inviteCodePlaceholder")}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
block
|
|
onClick={() => void handleLogin()}
|
|
loading={isLoading}
|
|
disabled={isSubmitDisabled}
|
|
>
|
|
{mode === "password" ? t("auth.login") : t("auth.loginOrRegister")}
|
|
</Button>
|
|
<Checkbox
|
|
checked={agreementChecked}
|
|
onChange={(event) => setAgreementChecked(event.target.checked)}
|
|
className="text-xs leading-5 text-neutral-500"
|
|
>
|
|
登录即代表同意
|
|
<a
|
|
href="https://www.popi.art/content/userTerms"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
《用户协议》
|
|
</a>
|
|
和
|
|
<a
|
|
href="https://www.popi.art/content/privacyPolicy"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
《隐私政策》
|
|
</a>
|
|
,未注册手机号将自动注册
|
|
</Checkbox>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={captchaOpen}
|
|
title={t("auth.captchaTitle")}
|
|
onCancel={() => setCaptchaOpen(false)}
|
|
footer={null}
|
|
centered
|
|
width={360}
|
|
destroyOnHidden
|
|
mask={{ closable: true }}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{captchaError ? <Alert type="error" showIcon title={captchaError} /> : null}
|
|
<div className="flex items-center justify-center">
|
|
{captchaImage ? (
|
|
<img
|
|
src={captchaImage}
|
|
alt={t("auth.captchaAlt")}
|
|
className="h-[72px] cursor-pointer rounded border border-neutral-200 bg-white"
|
|
onClick={() => void loadCaptcha()}
|
|
/>
|
|
) : (
|
|
<div className="flex h-[72px] w-[180px] items-center justify-center rounded border border-neutral-200 bg-neutral-50 text-sm text-neutral-500">
|
|
{t("auth.loading")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button size="small" onClick={() => void loadCaptcha()}>
|
|
{t("auth.captchaRefresh")}
|
|
</Button>
|
|
<Input
|
|
value={captchaValue}
|
|
onChange={(event) => setCaptchaValue(event.target.value)}
|
|
placeholder={t("auth.captchaInputPlaceholder")}
|
|
onPressEnter={() => void handleCaptchaOk()}
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
block
|
|
loading={captchaLoading || isSendingCode}
|
|
disabled={!captchaValue.trim() || !captchaId}
|
|
onClick={() => void handleCaptchaOk()}
|
|
>
|
|
{t("auth.confirm")}
|
|
</Button>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|