Files
SproutGate/sproutgate-frontend/src/components/UserPortal.jsx
2026-05-13 12:19:36 +08:00

936 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}