Update SproutGate
This commit is contained in:
@@ -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 Key」和「Secret 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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="加载中">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 图片 URL(https)
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx
Normal file
79
sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
Reference in New Issue
Block a user