feat: 更新SproutGate前后端代码

This commit is contained in:
2026-04-01 22:04:01 +08:00
parent 90590c7cb0
commit 650e1c7707
49 changed files with 3609 additions and 768 deletions

View File

@@ -6,12 +6,15 @@ import {
clearAuthClientContext,
fetchClientVisitMeta,
formatAuthBanMessage,
persistAuthClientFromFlow
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 }) {
export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthStandalone = false }) {
const [account, setAccount] = useState("");
const [password, setPassword] = useState("");
const [user, setUser] = useState(null);
@@ -23,6 +26,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
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);
@@ -79,6 +85,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
setUser(null);
setProfileForm({
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
});
};
const bearerHeaders = (tokenValue) => ({
@@ -160,6 +169,12 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
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);
}
} catch {
/* 忽略,默认不要求邀请码 */
@@ -168,18 +183,18 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
return () => { cancelled = true; };
}, []);
// 仅在外部 user 更新且资料弹窗关闭时同步表单,避免 /api/auth/me 二次请求在编辑期间把表单项覆盖回服务器值QQ 用户会把已填写的自定义头像链接清空)
useEffect(() => {
if (user) {
setProfileForm({
username: user.username || "",
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
password: ""
});
}
}, [user]);
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();
@@ -216,7 +231,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
syncCurrentUser(data.token).catch(() => {});
setAccount("");
setPassword("");
if (isAuthFlow) {
if (isAuthFlow && !oauthStandalone) {
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
return;
}
@@ -228,10 +243,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
};
const handleLogout = () => {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
clearAuthClientContext();
setUser(null);
clearTokenAndUser();
setProfileEditorOpen(false);
setProfileEditorFocus("");
setSecondaryEditorOpen(false);
@@ -262,13 +275,23 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
};
const handleRegisterChange = (field, value) => {
if (field === "account") {
value = normalizeSelfServiceAccountInput(value, registrationAccountMax);
}
setRegisterForm((prev) => ({ ...prev, [field]: value }));
};
const resetRegisterFlow = () => {
setRegisterSent(false);
setRegisterExpiresAt("");
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
setRegisterForm({
account: randomSelfServiceAccount(10),
password: "",
username: "",
email: "",
code: "",
inviteCode: ""
});
};
const handleSendCode = async (event) => {
@@ -281,6 +304,11 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
setRegisterLoading(false);
return;
}
if (registerForm.account.length < registrationAccountMin) {
setRegisterError(`账户至少 ${registrationAccountMin} 位,仅小写字母与数字`);
setRegisterLoading(false);
return;
}
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
setRegisterError("请输入邀请码");
setRegisterLoading(false);
@@ -330,7 +358,12 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "验证失败");
setRegisterMessage("注册成功,请使用账号登录");
const coins = Number(data.user?.sproutCoins);
setRegisterMessage(
Number.isFinite(coins) && coins > 0
? `注册成功,已获得 ${coins} 萌芽币,请使用账号登录`
: "注册成功,请使用账号登录"
);
setRegisterSent(false);
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
setMode("login");
@@ -542,6 +575,16 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
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);
};
@@ -613,14 +656,26 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
}
};
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}
onPreviewImage={onPreviewImage}
handleContinueAuth={handleContinueAuth}
handleSwitchAuthAccount={handleSwitchAuthAccount}
mode={mode}
setMode={setMode}
account={account}
@@ -643,6 +698,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
setRegisterError={setRegisterError}
setRegisterMessage={setRegisterMessage}
registrationRequireInvite={registrationRequireInvite}
inviteRegisterReward={inviteRegisterReward}
registrationAccountMin={registrationAccountMin}
registrationAccountMax={registrationAccountMax}
resetRegisterFlow={resetRegisterFlow}
resetForm={resetForm}
handleResetChange={handleResetChange}
@@ -656,7 +714,16 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
setResetError={setResetError}
setResetMessage={setResetMessage}
/>
{user && (
{showOAuthConsent && (
<OAuthConsentScreen
authFlow={authFlow}
user={user}
onAllow={handleContinueAuth}
onSwitchAccount={handleSwitchAuthAccount}
onPreviewImage={onPreviewImage}
/>
)}
{showProfile && (
<UserPortalProfileSection
user={user}
onPreviewImage={onPreviewImage}