Update SproutGate
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user