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

@@ -38,12 +38,58 @@ export default function AdminPanel({ onReady }) {
const [newInvMax, setNewInvMax] = useState(0);
const [newInvExp, setNewInvExp] = useState("");
const [oauthLoading, setOauthLoading] = useState(false);
const [oauthSaving, setOauthSaving] = useState(false);
const [oauthError, setOauthError] = useState("");
const [oauthMessage, setOauthMessage] = useState("");
const [oauthGithubEnabled, setOauthGithubEnabled] = useState(false);
const [oauthGiteaEnabled, setOauthGiteaEnabled] = useState(false);
const [oauthGoogleEnabled, setOauthGoogleEnabled] = useState(false);
const [oauthMicrosoftEnabled, setOauthMicrosoftEnabled] = useState(false);
const [oauthGithubClientId, setOauthGithubClientId] = useState("");
const [oauthGithubClientSecret, setOauthGithubClientSecret] = useState("");
const [oauthGiteaBase, setOauthGiteaBase] = useState("https://git.shumengya.top");
const [oauthGiteaClientId, setOauthGiteaClientId] = useState("");
const [oauthGiteaClientSecret, setOauthGiteaClientSecret] = useState("");
const [oauthGoogleClientId, setOauthGoogleClientId] = useState("");
const [oauthGoogleClientSecret, setOauthGoogleClientSecret] = useState("");
const [oauthMicrosoftClientId, setOauthMicrosoftClientId] = useState("");
const [oauthMicrosoftClientSecret, setOauthMicrosoftClientSecret] = useState("");
const [oauthMicrosoftTenant, setOauthMicrosoftTenant] = useState("common");
const [oauthLinuxdoEnabled, setOauthLinuxdoEnabled] = useState(false);
const [oauthLinuxdoConnectBase, setOauthLinuxdoConnectBase] = useState("https://connect.linux.do");
const [oauthLinuxdoClientId, setOauthLinuxdoClientId] = useState("");
const [oauthLinuxdoClientSecret, setOauthLinuxdoClientSecret] = useState("");
const [oauthReturnPrefixes, setOauthReturnPrefixes] = useState("http://localhost:5173/\nhttp://127.0.0.1:5173/");
const [oauthSignUpWithInvite, setOauthSignUpWithInvite] = useState(false);
const [oauthGithubSecretSet, setOauthGithubSecretSet] = useState(false);
const [oauthGiteaSecretSet, setOauthGiteaSecretSet] = useState(false);
const [oauthGoogleSecretSet, setOauthGoogleSecretSet] = useState(false);
const [oauthMicrosoftSecretSet, setOauthMicrosoftSecretSet] = useState(false);
const [oauthLinuxdoSecretSet, setOauthLinuxdoSecretSet] = useState(false);
const [oauthGithubLogoUrl, setOauthGithubLogoUrl] = useState("");
const [oauthGiteaLogoUrl, setOauthGiteaLogoUrl] = useState("");
const [oauthGoogleLogoUrl, setOauthGoogleLogoUrl] = useState("");
const [oauthMicrosoftLogoUrl, setOauthMicrosoftLogoUrl] = useState("");
const [oauthLinuxdoLogoUrl, setOauthLinuxdoLogoUrl] = useState("");
const [tsLoading, setTsLoading] = useState(false);
const [tsSaving, setTsSaving] = useState(false);
const [tsError, setTsError] = useState("");
const [tsMessage, setTsMessage] = useState("");
const [tsEnabled, setTsEnabled] = useState(false);
const [tsSiteKey, setTsSiteKey] = useState("");
const [tsSecretKey, setTsSecretKey] = useState("");
const [tsSecretKeySet, setTsSecretKeySet] = useState(false);
useEffect(() => {
if (token) {
localStorage.setItem("sproutgate_admin_token", token);
loadUsers();
loadCheckInConfig();
loadRegistration();
loadOAuth();
loadTurnstile();
} else if (onReady && !readySent) {
onReady();
setReadySent(true);
@@ -90,6 +136,165 @@ export default function AdminPanel({ onReady }) {
}
};
const loadOAuth = async () => {
if (!token) return;
setOauthLoading(true);
setOauthError("");
try {
const res = await fetch(`${API_BASE}/api/admin/oauth`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载 OAuth 配置失败");
setOauthGithubEnabled(Boolean(data.githubEnabled));
setOauthGiteaEnabled(Boolean(data.giteaEnabled));
setOauthGithubClientId((data.githubClientId || "").trim());
setOauthGithubClientSecret("");
setOauthGithubSecretSet(Boolean(data.githubClientSecretSet));
setOauthGiteaBase((data.giteaBaseUrl || "https://git.shumengya.top").trim());
setOauthGiteaClientId((data.giteaClientId || "").trim());
setOauthGiteaClientSecret("");
setOauthGiteaSecretSet(Boolean(data.giteaClientSecretSet));
setOauthGoogleEnabled(Boolean(data.googleEnabled));
setOauthMicrosoftEnabled(Boolean(data.microsoftEnabled));
setOauthGoogleClientId((data.googleClientId || "").trim());
setOauthGoogleClientSecret("");
setOauthGoogleSecretSet(Boolean(data.googleClientSecretSet));
setOauthMicrosoftClientId((data.microsoftClientId || "").trim());
setOauthMicrosoftClientSecret("");
setOauthMicrosoftSecretSet(Boolean(data.microsoftClientSecretSet));
setOauthMicrosoftTenant((data.microsoftTenant || "common").trim() || "common");
setOauthLinuxdoEnabled(Boolean(data.linuxdoEnabled));
setOauthLinuxdoConnectBase((data.linuxdoConnectBaseUrl || "https://connect.linux.do").trim());
setOauthLinuxdoClientId((data.linuxdoClientId || "").trim());
setOauthLinuxdoClientSecret("");
setOauthLinuxdoSecretSet(Boolean(data.linuxdoClientSecretSet));
setOauthGithubLogoUrl((data.githubLogoUrl || "").trim());
setOauthGiteaLogoUrl((data.giteaLogoUrl || "").trim());
setOauthGoogleLogoUrl((data.googleLogoUrl || "").trim());
setOauthMicrosoftLogoUrl((data.microsoftLogoUrl || "").trim());
setOauthLinuxdoLogoUrl((data.linuxdoLogoUrl || "").trim());
const pfx = data.allowedReturnPrefixes;
setOauthReturnPrefixes(
Array.isArray(pfx) && pfx.length ? pfx.join("\n") : "http://localhost:5173/\nhttp://127.0.0.1:5173/"
);
setOauthSignUpWithInvite(Boolean(data.allowOAuthSignUpWhenInviteRequired));
} catch (err) {
setOauthError(err.message);
} finally {
setOauthLoading(false);
}
};
const saveOAuth = async () => {
if (!token) return;
setOauthMessage("");
setOauthError("");
setOauthSaving(true);
const prefixes = oauthReturnPrefixes
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
try {
const res = await fetch(`${API_BASE}/api/admin/oauth`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({
githubEnabled: oauthGithubEnabled,
giteaEnabled: oauthGiteaEnabled,
googleEnabled: oauthGoogleEnabled,
microsoftEnabled: oauthMicrosoftEnabled,
linuxdoEnabled: oauthLinuxdoEnabled,
githubClientId: oauthGithubClientId.trim(),
githubClientSecret: oauthGithubClientSecret,
giteaBaseUrl: oauthGiteaBase.trim() || "https://git.shumengya.top",
giteaClientId: oauthGiteaClientId.trim(),
giteaClientSecret: oauthGiteaClientSecret,
googleClientId: oauthGoogleClientId.trim(),
googleClientSecret: oauthGoogleClientSecret,
microsoftClientId: oauthMicrosoftClientId.trim(),
microsoftClientSecret: oauthMicrosoftClientSecret,
microsoftTenant: oauthMicrosoftTenant.trim() || "common",
linuxdoConnectBaseUrl: oauthLinuxdoConnectBase.trim() || "https://connect.linux.do",
linuxdoClientId: oauthLinuxdoClientId.trim(),
linuxdoClientSecret: oauthLinuxdoClientSecret,
githubLogoUrl: oauthGithubLogoUrl.trim(),
giteaLogoUrl: oauthGiteaLogoUrl.trim(),
googleLogoUrl: oauthGoogleLogoUrl.trim(),
microsoftLogoUrl: oauthMicrosoftLogoUrl.trim(),
linuxdoLogoUrl: oauthLinuxdoLogoUrl.trim(),
allowedReturnPrefixes: prefixes,
allowOAuthSignUpWhenInviteRequired: oauthSignUpWithInvite
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存失败");
setOauthMessage("第三方登录配置已保存");
setOauthGithubClientSecret("");
setOauthGiteaClientSecret("");
setOauthGoogleClientSecret("");
setOauthMicrosoftClientSecret("");
setOauthLinuxdoClientSecret("");
setOauthGithubSecretSet(Boolean(data.githubClientSecretSet));
setOauthGiteaSecretSet(Boolean(data.giteaClientSecretSet));
setOauthGoogleSecretSet(Boolean(data.googleClientSecretSet));
setOauthMicrosoftSecretSet(Boolean(data.microsoftClientSecretSet));
setOauthLinuxdoSecretSet(Boolean(data.linuxdoClientSecretSet));
} catch (err) {
setOauthError(err.message);
} finally {
setOauthSaving(false);
}
};
const loadTurnstile = async () => {
if (!token) return;
setTsLoading(true);
setTsError("");
try {
const res = await fetch(`${API_BASE}/api/admin/turnstile`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载 Turnstile 配置失败");
setTsEnabled(Boolean(data.enabled));
setTsSiteKey((data.siteKey || "").trim());
setTsSecretKey("");
setTsSecretKeySet(Boolean(data.secretKeySet));
} catch (err) {
setTsError(err.message);
} finally {
setTsLoading(false);
}
};
const saveTurnstile = async () => {
if (!token) return;
setTsMessage("");
setTsError("");
setTsSaving(true);
try {
const res = await fetch(`${API_BASE}/api/admin/turnstile`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({
enabled: tsEnabled,
siteKey: tsSiteKey.trim(),
secretKey: tsSecretKey
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存失败");
setTsMessage("Turnstile 配置已保存");
setTsSecretKey("");
setTsSecretKeySet(Boolean(data.secretKeySet));
} catch (err) {
setTsError(err.message);
} finally {
setTsSaving(false);
}
};
const loadRegistration = async () => {
if (!token) return;
setRegLoading(true);
@@ -366,22 +571,71 @@ export default function AdminPanel({ onReady }) {
const editingUser = users.find((u) => u.account === selectedAccount);
const editingAuthClients = editingUser?.authClients || [];
return (
<section className="panel">
<div className="admin-console form">
<div className="panel-title">管理员控制台</div>
const validAdminNav = useMemo(
() => new Set(["general", "users", "checkin", "oauth", "turnstile", "registration"]),
[]
);
const [adminNav, setAdminNav] = useState(() => {
if (typeof window === "undefined") return "general";
const h = (window.location.hash || "").replace(/^#/, "");
return validAdminNav.has(h) ? h : "general";
});
useEffect(() => {
const onHash = () => {
const h = (window.location.hash || "").replace(/^#/, "");
if (validAdminNav.has(h)) setAdminNav(h);
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, [validAdminNav]);
const goAdminNav = (id) => {
if (!validAdminNav.has(id)) return;
setAdminNav(id);
if ((window.location.hash || "").replace(/^#/, "") !== id) {
window.location.hash = id;
}
};
return (
<section className="panel admin-shell">
<div className="admin-layout">
<aside className="admin-sidebar" aria-label="管理模块">
<div className="admin-sidebar-title">管理后台</div>
<nav className="admin-sidebar-nav">
{[
{ id: "general", label: "概览与身份" },
{ id: "users", label: "用户管理" },
{ id: "checkin", label: "签到设置" },
{ id: "oauth", label: "第三方登录" },
{ id: "turnstile", label: "人机验证" },
{ id: "registration", label: "注册与邀请" }
].map((item) => (
<button
key={item.id}
type="button"
className={adminNav === item.id ? "admin-sidebar-link is-active" : "admin-sidebar-link"}
onClick={() => goAdminNav(item.id)}
>
{item.label}
</button>
))}
</nav>
</aside>
<div className="admin-main form admin-console">
<h1 className="admin-main-title">管理员控制台</h1>
{adminNav === "general" && (
<div className="admin-section">
<h2 className="admin-section-heading">管理员 Token</h2>
<label>
<IconLabel icon={icons.token} text="Token" />
<input value={token} onChange={(e) => setToken(e.target.value.trim())} placeholder="请输入管理员 Token" />
</label>
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
{loading ? "加载中..." : "刷新用户列表"}
</button>
<p className="hint">在左侧切换模块填写 Token 后可使用用户管理等需鉴权的操作</p>
</div>
)}
{adminNav === "checkin" && (
<div className="admin-section">
<h2 className="admin-section-heading">签到设置</h2>
<label>
@@ -403,7 +657,310 @@ export default function AdminPanel({ onReady }) {
</button>
</div>
</div>
)}
{adminNav === "oauth" && (
<div className="admin-section">
<h2 className="admin-section-heading">第三方登录GitHub / Gitea / Google / Microsoft / LINUX DO</h2>
<p className="hint">
在对应云控制台注册应用回调地址在 API 公网根后加
<code className="inline-code">/api/auth/oauth/github/callback</code>
<code className="inline-code">/api/auth/oauth/gitea/callback</code>
<code className="inline-code">/api/auth/oauth/google/callback</code>
<code className="inline-code">/api/auth/oauth/microsoft/callback</code>
<code className="inline-code">/api/auth/oauth/linuxdo/callback</code>
可设置
<code className="inline-code">PUBLIC_API_BASE</code> <code className="inline-code">GOOGLE_CLIENT_SECRET</code><code className="inline-code">MICROSOFT_CLIENT_SECRET</code> 等环境变量{" "}
<code className="inline-code">docs/oauth-github-gitea-testing.md</code>下方需配置允许的回跳地址
</p>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 GitHub 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthGithubEnabled}
onChange={(e) => setOauthGithubEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="GitHub Client ID" />
<input value={oauthGithubClientId} onChange={(e) => setOauthGithubClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="GitHub Client Secret" hint={oauthGithubSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthGithubClientSecret}
onChange={(e) => setOauthGithubClientSecret(e.target.value)}
placeholder={oauthGithubSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="GitHub 登录按钮 Logo" hint="选填https 图片地址,用户中心胶囊按钮左侧)" />
<input
value={oauthGithubLogoUrl}
onChange={(e) => setOauthGithubLogoUrl(e.target.value)}
placeholder="https://example.com/github-mark.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 Gitea 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthGiteaEnabled}
onChange={(e) => setOauthGiteaEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.link} text="Gitea 根地址" />
<input value={oauthGiteaBase} onChange={(e) => setOauthGiteaBase(e.target.value)} placeholder="https://git.shumengya.top" disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.username} text="Gitea Client ID" />
<input value={oauthGiteaClientId} onChange={(e) => setOauthGiteaClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="Gitea Client Secret" hint={oauthGiteaSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthGiteaClientSecret}
onChange={(e) => setOauthGiteaClientSecret(e.target.value)}
placeholder={oauthGiteaSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="Gitea 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthGiteaLogoUrl}
onChange={(e) => setOauthGiteaLogoUrl(e.target.value)}
placeholder="https://example.com/gitea.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 Google 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthGoogleEnabled}
onChange={(e) => setOauthGoogleEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="Google Client ID" />
<input value={oauthGoogleClientId} onChange={(e) => setOauthGoogleClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="Google Client Secret" hint={oauthGoogleSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthGoogleClientSecret}
onChange={(e) => setOauthGoogleClientSecret(e.target.value)}
placeholder={oauthGoogleSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="Google 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthGoogleLogoUrl}
onChange={(e) => setOauthGoogleLogoUrl(e.target.value)}
placeholder="https://example.com/google-g.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 Microsoft 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthMicrosoftEnabled}
onChange={(e) => setOauthMicrosoftEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="Microsoft Client ID" />
<input value={oauthMicrosoftClientId} onChange={(e) => setOauthMicrosoftClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel
icon={icons.link}
text="Microsoft 租户"
hint="Entra 租户 ID 或 common多租户+个人账户常用 common"
/>
<input
value={oauthMicrosoftTenant}
onChange={(e) => setOauthMicrosoftTenant(e.target.value)}
placeholder="common"
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.token} text="Microsoft Client Secret" hint={oauthMicrosoftSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthMicrosoftClientSecret}
onChange={(e) => setOauthMicrosoftClientSecret(e.target.value)}
placeholder={oauthMicrosoftSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="Microsoft 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthMicrosoftLogoUrl}
onChange={(e) => setOauthMicrosoftLogoUrl(e.target.value)}
placeholder="https://example.com/ms.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 LINUX DO 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthLinuxdoEnabled}
onChange={(e) => setOauthLinuxdoEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.link} text="LINUX DO Connect 根地址" />
<input
value={oauthLinuxdoConnectBase}
onChange={(e) => setOauthLinuxdoConnectBase(e.target.value)}
placeholder="https://connect.linux.do"
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.username} text="LINUX DO Client ID" />
<input value={oauthLinuxdoClientId} onChange={(e) => setOauthLinuxdoClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="LINUX DO Client Secret" hint={oauthLinuxdoSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthLinuxdoClientSecret}
onChange={(e) => setOauthLinuxdoClientSecret(e.target.value)}
placeholder={oauthLinuxdoSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="LINUX DO 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthLinuxdoLogoUrl}
onChange={(e) => setOauthLinuxdoLogoUrl(e.target.value)}
placeholder="https://example.com/linuxdo.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="full-span">
<IconLabel icon={icons.link} text="允许的回跳地址前缀" hint="(每行一个,需含协议与尾斜杠,如 https://auth.shumengya.top/" />
<textarea
value={oauthReturnPrefixes}
onChange={(e) => setOauthReturnPrefixes(e.target.value)}
rows={4}
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.token} text="需邀请时仍允许 OAuth 新用户" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthSignUpWithInvite}
onChange={(e) => setOauthSignUpWithInvite(e.target.checked)}
disabled={!token || oauthLoading}
/>
<span>开启后即使注册策略要求邀请码用户仍可用第三方登录直接注册新账户</span>
</span>
</label>
{oauthError && <div className="error">{oauthError}</div>}
{oauthMessage && <div className="success">{oauthMessage}</div>}
<div className="actions compact">
<button type="button" className="primary" onClick={saveOAuth} disabled={!token || oauthSaving || oauthLoading}>
{oauthSaving ? "保存中…" : "保存第三方登录"}
</button>
<button type="button" className="ghost" onClick={loadOAuth} disabled={!token || oauthLoading}>
{oauthLoading ? "加载中…" : "重新加载"}
</button>
</div>
</div>
)}
{adminNav === "turnstile" && (
<div className="admin-section">
<h2 className="admin-section-heading">Cloudflare Turnstile 人机验证</h2>
<p className="hint">
<a href="https://dash.cloudflare.com/" target="_blank" rel="noopener noreferrer">Cloudflare Dashboard</a> Turnstile 创建站点Site KeySecret Key填入下方
启用后登录与注册接口在提交前均需完成人机验证
</p>
<label className="admin-ban-row">
<IconLabel icon={icons.token} text="启用 Turnstile 人机验证" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={tsEnabled}
onChange={(e) => setTsEnabled(e.target.checked)}
disabled={!token || tsLoading}
/>
<span>开启后登录与注册均需通过 Cloudflare Turnstile 验证</span>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="Site Key前端展示用" />
<input
value={tsSiteKey}
onChange={(e) => setTsSiteKey(e.target.value)}
placeholder="0x4AAAAAAAxxx..."
disabled={!token || tsLoading}
/>
</label>
<label>
<IconLabel icon={icons.token} text="Secret Key后端验证用" hint={tsSecretKeySet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={tsSecretKey}
onChange={(e) => setTsSecretKey(e.target.value)}
placeholder={tsSecretKeySet ? "留空保留" : "0x4AAAAAAAxxx..."}
disabled={!token || tsLoading}
/>
</label>
{tsError && <div className="error">{tsError}</div>}
{tsMessage && <div className="success">{tsMessage}</div>}
<div className="actions compact">
<button type="button" className="primary" onClick={saveTurnstile} disabled={!token || tsSaving || tsLoading}>
{tsSaving ? "保存中…" : "保存 Turnstile 配置"}
</button>
<button type="button" className="ghost" onClick={loadTurnstile} disabled={!token || tsLoading}>
{tsLoading ? "加载中…" : "重新加载"}
</button>
</div>
</div>
)}
{adminNav === "registration" && (
<div className="admin-section">
<h2 className="admin-section-heading">注册与邀请码</h2>
<label className="admin-ban-row">
@@ -504,33 +1061,38 @@ export default function AdminPanel({ onReady }) {
</div>
)}
</div>
)}
{adminNav === "users" && (
<div className="admin-section">
<div className="list-header">
<h2 className="admin-section-heading admin-section-heading-inline">用户列表</h2>
<div className="actions compact">
<button className="primary" onClick={openCreateUser}>添加用户</button>
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
{loading ? "加载中…" : "刷新列表"}
</button>
</div>
</div>
{message && <div className="success">{message}</div>}
{users.length === 0 && <div className="hint">暂无用户</div>}
<div className="table admin-table">
<div className="table-row header">
<TableCell icon={icons.account}>账户</TableCell>
<TableCell icon={icons.username}>用户名</TableCell>
<TableCell icon={icons.email}>邮箱</TableCell>
<TableCell icon={icons.level}>等级</TableCell>
<TableCell icon={icons.coins}>萌芽币</TableCell>
<TableCell icon={icons.ban}>状态</TableCell>
<TableCell icon={icons.visitIp}>最近 IP</TableCell>
<TableCell icon={icons.visitGeo}>最近位置</TableCell>
<TableCell icon={icons.account} dataLabel="账户">账户</TableCell>
<TableCell icon={icons.username} dataLabel="用户名">用户名</TableCell>
<TableCell icon={icons.email} dataLabel="邮箱">邮箱</TableCell>
<TableCell icon={icons.level} dataLabel="等级">等级</TableCell>
<TableCell icon={icons.coins} dataLabel="萌芽币">萌芽币</TableCell>
<TableCell icon={icons.ban} dataLabel="状态">状态</TableCell>
<TableCell icon={icons.visitIp} dataLabel="IP">最近 IP</TableCell>
<TableCell icon={icons.visitGeo} dataLabel="位置">最近位置</TableCell>
<span>操作</span>
</div>
{users.map((u) => (
<div className="table-row" key={u.account}>
<TableCell icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
<TableCell icon={icons.username}>{u.username || "-"}</TableCell>
<TableCell icon={icons.email}>
<TableCell dataLabel="账户" icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
<TableCell dataLabel="用户名" icon={icons.username}>{u.username || "-"}</TableCell>
<TableCell dataLabel="邮箱" icon={icons.email}>
<span className="admin-email-cell">
{u.email ? (
<MailtoEmail address={u.email} className="profile-external-link">{u.email}</MailtoEmail>
@@ -550,18 +1112,18 @@ export default function AdminPanel({ onReady }) {
)}
</span>
</TableCell>
<TableCell icon={icons.level}>{u.level ?? 0} </TableCell>
<TableCell icon={icons.coins}>{u.sproutCoins}</TableCell>
<TableCell icon={icons.ban}>
<TableCell dataLabel="等级" icon={icons.level}>{u.level ?? 0} </TableCell>
<TableCell dataLabel="萌芽币" icon={icons.coins}>{u.sproutCoins}</TableCell>
<TableCell dataLabel="状态" icon={icons.ban}>
{u.banned ? (
<span className="admin-user-banned" title={u.banReason || "已封禁"}>封禁</span>
) : (
"正常"
)}
</TableCell>
<TableCell icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
<TableCell icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
<span className="row-actions">
<TableCell dataLabel="最近 IP" icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
<TableCell dataLabel="位置" icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
<span className="row-actions" data-label="操作">
<button className="ghost" onClick={() => selectUser(u)}>编辑</button>
<button className="danger" onClick={() => handleDelete(u.account)}>删除</button>
</span>
@@ -569,6 +1131,8 @@ export default function AdminPanel({ onReady }) {
))}
</div>
</div>
)}
</div>
</div>
{editorOpen && (

View File

@@ -63,7 +63,7 @@ export default function PublicUserListPage({ onReady, onPreviewImage }) {
<a className="public-user-list-row" href={`/user/${encodeURIComponent(u.account)}`}>
<AvatarImg
user={profileUser}
placeholderSize={56}
placeholderSize={44}
alt={name}
className="public-user-list-avatar previewable-image"
previewable

View File

@@ -1,11 +1,26 @@
import React from "react";
import { LOGO_192_SRC } from "../config";
export default function SplashScreen() {
/**
* 与顶栏小 Logo 相同:在图标区域连点 5 次会触发 App 中管理员入口onLogoTap
*/
export default function SplashScreen({ onLogoTap }) {
return (
<div className="splash">
<div className="splash-glow" aria-hidden="true" />
<div className="splash-content">
{typeof onLogoTap === "function" ? (
<button type="button" className="splash-logo-hit" onClick={onLogoTap} aria-label="SproutGate 标志,连点五次可打开管理员验证">
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden="true">
<span />
<span />
<span />
</div>
<img className="splash-logo" src={LOGO_192_SRC} alt="" width={120} height={120} decoding="async" />
</div>
</button>
) : (
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden="true">
<span />
@@ -14,6 +29,7 @@ export default function SplashScreen() {
</div>
<img className="splash-logo" src={LOGO_192_SRC} alt="SproutGate" width={120} height={120} decoding="async" />
</div>
)}
<div className="splash-title">萌芽账户认证中心</div>
<div className="splash-subtitle">加载中</div>
<div className="splash-dots" aria-label="加载中">

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>

View File

@@ -20,11 +20,15 @@ export function InfoLabel({ icon, text }) {
);
}
export function TableCell({ icon, children, onClick }) {
export function TableCell({ icon, children, onClick, dataLabel }) {
return (
<span className="table-cell" onClick={onClick}>
<span
className="table-cell"
onClick={onClick}
data-label={dataLabel || undefined}
>
<span className="icon">{icon}</span>
<span>{children}</span>
<span className="table-cell-value">{children}</span>
</span>
);
}

View File

@@ -0,0 +1,77 @@
import React from "react";
const PROVIDERS = [
{ id: "github", label: "使用 GitHub 继续" },
{ id: "gitea", label: "使用 Gitea 继续" },
{ id: "google", label: "使用 Google 继续" },
{ id: "microsoft", label: "使用 Microsoft 继续" },
{ id: "linuxdo", label: "使用 Linux DO 继续" }
];
/**
* @param {Record<string, string>} [oauthLogos] 各 provider id → 管理后台填写的 Logo 图片 URLhttps
*/
export default function OAuthPillButtons({
githubOAuthEnabled = false,
giteaOAuthEnabled = false,
googleOAuthEnabled = false,
microsoftOAuthEnabled = false,
linuxdoOAuthEnabled = false,
oauthLogos = {},
onOAuthLogin,
disabled = false,
variant = "default"
}) {
if (!onOAuthLogin) return null;
const enabled = (p) => {
switch (p) {
case "github": return githubOAuthEnabled;
case "gitea": return giteaOAuthEnabled;
case "google": return googleOAuthEnabled;
case "microsoft": return microsoftOAuthEnabled;
case "linuxdo": return linuxdoOAuthEnabled;
default: return false;
}
};
const list = PROVIDERS.filter((p) => enabled(p.id));
if (list.length === 0) return null;
return (
<div className={`auth-oauth-stack${variant === "underLead" ? " auth-oauth-stack--under-lead" : ""}`}>
<span className="auth-oauth-label">第三方登录</span>
{list.map((p) => {
const logo = (typeof oauthLogos?.[p.id] === "string" ? oauthLogos[p.id] : "").trim();
return (
<button
key={p.id}
type="button"
className={`auth-oauth-pill${logo ? "" : " auth-oauth-pill--no-logo"}`}
onClick={() => onOAuthLogin(p.id)}
disabled={disabled}
>
<span className="auth-oauth-pill-inner">
{logo ? (
<span className="auth-oauth-pill-icon" aria-hidden>
<img
className="auth-oauth-pill-logo"
src={logo}
alt=""
loading="lazy"
decoding="async"
onError={(e) => {
const btn = e.currentTarget.closest("button");
e.currentTarget.closest(".auth-oauth-pill-icon")?.remove();
if (btn) btn.classList.add("auth-oauth-pill--no-logo");
}}
/>
</span>
) : null}
<span className="auth-oauth-pill-text">{p.label}</span>
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useImperativeHandle, useRef, forwardRef } from "react";
const SCRIPT_ID = "cf-turnstile-script";
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
function ensureScript(onReady) {
if (typeof window === "undefined") return;
const existing = document.getElementById(SCRIPT_ID);
if (existing) {
if (window.turnstile) {
onReady();
} else {
existing.addEventListener("load", onReady, { once: true });
}
return;
}
const s = document.createElement("script");
s.id = SCRIPT_ID;
s.src = SCRIPT_SRC;
s.defer = true;
s.addEventListener("load", onReady, { once: true });
document.head.appendChild(s);
}
/**
* Cloudflare Turnstile 人机验证组件。
* 通过 ref.reset() 可在提交后重置 widget 以获取新 token。
*
* @param {string} siteKey - Cloudflare Turnstile Site Key
* @param {Function} onToken - token 回调token 失效/错误时传入空串)
* @param {"light"|"dark"|"auto"} [theme]
*/
const TurnstileWidget = forwardRef(function TurnstileWidget(
{ siteKey, onToken, theme = "light" },
ref
) {
const containerRef = useRef(null);
const widgetIdRef = useRef(null);
const onTokenRef = useRef(onToken);
useEffect(() => { onTokenRef.current = onToken; }, [onToken]);
const removeWidget = useCallback(() => {
if (widgetIdRef.current != null && window.turnstile) {
try { window.turnstile.remove(widgetIdRef.current); } catch {}
widgetIdRef.current = null;
}
if (containerRef.current) containerRef.current.innerHTML = "";
}, []);
const renderWidget = useCallback(() => {
if (!containerRef.current || !window.turnstile) return;
removeWidget();
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
callback: (token) => onTokenRef.current?.(token),
"expired-callback": () => onTokenRef.current?.(""),
"error-callback": () => onTokenRef.current?.(""),
});
}, [siteKey, theme, removeWidget]);
useImperativeHandle(ref, () => ({
reset() {
if (widgetIdRef.current != null && window.turnstile) {
try { window.turnstile.reset(widgetIdRef.current); } catch {}
}
},
}));
useEffect(() => {
ensureScript(renderWidget);
return removeWidget;
}, [renderWidget, removeWidget]);
return <div ref={containerRef} className="turnstile-widget" />;
});
export default TurnstileWidget;

View File

@@ -1,6 +1,8 @@
import React from "react";
import icons from "../../icons";
import { IconLabel } from "../common";
import OAuthPillButtons from "../oauth/OAuthPillButtons";
import TurnstileWidget from "../oauth/TurnstileWidget";
export default function UserPortalAuthSection({
isAuthFlow,
@@ -42,7 +44,20 @@ export default function UserPortalAuthSection({
handleSendReset,
handleResetPassword,
setResetError,
setResetMessage
setResetMessage,
githubOAuthEnabled = false,
giteaOAuthEnabled = false,
googleOAuthEnabled = false,
microsoftOAuthEnabled = false,
linuxdoOAuthEnabled = false,
oauthLogos = {},
onOAuthLogin,
turnstileEnabled = false,
turnstileSiteKey = "",
onLoginTurnstileToken,
loginTurnstileRef,
onRegisterTurnstileToken,
registerTurnstileRef
}) {
return (
<>
@@ -82,11 +97,30 @@ export default function UserPortalAuthSection({
</label>
{error && <div className="error auth-form-alert" role="alert">{error}</div>}
</div>
{turnstileEnabled && turnstileSiteKey && (
<div className="auth-turnstile-wrap">
<TurnstileWidget
ref={loginTurnstileRef}
siteKey={turnstileSiteKey}
onToken={onLoginTurnstileToken}
/>
</div>
)}
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
</div>
<OAuthPillButtons
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onOAuthLogin={onOAuthLogin}
disabled={loading}
/>
<div className="auth-form-footer">
<button
type="button"
@@ -109,6 +143,28 @@ export default function UserPortalAuthSection({
<h2>注册账号</h2>
<p className="auth-form-lead">创建萌芽统一账户验证码将发送至你的邮箱</p>
</div>
{!registerSent && turnstileEnabled && turnstileSiteKey && (
<div className="auth-turnstile-wrap auth-turnstile-wrap--register-oauth">
<TurnstileWidget
ref={registerTurnstileRef}
siteKey={turnstileSiteKey}
onToken={onRegisterTurnstileToken}
/>
</div>
)}
{!registerSent && (
<OAuthPillButtons
variant="underLead"
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onOAuthLogin={onOAuthLogin}
disabled={registerLoading}
/>
)}
<div className="auth-form-body">
<label className="auth-field">
<IconLabel

View File

@@ -1,10 +1,30 @@
import React from "react";
import React, { useState } from "react";
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
import { avatarStatusLabel } from "../../avatar";
import icons from "../../icons";
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
import AvatarImg from "../AvatarImg";
/** 与登录页 OAuth 按钮相同:管理后台配置的 logo URL失败时回退为链环图标 */
function ProfileOAuthProviderIcon({ providerId, oauthLogos = {} }) {
const raw = oauthLogos?.[providerId];
const url = typeof raw === "string" ? raw.trim() : "";
const [failed, setFailed] = useState(false);
if (!url || failed) {
return icons.link;
}
return (
<img
className="profile-oauth-logo"
src={url}
alt=""
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
export default function UserPortalProfileSection({
user,
onPreviewImage,
@@ -36,7 +56,15 @@ export default function UserPortalProfileSection({
checkInLoading,
checkInError,
checkInMessage,
handleCheckIn
handleCheckIn,
githubOAuthEnabled = false,
giteaOAuthEnabled = false,
googleOAuthEnabled = false,
microsoftOAuthEnabled = false,
linuxdoOAuthEnabled = false,
oauthLogos = {},
onBindOAuth,
onUnlinkOAuth
}) {
return (
<div className="card profile">
@@ -116,6 +144,59 @@ export default function UserPortalProfileSection({
<InfoRow icon={icons.bio} label="简介" value={user.bio ? "已填写" : "暂无简介"} actionLabel="修改" onAction={() => openProfileEditor("bio")} />
</div>
{(githubOAuthEnabled || giteaOAuthEnabled || googleOAuthEnabled || microsoftOAuthEnabled || linuxdoOAuthEnabled) && onBindOAuth && onUnlinkOAuth && (
<>
<div className="profile-section-title">第三方账号</div>
<div className="profile-info-rows">
{githubOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="github" oauthLogos={oauthLogos} />}
label="GitHub"
value={user.githubLinked ? "已绑定" : "未绑定"}
actionLabel={user.githubLinked ? "解绑" : "绑定"}
onAction={() => (user.githubLinked ? onUnlinkOAuth("github") : onBindOAuth("github"))}
/>
)}
{giteaOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="gitea" oauthLogos={oauthLogos} />}
label="Gitea"
value={user.giteaLinked ? "已绑定" : "未绑定"}
actionLabel={user.giteaLinked ? "解绑" : "绑定"}
onAction={() => (user.giteaLinked ? onUnlinkOAuth("gitea") : onBindOAuth("gitea"))}
/>
)}
{googleOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="google" oauthLogos={oauthLogos} />}
label="Google"
value={user.googleLinked ? "已绑定" : "未绑定"}
actionLabel={user.googleLinked ? "解绑" : "绑定"}
onAction={() => (user.googleLinked ? onUnlinkOAuth("google") : onBindOAuth("google"))}
/>
)}
{microsoftOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="microsoft" oauthLogos={oauthLogos} />}
label="Microsoft"
value={user.microsoftLinked ? "已绑定" : "未绑定"}
actionLabel={user.microsoftLinked ? "解绑" : "绑定"}
onAction={() => (user.microsoftLinked ? onUnlinkOAuth("microsoft") : onBindOAuth("microsoft"))}
/>
)}
{linuxdoOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="linuxdo" oauthLogos={oauthLogos} />}
label="LINUX DO"
value={user.linuxdoLinked ? "已绑定" : "未绑定"}
actionLabel={user.linuxdoLinked ? "解绑" : "绑定"}
onAction={() => (user.linuxdoLinked ? onUnlinkOAuth("linuxdo") : onBindOAuth("linuxdo"))}
/>
)}
</div>
</>
)}
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />