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

@@ -35,6 +35,19 @@ function App() {
const authFlow = useMemo(() => getAuthFlowFromSearch(search), [search]);
const publicAccount = useMemo(() => getPublicAccountFromPathname(pathname), [pathname]);
const isHomeUserCenter = !isAdmin && !isPublicUserList && !isPublicUser && !isAuthorizeRoute;
const usesUserPortal = isAuthorizeRoute || isHomeUserCenter;
const [guestAuthLayout, setGuestAuthLayout] = useState(false);
const shellAuthGuest = guestAuthLayout && usesUserPortal;
/** 与未登录用户中心同套全屏 + 底图 + 主区磨砂 */
const shellPublicImmersive = isPublicUserList || isPublicUser;
const shellImmersive = Boolean(shellAuthGuest || shellPublicImmersive);
useLayoutEffect(() => {
if (!usesUserPortal) setGuestAuthLayout(false);
}, [usesUserPortal]);
const authDenyHref = useMemo(() => {
const r = (authFlow?.redirectUri || "").trim();
if (!r) return "";
@@ -174,15 +187,15 @@ function App() {
<div className="app-header-brand">
<button
type="button"
className="app-header-logo-btn"
className="app-header-brand-btn"
onClick={handleLogoTap}
aria-label="SproutGate 标志"
aria-label="站点标志(在图标或标题上连续点击五次可打开管理员验证)"
>
<img className="app-header-logo" src={LOGO_192_SRC} alt="" width={40} height={40} decoding="async" />
<div className="app-header-brand-text">
<h1>萌芽账户认证中心</h1>
</div>
</button>
<div>
<h1>萌芽账户认证中心</h1>
</div>
</div>
);
@@ -212,12 +225,12 @@ function App() {
);
return (
<div className="app-shell">
<div className={`app-shell${shellImmersive ? " app-shell--auth-guest" : ""}`}>
<RandomSiteBackground />
{booting && <SplashScreen />}
<div className="app">
{booting && <SplashScreen onLogoTap={handleLogoTap} />}
<div className={`app${shellImmersive ? " app--auth-guest" : ""}`}>
{isPublicUserList ? (
<div className="unified-user-shell">
<div className="unified-user-shell unified-user-shell--auth-guest">
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
@@ -230,7 +243,7 @@ function App() {
</div>
</div>
) : isPublicUser ? (
<div className="unified-user-shell">
<div className="unified-user-shell unified-user-shell--auth-guest">
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
@@ -256,7 +269,11 @@ function App() {
</div>
</div>
) : isAuthorizeRoute ? (
<div className="unified-user-shell oauth-authorize-shell">
<div
className={`unified-user-shell oauth-authorize-shell${
shellAuthGuest ? " unified-user-shell--auth-guest" : ""
}`}
>
<div className="unified-user-card oauth-authorize-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header oauth-authorize-header">
@@ -268,13 +285,16 @@ function App() {
onReady={markReady}
authFlow={authFlow}
onPreviewImage={openImagePreview}
onGuestAuthLayoutChange={setGuestAuthLayout}
oauthStandalone
/>
</div>
</div>
</div>
) : (
<div className="unified-user-shell">
<div
className={`unified-user-shell${shellAuthGuest ? " unified-user-shell--auth-guest" : ""}`}
>
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
@@ -282,7 +302,12 @@ function App() {
{headerNav}
</header>
<div className="unified-user-main">
<UserPortal onReady={markReady} authFlow={authFlow} onPreviewImage={openImagePreview} />
<UserPortal
onReady={markReady}
authFlow={authFlow}
onPreviewImage={openImagePreview}
onGuestAuthLayoutChange={setGuestAuthLayout}
/>
</div>
</div>
</div>
@@ -296,7 +321,10 @@ function App() {
<div className="modal-header">
<div>
<h2>管理员入口</h2>
<p>请输入与后端配置一致的管理员 Token</p>
<p>请输入与后端 <code className="inline-code">X-Admin-Token</code> 一致的管理员 Token</p>
<p className="hint admin-gate-token-hint">
未修改过本机/内网 data 默认示例常为 <code className="inline-code">shumengya520</code>以你部署的后端 <code className="inline-code">config</code> 为准
</p>
</div>
<button type="button" className="ghost modal-close" onClick={closeAdminGate}>关闭</button>
</div>

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}`} />

View File

@@ -9,6 +9,12 @@ export const API_BASE = (() => {
return (configuredBase || fallbackBase).replace(/\/+$/, "");
})();
/** OAuth 成功回跳:当前页(不含 # fragment与后端 `allowedReturnPrefixes` 白名单配合 */
export function getOAuthReturnTo() {
if (typeof window === "undefined") return "";
return `${window.location.origin}${window.location.pathname}${window.location.search}`;
}
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;

View File

@@ -119,6 +119,24 @@ a {
text-align: center;
}
button.splash-logo-hit {
display: block;
border: none;
padding: 0;
margin: 0;
background: none;
cursor: pointer;
font: inherit;
line-height: 0;
border-radius: 20px;
-webkit-tap-highlight-color: transparent;
}
button.splash-logo-hit:focus-visible {
outline: 2px solid #404040;
outline-offset: 4px;
}
.splash-logo-wrap {
position: relative;
width: 200px;
@@ -246,22 +264,34 @@ a {
min-width: 0;
}
.app-header-logo-btn {
/* 整段品牌区(图 + 标题)连点 5 次,与开屏大 Logo 共用 App.handleLogoTap */
.app-header-brand-btn {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
border: none;
padding: 0;
margin: 0;
background: none;
cursor: pointer;
border-radius: 8px;
line-height: 0;
flex-shrink: 0;
border-radius: 10px;
text-align: left;
font: inherit;
line-height: 1.2;
flex: 1;
-webkit-tap-highlight-color: transparent;
}
.app-header-logo-btn:focus-visible {
.app-header-brand-btn:focus-visible {
outline: 2px solid #404040;
outline-offset: 2px;
}
.app-header-brand-text {
min-width: 0;
}
.app-header-logo {
display: block;
width: 40px;
@@ -276,7 +306,8 @@ a {
}
.app-header h1,
.unified-user-header h1 {
.unified-user-header h1,
.app-header-brand-btn h1 {
margin: 0;
font-size: 21px;
font-weight: 800;
@@ -352,6 +383,105 @@ nav a:hover {
gap: 10px;
}
/* 仅未登录的登录/注册/重置:全视口磨砂主壳 + 轻遮罩;已登录/公开页/管理/无效 OAuth 等保持原样 */
.app-shell--auth-guest {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.app-shell--auth-guest .app-rand-bg__veil {
background: rgba(244, 244, 245, 0.58);
}
.app--auth-guest {
width: 100%;
max-width: none;
margin: 0;
min-height: 0;
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
box-sizing: border-box;
}
.app-shell--auth-guest > .app--auth-guest {
flex: 1;
min-height: 0;
}
.unified-user-shell--auth-guest {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
.unified-user-shell--auth-guest .unified-user-card {
--unified-glass: rgba(255, 255, 255, 0.4);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
background: var(--unified-glass);
border-radius: 0;
box-shadow: none;
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
backdrop-filter: blur(20px) saturate(1.12);
-webkit-backdrop-filter: blur(20px) saturate(1.12);
}
.unified-user-shell--auth-guest .unified-user-card-accent {
flex-shrink: 0;
}
.unified-user-shell--auth-guest .unified-user-header {
background: rgba(255, 255, 255, 0.28);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
flex-shrink: 0;
}
.unified-user-shell--auth-guest .unified-user-header .app-header-logo {
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.unified-user-shell--auth-guest .unified-user-header nav a {
background: rgba(255, 255, 255, 0.55);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.unified-user-shell--auth-guest .unified-user-main {
flex: 1;
min-height: 0;
background: transparent;
}
/* 访客登录/注册/重置:表单区单独一层半透明磨砂矩形容器 */
.unified-user-shell--auth-guest .unified-user-main .panel > form.auth-card {
max-width: 480px;
width: 100%;
background: rgba(255, 255, 255, 0.34);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
padding: 26px 24px 22px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
}
.unified-page-loading {
padding: 20px 4px 8px;
text-align: center;
@@ -373,12 +503,12 @@ nav a:hover {
/* === 公开用户列表 /users === */
.public-user-list-page {
max-width: 640px;
max-width: 820px;
margin: 0 auto;
}
.public-user-list-head {
margin-bottom: 14px;
margin-bottom: 10px;
}
.public-user-list-title {
@@ -405,17 +535,17 @@ nav a:hover {
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.public-user-list-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
gap: 12px;
padding: 7px 12px;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 12px;
border-radius: 10px;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
@@ -435,13 +565,13 @@ nav a:hover {
.public-user-list-row .public-user-list-avatar.avatar-shell {
flex-shrink: 0;
width: 56px;
height: 56px;
width: 44px;
height: 44px;
}
.public-user-list-row .public-user-list-avatar.avatar-shell img {
width: 56px;
height: 56px;
width: 44px;
height: 44px;
}
.public-user-list-meta {
@@ -449,28 +579,31 @@ nav a:hover {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
}
.public-user-list-name {
font-size: 16px;
font-size: 15px;
font-weight: 700;
color: #111827;
line-height: 1.25;
}
.public-user-list-account {
font-size: 13px;
font-size: 12px;
color: #737373;
line-height: 1.25;
}
.public-user-list-sub {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
font-size: 13px;
gap: 4px 8px;
font-size: 12px;
color: #6b7280;
margin-top: 4px;
margin-top: 2px;
line-height: 1.3;
}
.public-user-list-sub .icon {
@@ -487,23 +620,90 @@ nav a:hover {
.public-user-list-chevron {
flex-shrink: 0;
font-size: 22px;
font-size: 18px;
font-weight: 300;
color: #a3a3a3;
line-height: 1;
}
/* 与登录/注册同套全屏毛玻璃:公开用户列表 */
.unified-user-shell--auth-guest .public-user-list-page {
max-width: 900px;
width: 100%;
padding: 18px 16px 16px;
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
box-sizing: border-box;
}
.unified-user-shell--auth-guest .public-user-list-row {
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.42);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
}
.unified-user-shell--auth-guest .public-user-list-row:hover {
background: rgba(255, 255, 255, 0.38);
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.1);
}
.unified-user-shell--auth-guest .public-user-list-sub-sep {
background: rgba(0, 0, 0, 0.12);
}
/* 公开用户个人主页:单张磨砂内容卡 */
.unified-user-shell--auth-guest .unified-user-main .card.profile {
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
padding: 22px 20px 24px;
}
.unified-user-shell--auth-guest .unified-user-main .unified-page-miss {
max-width: 420px;
margin: 0 auto;
padding: 28px 24px 26px;
text-align: center;
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
/* 外层已是卡片,内层登录表单不再叠第二张卡片 */
.unified-user-main .auth-card {
background: transparent;
box-shadow: none;
border-radius: 0;
padding: 4px 0 0;
max-width: 420px;
max-width: 480px;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 768px) {
.unified-user-main .panel > form.auth-card,
.unified-user-shell--auth-guest .unified-user-main .panel > form.auth-card {
max-width: 560px;
}
}
.unified-user-main .auth-card::before {
display: none;
}
@@ -517,6 +717,131 @@ nav a:hover {
border-radius: 0;
}
/* === 管理后台:侧边栏 + 主区 === */
.admin-shell.panel {
max-width: 1200px;
}
.admin-layout {
display: flex;
align-items: stretch;
gap: 0;
min-height: 0;
margin: 0;
}
.admin-sidebar {
flex: 0 0 200px;
max-width: 200px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px 0 0 10px;
padding: 16px 10px 20px;
box-sizing: border-box;
}
.admin-sidebar-title {
font-size: 12px;
font-weight: 800;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px 8px;
}
.admin-sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
button.admin-sidebar-link {
display: block;
width: 100%;
text-align: left;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 600;
font-family: inherit;
line-height: 1.3;
cursor: pointer;
box-sizing: border-box;
transition: background 0.12s ease, color 0.12s ease;
}
button.admin-sidebar-link:hover {
background: #e2e8f0;
color: #0f172a;
}
button.admin-sidebar-link.is-active {
background: #111827;
color: #fff;
}
.admin-main {
flex: 1;
min-width: 0;
padding: 12px 16px 24px 20px;
max-height: min(86vh, 880px);
overflow-x: auto;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-left: none;
border-radius: 0 10px 10px 0;
background: #fff;
box-sizing: border-box;
}
.admin-main-title {
font-size: 18px;
font-weight: 800;
margin: 0 0 14px;
color: #111827;
letter-spacing: -0.3px;
}
.admin-main .admin-section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: none;
}
@media (max-width: 800px) {
.admin-layout {
flex-direction: column;
}
.admin-sidebar {
flex: none;
max-width: none;
border-radius: 10px 10px 0 0;
border-bottom: none;
}
.admin-sidebar-nav {
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
}
button.admin-sidebar-link {
width: auto;
flex: 1 1 auto;
min-width: calc(50% - 4px);
text-align: center;
font-size: 13px;
padding: 8px 10px;
}
.admin-main {
max-height: none;
border-radius: 0 0 10px 10px;
border-top: 1px solid #e2e8f0;
border-left: 1px solid #e2e8f0;
}
}
/* === 管理后台:单卡片内分区(无嵌套 card=== */
.unified-user-main .admin-console {
padding: 0;
@@ -623,6 +948,111 @@ nav a:hover {
border-bottom: 1px solid #e5e5e5;
}
/* 管理后台 · 用户表:窄屏改为卡片行,避免 9 列挤碎文字 */
@media (max-width: 900px) {
.unified-user-main .admin-table {
display: block;
}
.unified-user-main .admin-table .table-row.header {
display: none !important;
}
.unified-user-main .admin-table .table-row:not(.header) {
display: flex !important;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 12px;
margin-bottom: 12px;
background: #fafbfc;
border: 1px solid #e2e8f0 !important;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
grid-template-columns: unset !important;
}
.unified-user-main .admin-table .table-row:not(.header):hover {
background: #f1f5f9;
}
.unified-user-main .admin-table .table-row:not(.header) > .table-cell {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
column-gap: 10px;
row-gap: 4px;
min-width: 0;
font-size: 14px;
}
.unified-user-main .admin-table .table-row:not(.header) > .table-cell::before {
content: attr(data-label) "";
flex: 0 0 4.5em;
color: #64748b;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
padding-top: 1px;
}
.unified-user-main .admin-table .table-row:not(.header) > .table-cell .table-cell-value {
flex: 1 1 0;
min-width: 0;
word-break: normal;
overflow-wrap: anywhere;
}
.unified-user-main .admin-table .admin-email-cell,
.unified-user-main .admin-table .table-row span {
word-break: normal;
overflow-wrap: anywhere;
}
.unified-user-main .admin-table .table-row .row-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 2px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
.unified-user-main .admin-table .table-row .row-actions::before {
content: attr(data-label) "";
flex: 0 0 100%;
color: #64748b;
font-size: 12px;
font-weight: 700;
margin: 0 0 2px;
}
}
/* 管理后台主区:小屏不裁切内容、侧栏不竖排挤占过高 */
@media (max-width: 800px) {
.admin-main {
padding: 10px 12px 20px 12px;
}
.admin-main-title {
font-size: 16px;
}
}
@media (max-width: 800px) {
.admin-layout {
min-width: 0;
}
.admin-sidebar {
padding: 12px 8px 14px;
}
}
.admin-user-banned {
color: #b91c1c;
font-weight: 700;
@@ -703,6 +1133,15 @@ nav a:hover {
font-size: 15px;
}
/* 管理后台:开关与标题同一行 */
.form label.admin-ban-row {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
}
.label-text,
.info-label {
display: inline-flex;
@@ -1067,6 +1506,20 @@ button:disabled {
flex-shrink: 0;
}
/* 个人资料「第三方账号」:与登录页相同的管理后台配置 Logo */
.info-row-label .icon:has(> .profile-oauth-logo) {
width: 22px;
height: 22px;
}
.info-row-label .profile-oauth-logo {
display: block;
width: 22px;
height: 22px;
object-fit: contain;
object-position: center;
}
.info-row-value {
flex: 1;
min-width: 0;
@@ -1642,6 +2095,13 @@ a.tag.tag-mailto:hover {
font-size: 14px;
}
.modal-header .admin-gate-token-hint {
margin-top: 8px;
margin-bottom: 0;
color: #64748b;
font-size: 13px;
}
.modal-close {
flex-shrink: 0;
}
@@ -1747,7 +2207,7 @@ a.tag.tag-mailto:hover {
/* === Auth 登录 / 注册 / 重置(与顶栏风格统一)=== */
.panel > form.auth-card {
max-width: 420px;
max-width: 480px;
align-self: center;
width: 100%;
padding: 28px 26px 24px;
@@ -1823,6 +2283,127 @@ a.tag.tag-mailto:hover {
margin-top: 22px;
}
/* 第三方登录:全宽胶囊(登录 / 注册) */
.auth-oauth-stack {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.auth-oauth-stack--under-lead {
margin-top: 0;
margin-bottom: 8px;
padding-top: 0;
border-top: none;
}
.auth-oauth-label {
width: 100%;
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.auth-oauth-pill {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 44px;
padding: 8px 16px;
border: 1px solid #e5e7eb;
border-radius: 9999px;
background: #fff;
color: #1e293b;
font-size: 14px;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
box-sizing: border-box;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.auth-oauth-pill:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.auth-oauth-pill:focus-visible {
outline: 2px solid #94a3b8;
outline-offset: 2px;
}
.auth-oauth-pill:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* inner 仅负责让文案居中;图标用绝对定位钉在按钮左侧固定位置,
这样所有按钮宽度相同,图标左缘永远在同一条垂线上,文案各自独立居中 */
.auth-oauth-pill-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
pointer-events: none;
}
.auth-oauth-pill-icon {
position: absolute;
left: 18%;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
line-height: 0;
pointer-events: none;
}
.auth-oauth-pill-logo {
display: block;
width: 28px;
height: 28px;
object-fit: contain;
object-position: center;
}
.auth-oauth-pill-text {
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}
.auth-oauth-pill--no-logo .auth-oauth-pill-text {
text-align: center;
}
.auth-turnstile-wrap {
display: flex;
justify-content: center;
padding: 4px 0 2px;
}
.auth-turnstile-wrap--register-oauth {
padding-bottom: 10px;
margin-bottom: 2px;
}
.turnstile-widget {
display: flex;
justify-content: center;
}
.auth-submit {
width: 100%;
min-height: 48px;
@@ -1946,10 +2527,14 @@ select {
/* --- Tablet (<= 900px) --- */
@media (max-width: 900px) {
.app {
.app:not(.app--auth-guest) {
padding: 12px 12px 28px;
}
.app--auth-guest {
padding: 0;
}
.app-header {
padding: 10px 12px;
border-radius: 8px;
@@ -1957,7 +2542,8 @@ select {
}
.app-header h1,
.unified-user-header h1 {
.unified-user-header h1,
.app-header-brand-btn h1 {
font-size: 19px;
}
@@ -2013,10 +2599,14 @@ select {
/* --- Mobile (<= 640px) --- */
@media (max-width: 640px) {
.app {
.app:not(.app--auth-guest) {
padding: 12px 12px 32px;
}
.app--auth-guest {
padding: 0;
}
.app-header {
padding: 10px 14px;
border-radius: 10px;
@@ -2025,7 +2615,8 @@ select {
}
.app-header h1,
.unified-user-header h1 {
.unified-user-header h1,
.app-header-brand-btn h1 {
font-size: 17px;
}
@@ -2069,6 +2660,10 @@ select {
max-width: none;
}
.unified-user-shell--auth-guest .unified-user-main .panel > form.auth-card {
padding: 20px 16px 18px;
}
.panel > form.auth-card .auth-form-head h2 {
font-size: 20px;
}