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

"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>
</>
);
}