Update SproutGate

This commit is contained in:
2026-05-13 12:19:36 +08:00
parent f7db8aa053
commit a37b92e144
51 changed files with 12034 additions and 254 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
API_BASE,
authClientFetchHeaders,
@@ -6,6 +6,7 @@ import {
clearAuthClientContext,
fetchClientVisitMeta,
formatAuthBanMessage,
getOAuthReturnTo,
normalizeSelfServiceAccountInput,
persistAuthClientFromFlow,
randomSelfServiceAccount
@@ -14,7 +15,13 @@ import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
import OAuthConsentScreen from "./OAuthConsentScreen";
export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthStandalone = false }) {
export default function UserPortal({
onReady,
authFlow,
onPreviewImage,
oauthStandalone = false,
onGuestAuthLayoutChange
}) {
const [account, setAccount] = useState("");
const [password, setPassword] = useState("");
const [user, setUser] = useState(null);
@@ -67,6 +74,22 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
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 = "") => {
@@ -148,6 +171,39 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
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());
@@ -161,6 +217,14 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
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 () => {
@@ -175,6 +239,20 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
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 {
/* 忽略,默认不要求邀请码 */
@@ -202,6 +280,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
setError("");
try {
const loginPayload = { account, password };
if (turnstileEnabled) loginPayload.turnstileToken = loginTurnstileToken;
const cid = (authFlow?.clientId || "").trim();
const cname = (authFlow?.clientName || "").trim();
if (cid) {
@@ -237,6 +316,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
}
} catch (err) {
setError(err.message);
setLoginTurnstileToken("");
loginTurnstileRef.current?.reset();
} finally {
setLoading(false);
}
@@ -321,6 +402,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
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`, {
@@ -335,6 +417,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
setRegisterMessage("验证码已发送,请检查邮箱");
} catch (err) {
setRegisterError(err.message);
setRegisterTurnstileToken("");
registerTurnstileRef.current?.reset();
} finally {
setRegisterLoading(false);
}
@@ -656,6 +740,71 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
}
};
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">
@@ -713,6 +862,21 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
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
@@ -756,6 +920,14 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
checkInError={checkInError}
checkInMessage={checkInMessage}
handleCheckIn={handleCheckIn}
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onBindOAuth={bindOAuth}
onUnlinkOAuth={unlinkOAuth}
/>
)}
</section>