936 lines
33 KiB
JavaScript
936 lines
33 KiB
JavaScript
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import {
|
||
API_BASE,
|
||
authClientFetchHeaders,
|
||
buildAuthCallbackUrl,
|
||
clearAuthClientContext,
|
||
fetchClientVisitMeta,
|
||
formatAuthBanMessage,
|
||
getOAuthReturnTo,
|
||
normalizeSelfServiceAccountInput,
|
||
persistAuthClientFromFlow,
|
||
randomSelfServiceAccount
|
||
} from "../config";
|
||
import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
|
||
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
|
||
import OAuthConsentScreen from "./OAuthConsentScreen";
|
||
|
||
export default function UserPortal({
|
||
onReady,
|
||
authFlow,
|
||
onPreviewImage,
|
||
oauthStandalone = false,
|
||
onGuestAuthLayoutChange
|
||
}) {
|
||
const [account, setAccount] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [user, setUser] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [mode, setMode] = useState("login");
|
||
|
||
const [registerForm, setRegisterForm] = useState({
|
||
account: "", password: "", username: "", email: "", code: "", inviteCode: ""
|
||
});
|
||
const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);
|
||
const [inviteRegisterReward, setInviteRegisterReward] = useState(10);
|
||
const [registrationAccountMin, setRegistrationAccountMin] = useState(3);
|
||
const [registrationAccountMax, setRegistrationAccountMax] = useState(32);
|
||
const [registerSent, setRegisterSent] = useState(false);
|
||
const [registerExpiresAt, setRegisterExpiresAt] = useState("");
|
||
const [registerLoading, setRegisterLoading] = useState(false);
|
||
const [registerError, setRegisterError] = useState("");
|
||
const [registerMessage, setRegisterMessage] = useState("");
|
||
|
||
const [resetForm, setResetForm] = useState({
|
||
account: "", email: "", code: "", newPassword: ""
|
||
});
|
||
const [resetSent, setResetSent] = useState(false);
|
||
const [resetExpiresAt, setResetExpiresAt] = useState("");
|
||
const [resetLoading, setResetLoading] = useState(false);
|
||
const [resetError, setResetError] = useState("");
|
||
const [resetMessage, setResetMessage] = useState("");
|
||
|
||
const [secondaryForm, setSecondaryForm] = useState({ email: "", code: "" });
|
||
const [secondarySent, setSecondarySent] = useState(false);
|
||
const [secondaryExpiresAt, setSecondaryExpiresAt] = useState("");
|
||
const [secondaryLoading, setSecondaryLoading] = useState(false);
|
||
const [secondaryError, setSecondaryError] = useState("");
|
||
const [secondaryMessage, setSecondaryMessage] = useState("");
|
||
|
||
const [profileForm, setProfileForm] = useState({
|
||
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
|
||
});
|
||
const [profileLoading, setProfileLoading] = useState(false);
|
||
const [profileError, setProfileError] = useState("");
|
||
const [profileMessage, setProfileMessage] = useState("");
|
||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||
const [profileEditorFocus, setProfileEditorFocus] = useState("");
|
||
const [secondaryEditorOpen, setSecondaryEditorOpen] = useState(false);
|
||
|
||
const [checkInReward, setCheckInReward] = useState(1);
|
||
const [checkInToday, setCheckInToday] = useState(false);
|
||
const [checkInLoading, setCheckInLoading] = useState(false);
|
||
const [checkInError, setCheckInError] = useState("");
|
||
const [checkInMessage, setCheckInMessage] = useState("");
|
||
|
||
const [githubOAuthEnabled, setGithubOAuthEnabled] = useState(false);
|
||
const [giteaOAuthEnabled, setGiteaOAuthEnabled] = useState(false);
|
||
const [googleOAuthEnabled, setGoogleOAuthEnabled] = useState(false);
|
||
const [microsoftOAuthEnabled, setMicrosoftOAuthEnabled] = useState(false);
|
||
const [linuxdoOAuthEnabled, setLinuxdoOAuthEnabled] = useState(false);
|
||
const [oauthLogos, setOauthLogos] = useState({
|
||
github: "", gitea: "", google: "", microsoft: "", linuxdo: ""
|
||
});
|
||
|
||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||
const [turnstileSiteKey, setTurnstileSiteKey] = useState("");
|
||
const [loginTurnstileToken, setLoginTurnstileToken] = useState("");
|
||
const [registerTurnstileToken, setRegisterTurnstileToken] = useState("");
|
||
const loginTurnstileRef = useRef(null);
|
||
const registerTurnstileRef = useRef(null);
|
||
|
||
const isAuthFlow = Boolean(authFlow?.redirectUri);
|
||
|
||
const redirectToAuthCallback = (tokenValue, userData, expiresAt = "") => {
|
||
if (!isAuthFlow || !authFlow?.redirectUri || !tokenValue) return;
|
||
const callbackUrl = buildAuthCallbackUrl(authFlow.redirectUri, {
|
||
token: tokenValue,
|
||
account: userData?.account || "",
|
||
username: userData?.username || "",
|
||
expiresAt,
|
||
state: authFlow.state || ""
|
||
});
|
||
window.location.replace(callbackUrl);
|
||
};
|
||
|
||
const clearTokenAndUser = () => {
|
||
localStorage.removeItem("sproutgate_token");
|
||
localStorage.removeItem("sproutgate_token_expires_at");
|
||
setUser(null);
|
||
setProfileForm({
|
||
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
|
||
});
|
||
};
|
||
|
||
const bearerHeaders = (tokenValue) => ({
|
||
...authClientFetchHeaders(),
|
||
Authorization: `Bearer ${tokenValue}`
|
||
});
|
||
|
||
const syncCurrentUser = async (tokenValue) => {
|
||
const baseHeaders = bearerHeaders(tokenValue);
|
||
const res = await fetch(`${API_BASE}/api/auth/me`, { headers: baseHeaders });
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
if (res.status === 403) {
|
||
clearTokenAndUser();
|
||
const msg = formatAuthBanMessage(data);
|
||
setError(msg);
|
||
throw new Error(msg);
|
||
}
|
||
throw new Error(data.error || "加载用户失败");
|
||
}
|
||
if (data.user) setUser(data.user);
|
||
const checkIn = data.checkIn || {};
|
||
setCheckInReward(Number(checkIn.rewardCoins) || 1);
|
||
setCheckInToday(Boolean(checkIn.checkedInToday));
|
||
|
||
fetchClientVisitMeta()
|
||
.then((meta) => {
|
||
if (!meta.ip && !meta.displayLocation) return null;
|
||
const h = { ...baseHeaders };
|
||
if (meta.ip) h["X-Visit-Ip"] = meta.ip;
|
||
if (meta.displayLocation) h["X-Visit-Location"] = meta.displayLocation;
|
||
return fetch(`${API_BASE}/api/auth/me`, { headers: h });
|
||
})
|
||
.then(async (r) => {
|
||
if (!r) return null;
|
||
const d = await r.json().catch(() => ({}));
|
||
if (r.status === 403) {
|
||
clearTokenAndUser();
|
||
setError(formatAuthBanMessage(d));
|
||
return null;
|
||
}
|
||
return r.ok ? d : null;
|
||
})
|
||
.then((d) => {
|
||
if (!d) return;
|
||
if (d.user) setUser(d.user);
|
||
const c = d.checkIn;
|
||
if (c) {
|
||
setCheckInReward(Number(c.rewardCoins) || 1);
|
||
setCheckInToday(Boolean(c.checkedInToday));
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
|
||
return data;
|
||
};
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const done = () => { if (!cancelled && onReady) onReady(); };
|
||
const hash = (window.location.hash || "").replace(/^#/, "");
|
||
if (hash) {
|
||
const p = new URLSearchParams(hash);
|
||
const ot = p.get("oauth_token");
|
||
if (ot) {
|
||
const ex = p.get("oauth_expires_at") || "";
|
||
localStorage.setItem("sproutgate_token", ot);
|
||
localStorage.setItem("sproutgate_token_expires_at", ex);
|
||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||
syncCurrentUser(ot).catch(() => {}).finally(() => done());
|
||
return () => { cancelled = true; };
|
||
}
|
||
if (p.get("oauth_error")) {
|
||
let desc = p.get("oauth_error_description") || "OAuth 登录失败";
|
||
try {
|
||
desc = decodeURIComponent(desc.replace(/\+/g, " "));
|
||
} catch { /* use raw */ }
|
||
setError(desc);
|
||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||
done();
|
||
return () => { cancelled = true; };
|
||
}
|
||
if (p.get("oauth_bound")) {
|
||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||
const t = localStorage.getItem("sproutgate_token");
|
||
if (t) {
|
||
syncCurrentUser(t).catch(() => {}).finally(() => done());
|
||
} else {
|
||
done();
|
||
}
|
||
return () => { cancelled = true; };
|
||
}
|
||
}
|
||
const token = localStorage.getItem("sproutgate_token");
|
||
if (token) {
|
||
syncCurrentUser(token).catch(() => {}).finally(() => done());
|
||
} else {
|
||
done();
|
||
}
|
||
return () => { cancelled = true; };
|
||
}, [onReady]);
|
||
|
||
useEffect(() => {
|
||
persistAuthClientFromFlow(authFlow);
|
||
}, [authFlow]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!onGuestAuthLayoutChange) return;
|
||
const invalidOAuthStandalone = Boolean(
|
||
oauthStandalone && !(authFlow?.redirectUri || "").trim()
|
||
);
|
||
onGuestAuthLayoutChange(Boolean(!user && !invalidOAuthStandalone));
|
||
}, [user, oauthStandalone, authFlow, onGuestAuthLayoutChange]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/public/registration-policy`);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!cancelled && res.ok) {
|
||
setRegistrationRequireInvite(Boolean(data.requireInviteCode));
|
||
const ir = Number(data.inviteRegisterRewardCoins);
|
||
if (Number.isFinite(ir) && ir >= 0) setInviteRegisterReward(ir);
|
||
const am = Number(data.selfServiceAccountMax);
|
||
const ami = Number(data.selfServiceAccountMin);
|
||
if (Number.isFinite(am) && am >= 3 && am <= 32) setRegistrationAccountMax(am);
|
||
if (Number.isFinite(ami) && ami >= 3 && ami <= 32) setRegistrationAccountMin(ami);
|
||
setGithubOAuthEnabled(Boolean(data.githubOAuthEnabled));
|
||
setGiteaOAuthEnabled(Boolean(data.giteaOAuthEnabled));
|
||
setGoogleOAuthEnabled(Boolean(data.googleOAuthEnabled));
|
||
setMicrosoftOAuthEnabled(Boolean(data.microsoftOAuthEnabled));
|
||
setLinuxdoOAuthEnabled(Boolean(data.linuxdoOAuthEnabled));
|
||
setOauthLogos({
|
||
github: String(data.githubOAuthLogoUrl || "").trim(),
|
||
gitea: String(data.giteaOAuthLogoUrl || "").trim(),
|
||
google: String(data.googleOAuthLogoUrl || "").trim(),
|
||
microsoft: String(data.microsoftOAuthLogoUrl || "").trim(),
|
||
linuxdo: String(data.linuxdoOAuthLogoUrl || "").trim()
|
||
});
|
||
setTurnstileEnabled(Boolean(data.turnstileEnabled));
|
||
setTurnstileSiteKey(String(data.turnstileSiteKey || "").trim());
|
||
}
|
||
} catch {
|
||
/* 忽略,默认不要求邀请码 */
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// 仅在外部 user 更新且资料弹窗关闭时同步表单,避免 /api/auth/me 二次请求在编辑期间把表单项覆盖回服务器值(QQ 用户会把已填写的自定义头像链接清空)
|
||
useEffect(() => {
|
||
if (!user || profileEditorOpen) return;
|
||
setProfileForm({
|
||
username: user.username || "",
|
||
phone: user.phone || "",
|
||
avatarUrl: user.customAvatarUrl ?? "",
|
||
websiteUrl: user.websiteUrl || "",
|
||
bio: user.bio || "",
|
||
password: ""
|
||
});
|
||
}, [user, profileEditorOpen]);
|
||
|
||
const handleLogin = async (event) => {
|
||
event.preventDefault();
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const loginPayload = { account, password };
|
||
if (turnstileEnabled) loginPayload.turnstileToken = loginTurnstileToken;
|
||
const cid = (authFlow?.clientId || "").trim();
|
||
const cname = (authFlow?.clientName || "").trim();
|
||
if (cid) {
|
||
loginPayload.clientId = cid;
|
||
if (cname) loginPayload.clientName = cname;
|
||
} else {
|
||
const h = authClientFetchHeaders();
|
||
if (h["X-Auth-Client"]) {
|
||
loginPayload.clientId = h["X-Auth-Client"];
|
||
if (h["X-Auth-Client-Name"]) loginPayload.clientName = h["X-Auth-Client-Name"];
|
||
}
|
||
}
|
||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(loginPayload)
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
throw new Error(
|
||
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "登录失败")
|
||
);
|
||
}
|
||
localStorage.setItem("sproutgate_token", data.token);
|
||
localStorage.setItem("sproutgate_token_expires_at", data.expiresAt || "");
|
||
setUser(data.user);
|
||
syncCurrentUser(data.token).catch(() => {});
|
||
setAccount("");
|
||
setPassword("");
|
||
if (isAuthFlow && !oauthStandalone) {
|
||
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
setError(err.message);
|
||
setLoginTurnstileToken("");
|
||
loginTurnstileRef.current?.reset();
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
clearAuthClientContext();
|
||
clearTokenAndUser();
|
||
setProfileEditorOpen(false);
|
||
setProfileEditorFocus("");
|
||
setSecondaryEditorOpen(false);
|
||
setCheckInReward(1);
|
||
setCheckInToday(false);
|
||
setCheckInError("");
|
||
setCheckInMessage("");
|
||
};
|
||
|
||
const handleContinueAuth = () => {
|
||
const tokenValue = localStorage.getItem("sproutgate_token") || "";
|
||
if (!tokenValue) {
|
||
setError("当前没有可用的登录会话,请先重新登录");
|
||
return;
|
||
}
|
||
redirectToAuthCallback(
|
||
tokenValue, user,
|
||
localStorage.getItem("sproutgate_token_expires_at") || ""
|
||
);
|
||
};
|
||
|
||
const handleSwitchAuthAccount = () => {
|
||
handleLogout();
|
||
setMode("login");
|
||
setAccount("");
|
||
setPassword("");
|
||
setError("");
|
||
};
|
||
|
||
const handleRegisterChange = (field, value) => {
|
||
if (field === "account") {
|
||
value = normalizeSelfServiceAccountInput(value, registrationAccountMax);
|
||
}
|
||
setRegisterForm((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const resetRegisterFlow = () => {
|
||
setRegisterSent(false);
|
||
setRegisterExpiresAt("");
|
||
setRegisterForm({
|
||
account: randomSelfServiceAccount(10),
|
||
password: "",
|
||
username: "",
|
||
email: "",
|
||
code: "",
|
||
inviteCode: ""
|
||
});
|
||
};
|
||
|
||
const handleSendCode = async (event) => {
|
||
event.preventDefault();
|
||
setRegisterLoading(true);
|
||
setRegisterError("");
|
||
setRegisterMessage("");
|
||
if (!registerForm.account || !registerForm.password || !registerForm.email) {
|
||
setRegisterError("请填写账户、密码和邮箱");
|
||
setRegisterLoading(false);
|
||
return;
|
||
}
|
||
if (registerForm.account.length < registrationAccountMin) {
|
||
setRegisterError(`账户至少 ${registrationAccountMin} 位,仅小写字母与数字`);
|
||
setRegisterLoading(false);
|
||
return;
|
||
}
|
||
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
|
||
setRegisterError("请输入邀请码");
|
||
setRegisterLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
const payload = {
|
||
account: registerForm.account,
|
||
password: registerForm.password,
|
||
username: registerForm.username,
|
||
email: registerForm.email
|
||
};
|
||
if (turnstileEnabled) payload.turnstileToken = registerTurnstileToken;
|
||
const inv = String(registerForm.inviteCode || "").trim();
|
||
if (inv) payload.inviteCode = inv;
|
||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "发送验证码失败");
|
||
setRegisterSent(true);
|
||
setRegisterExpiresAt(data.expiresAt || "");
|
||
setRegisterMessage("验证码已发送,请检查邮箱");
|
||
} catch (err) {
|
||
setRegisterError(err.message);
|
||
setRegisterTurnstileToken("");
|
||
registerTurnstileRef.current?.reset();
|
||
} finally {
|
||
setRegisterLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleVerifyRegister = async (event) => {
|
||
event.preventDefault();
|
||
setRegisterLoading(true);
|
||
setRegisterError("");
|
||
setRegisterMessage("");
|
||
if (!registerForm.account || !registerForm.code) {
|
||
setRegisterError("请输入账户与验证码");
|
||
setRegisterLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/verify-email`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ account: registerForm.account, code: registerForm.code })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "验证失败");
|
||
const coins = Number(data.user?.sproutCoins);
|
||
setRegisterMessage(
|
||
Number.isFinite(coins) && coins > 0
|
||
? `注册成功,已获得 ${coins} 萌芽币,请使用账号登录`
|
||
: "注册成功,请使用账号登录"
|
||
);
|
||
setRegisterSent(false);
|
||
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
|
||
setMode("login");
|
||
} catch (err) {
|
||
setRegisterError(err.message);
|
||
} finally {
|
||
setRegisterLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleResetChange = (field, value) => {
|
||
setResetForm((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const handleSendReset = async (event) => {
|
||
event.preventDefault();
|
||
setResetLoading(true);
|
||
setResetError("");
|
||
setResetMessage("");
|
||
if (!resetForm.account || !resetForm.email) {
|
||
setResetError("请填写账户与邮箱");
|
||
setResetLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/forgot-password`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ account: resetForm.account, email: resetForm.email })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
throw new Error(
|
||
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "发送重置邮件失败")
|
||
);
|
||
}
|
||
setResetSent(true);
|
||
setResetExpiresAt(data.expiresAt || "");
|
||
setResetMessage("重置验证码已发送,请检查邮箱");
|
||
} catch (err) {
|
||
setResetError(err.message);
|
||
} finally {
|
||
setResetLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleResetPassword = async (event) => {
|
||
event.preventDefault();
|
||
setResetLoading(true);
|
||
setResetError("");
|
||
setResetMessage("");
|
||
if (!resetForm.account || !resetForm.code || !resetForm.newPassword) {
|
||
setResetError("请填写账户、验证码与新密码");
|
||
setResetLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/reset-password`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
account: resetForm.account,
|
||
code: resetForm.code,
|
||
newPassword: resetForm.newPassword
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
throw new Error(
|
||
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "重置失败")
|
||
);
|
||
}
|
||
setResetMessage("密码已重置,请使用新密码登录");
|
||
setResetSent(false);
|
||
setResetForm({ account: "", email: "", code: "", newPassword: "" });
|
||
setMode("login");
|
||
} catch (err) {
|
||
setResetError(err.message);
|
||
} finally {
|
||
setResetLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSecondaryChange = (field, value) => {
|
||
setSecondaryForm((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const handleSendSecondary = async (event) => {
|
||
event.preventDefault();
|
||
setSecondaryLoading(true);
|
||
setSecondaryError("");
|
||
setSecondaryMessage("");
|
||
const token = localStorage.getItem("sproutgate_token");
|
||
if (!token) { setSecondaryError("请先登录"); setSecondaryLoading(false); return; }
|
||
if (!secondaryForm.email) { setSecondaryError("请输入辅助邮箱"); setSecondaryLoading(false); return; }
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/secondary-email/request`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
|
||
body: JSON.stringify({ email: secondaryForm.email })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
if (res.status === 403) {
|
||
clearTokenAndUser();
|
||
setSecondaryError(formatAuthBanMessage(data));
|
||
} else {
|
||
throw new Error(data.error || "发送验证码失败");
|
||
}
|
||
return;
|
||
}
|
||
setSecondarySent(true);
|
||
setSecondaryExpiresAt(data.expiresAt || "");
|
||
setSecondaryMessage("验证码已发送,请检查邮箱");
|
||
} catch (err) {
|
||
setSecondaryError(err.message);
|
||
} finally {
|
||
setSecondaryLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleVerifySecondary = async (event) => {
|
||
event.preventDefault();
|
||
setSecondaryLoading(true);
|
||
setSecondaryError("");
|
||
setSecondaryMessage("");
|
||
const token = localStorage.getItem("sproutgate_token");
|
||
if (!token) { setSecondaryError("请先登录"); setSecondaryLoading(false); return; }
|
||
if (!secondaryForm.email || !secondaryForm.code) {
|
||
setSecondaryError("请输入邮箱与验证码");
|
||
setSecondaryLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/secondary-email/verify`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
|
||
body: JSON.stringify({ email: secondaryForm.email, code: secondaryForm.code })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
if (res.status === 403) {
|
||
clearTokenAndUser();
|
||
setSecondaryError(formatAuthBanMessage(data));
|
||
} else {
|
||
throw new Error(data.error || "验证失败");
|
||
}
|
||
return;
|
||
}
|
||
if (data.user) setUser(data.user);
|
||
setSecondaryMessage("辅助邮箱验证成功");
|
||
setSecondarySent(false);
|
||
setSecondaryForm({ email: "", code: "" });
|
||
setSecondaryEditorOpen(false);
|
||
} catch (err) {
|
||
setSecondaryError(err.message);
|
||
} finally {
|
||
setSecondaryLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleProfileChange = (field, value) => {
|
||
setProfileForm((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const handleProfileSave = async (event) => {
|
||
event.preventDefault();
|
||
setProfileLoading(true);
|
||
setProfileError("");
|
||
setProfileMessage("");
|
||
const token = localStorage.getItem("sproutgate_token");
|
||
if (!token) { setProfileError("请先登录"); setProfileLoading(false); return; }
|
||
const payload = {
|
||
username: profileForm.username,
|
||
phone: profileForm.phone,
|
||
avatarUrl: profileForm.avatarUrl,
|
||
websiteUrl: profileForm.websiteUrl,
|
||
bio: profileForm.bio
|
||
};
|
||
if (profileForm.password) payload.password = profileForm.password;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/profile`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
if (res.status === 403) {
|
||
clearTokenAndUser();
|
||
setProfileError(formatAuthBanMessage(data));
|
||
} else {
|
||
throw new Error(data.error || "保存失败");
|
||
}
|
||
return;
|
||
}
|
||
setUser(data.user);
|
||
setProfileMessage("保存成功");
|
||
setProfileForm((prev) => ({ ...prev, password: "" }));
|
||
setProfileEditorOpen(false);
|
||
setProfileEditorFocus("");
|
||
} catch (err) {
|
||
setProfileError(err.message);
|
||
} finally {
|
||
setProfileLoading(false);
|
||
}
|
||
};
|
||
|
||
const openProfileEditor = (field = "") => {
|
||
setProfileError("");
|
||
setProfileMessage("");
|
||
if (user) {
|
||
setProfileForm({
|
||
username: user.username || "",
|
||
phone: user.phone || "",
|
||
avatarUrl: user.customAvatarUrl ?? "",
|
||
websiteUrl: user.websiteUrl || "",
|
||
bio: user.bio || "",
|
||
password: ""
|
||
});
|
||
}
|
||
setProfileEditorFocus(field);
|
||
setProfileEditorOpen(true);
|
||
};
|
||
|
||
const openSecondaryEditor = () => {
|
||
setSecondaryError("");
|
||
setSecondaryMessage("");
|
||
setSecondarySent(false);
|
||
setSecondaryForm({ email: "", code: "" });
|
||
setSecondaryEditorOpen(true);
|
||
};
|
||
|
||
const closeProfileEditor = () => {
|
||
setProfileEditorOpen(false);
|
||
setProfileEditorFocus("");
|
||
};
|
||
|
||
const closeSecondaryEditor = () => {
|
||
setSecondaryEditorOpen(false);
|
||
setSecondarySent(false);
|
||
setSecondaryForm({ email: "", code: "" });
|
||
setSecondaryError("");
|
||
setSecondaryMessage("");
|
||
};
|
||
|
||
const profileFocusLabels = {
|
||
phone: "手机号",
|
||
avatarUrl: "头像",
|
||
websiteUrl: "个人主页",
|
||
bio: "简介",
|
||
username: "用户名"
|
||
};
|
||
const profileModalTitle = profileEditorFocus && profileFocusLabels[profileEditorFocus]
|
||
? `修改${profileFocusLabels[profileEditorFocus]}`
|
||
: "修改资料";
|
||
|
||
const handleCheckIn = async () => {
|
||
const token = localStorage.getItem("sproutgate_token");
|
||
if (!token) { setCheckInError("请先登录"); return; }
|
||
setCheckInLoading(true);
|
||
setCheckInError("");
|
||
setCheckInMessage("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/check-in`, {
|
||
method: "POST",
|
||
headers: bearerHeaders(token)
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
if (res.status === 403) {
|
||
clearTokenAndUser();
|
||
setCheckInError(formatAuthBanMessage(data));
|
||
} else {
|
||
throw new Error(data.error || "签到失败");
|
||
}
|
||
return;
|
||
}
|
||
if (data.user) setUser(data.user);
|
||
const checkIn = data.checkIn || {};
|
||
setCheckInReward(Number(checkIn.rewardCoins) || 1);
|
||
setCheckInToday(Boolean(checkIn.checkedInToday));
|
||
setCheckInMessage(
|
||
data.message || (data.alreadyCheckedIn ? "今日已签到" : `签到成功,获得 ${data.awardedCoins ?? data.rewardCoins ?? 0} 萌芽币`)
|
||
);
|
||
} catch (err) {
|
||
setCheckInError(err.message);
|
||
} finally {
|
||
setCheckInLoading(false);
|
||
}
|
||
};
|
||
|
||
const openOAuthLogin = (provider) => {
|
||
const ret = getOAuthReturnTo();
|
||
if (!ret) return;
|
||
if (turnstileEnabled) {
|
||
const tok = mode === "register" ? registerTurnstileToken : loginTurnstileToken;
|
||
if (!tok) {
|
||
if (mode === "register") {
|
||
setRegisterError("请先完成人机验证后再使用第三方登录");
|
||
} else {
|
||
setError("请先完成人机验证后再使用第三方登录");
|
||
}
|
||
return;
|
||
}
|
||
const u = new URL(`${API_BASE}/api/auth/oauth/${encodeURIComponent(provider)}/start`);
|
||
u.searchParams.set("return_to", ret);
|
||
u.searchParams.set("turnstile_token", tok);
|
||
window.location.href = u.toString();
|
||
return;
|
||
}
|
||
window.location.href = `${API_BASE}/api/auth/oauth/${provider}/start?return_to=${encodeURIComponent(ret)}`;
|
||
};
|
||
|
||
const bindOAuth = async (provider) => {
|
||
const t = localStorage.getItem("sproutgate_token");
|
||
if (!t) return;
|
||
setProfileError("");
|
||
setProfileMessage("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/oauth/${provider}/bind`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${t}`,
|
||
...authClientFetchHeaders()
|
||
},
|
||
body: JSON.stringify({ returnTo: getOAuthReturnTo() })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "无法开始绑定");
|
||
if (data.url) window.location.href = data.url;
|
||
} catch (e) {
|
||
setProfileError(e.message || "绑定失败");
|
||
}
|
||
};
|
||
|
||
const unlinkOAuth = async (provider) => {
|
||
if (!window.confirm("确定要解绑该第三方账号吗?")) return;
|
||
const t = localStorage.getItem("sproutgate_token");
|
||
if (!t) return;
|
||
setProfileError("");
|
||
setProfileMessage("");
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/oauth/${provider}`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${t}`, ...authClientFetchHeaders() }
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "解绑失败");
|
||
if (data.user) setUser(data.user);
|
||
setProfileMessage("已更新第三方账号绑定");
|
||
} catch (e) {
|
||
setProfileError(e.message || "解绑失败");
|
||
}
|
||
};
|
||
|
||
if (oauthStandalone && !authFlow?.redirectUri?.trim()) {
|
||
return (
|
||
<section className="panel">
|
||
<div className="card form oauth-authorize-invalid">
|
||
<h2>无效授权链接</h2>
|
||
<p className="hint">缺少回跳地址(redirect_uri 或 return_url)。请从第三方应用重新发起登录。</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const showOAuthConsent = oauthStandalone && isAuthFlow && Boolean(user);
|
||
const showProfile = Boolean(user) && !showOAuthConsent;
|
||
|
||
return (
|
||
<section className="panel">
|
||
<UserPortalAuthSection
|
||
isAuthFlow={isAuthFlow}
|
||
oauthStandalone={oauthStandalone}
|
||
user={user}
|
||
mode={mode}
|
||
setMode={setMode}
|
||
account={account}
|
||
setAccount={setAccount}
|
||
password={password}
|
||
setPassword={setPassword}
|
||
loading={loading}
|
||
error={error}
|
||
setError={setError}
|
||
handleLogin={handleLogin}
|
||
registerForm={registerForm}
|
||
handleRegisterChange={handleRegisterChange}
|
||
registerSent={registerSent}
|
||
registerExpiresAt={registerExpiresAt}
|
||
registerLoading={registerLoading}
|
||
registerError={registerError}
|
||
registerMessage={registerMessage}
|
||
handleSendCode={handleSendCode}
|
||
handleVerifyRegister={handleVerifyRegister}
|
||
setRegisterError={setRegisterError}
|
||
setRegisterMessage={setRegisterMessage}
|
||
registrationRequireInvite={registrationRequireInvite}
|
||
inviteRegisterReward={inviteRegisterReward}
|
||
registrationAccountMin={registrationAccountMin}
|
||
registrationAccountMax={registrationAccountMax}
|
||
resetRegisterFlow={resetRegisterFlow}
|
||
resetForm={resetForm}
|
||
handleResetChange={handleResetChange}
|
||
resetSent={resetSent}
|
||
resetExpiresAt={resetExpiresAt}
|
||
resetLoading={resetLoading}
|
||
resetError={resetError}
|
||
resetMessage={resetMessage}
|
||
handleSendReset={handleSendReset}
|
||
handleResetPassword={handleResetPassword}
|
||
setResetError={setResetError}
|
||
setResetMessage={setResetMessage}
|
||
githubOAuthEnabled={githubOAuthEnabled}
|
||
giteaOAuthEnabled={giteaOAuthEnabled}
|
||
googleOAuthEnabled={googleOAuthEnabled}
|
||
microsoftOAuthEnabled={microsoftOAuthEnabled}
|
||
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
|
||
oauthLogos={oauthLogos}
|
||
onOAuthLogin={openOAuthLogin}
|
||
turnstileEnabled={turnstileEnabled}
|
||
turnstileSiteKey={turnstileSiteKey}
|
||
loginTurnstileToken={loginTurnstileToken}
|
||
onLoginTurnstileToken={setLoginTurnstileToken}
|
||
loginTurnstileRef={loginTurnstileRef}
|
||
registerTurnstileToken={registerTurnstileToken}
|
||
onRegisterTurnstileToken={setRegisterTurnstileToken}
|
||
registerTurnstileRef={registerTurnstileRef}
|
||
/>
|
||
{showOAuthConsent && (
|
||
<OAuthConsentScreen
|
||
authFlow={authFlow}
|
||
user={user}
|
||
onAllow={handleContinueAuth}
|
||
onSwitchAccount={handleSwitchAuthAccount}
|
||
onPreviewImage={onPreviewImage}
|
||
/>
|
||
)}
|
||
{showProfile && (
|
||
<UserPortalProfileSection
|
||
user={user}
|
||
onPreviewImage={onPreviewImage}
|
||
handleLogout={handleLogout}
|
||
openProfileEditor={openProfileEditor}
|
||
openSecondaryEditor={openSecondaryEditor}
|
||
profileEditorOpen={profileEditorOpen}
|
||
closeProfileEditor={closeProfileEditor}
|
||
handleProfileSave={handleProfileSave}
|
||
profileForm={profileForm}
|
||
handleProfileChange={handleProfileChange}
|
||
profileLoading={profileLoading}
|
||
profileError={profileError}
|
||
profileMessage={profileMessage}
|
||
profileModalTitle={profileModalTitle}
|
||
secondaryEditorOpen={secondaryEditorOpen}
|
||
closeSecondaryEditor={closeSecondaryEditor}
|
||
handleSendSecondary={handleSendSecondary}
|
||
handleVerifySecondary={handleVerifySecondary}
|
||
secondaryForm={secondaryForm}
|
||
handleSecondaryChange={handleSecondaryChange}
|
||
secondarySent={secondarySent}
|
||
secondaryLoading={secondaryLoading}
|
||
secondaryError={secondaryError}
|
||
secondaryMessage={secondaryMessage}
|
||
secondaryExpiresAt={secondaryExpiresAt}
|
||
checkInReward={checkInReward}
|
||
checkInToday={checkInToday}
|
||
checkInLoading={checkInLoading}
|
||
checkInError={checkInError}
|
||
checkInMessage={checkInMessage}
|
||
handleCheckIn={handleCheckIn}
|
||
githubOAuthEnabled={githubOAuthEnabled}
|
||
giteaOAuthEnabled={giteaOAuthEnabled}
|
||
googleOAuthEnabled={googleOAuthEnabled}
|
||
microsoftOAuthEnabled={microsoftOAuthEnabled}
|
||
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
|
||
oauthLogos={oauthLogos}
|
||
onBindOAuth={bindOAuth}
|
||
onUnlinkOAuth={unlinkOAuth}
|
||
/>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|