Files
ttstd-infinite-canvas/web/src/pages/admin/components/access-settings-panel.tsx

524 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Form, Input, Select, Skeleton } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { AlertTriangle, BadgeCheck, ChevronDown, Globe2, KeyRound, LockKeyhole, RefreshCw, RotateCcw, Save, ShieldCheck, UserPlus, UsersRound } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminLinuxDOSetting, getAdminRegistrationSetting, updateAdminLinuxDOSetting, updateAdminRegistrationSetting, type LinuxDOSetting, type RegistrationSetting } from "@/services/api/wallet";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
type LinuxDOFormValues = Omit<LinuxDOSetting, "hasClientSecret" | "updatedAt">;
export default function AccessSettingsPanel() {
const { message, modal } = App.useApp();
const [linuxdo, setLinuxdo] = useState<LinuxDOSetting | null>(null);
const [registration, setRegistration] = useState<RegistrationSetting | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [savingLinuxDO, setSavingLinuxDO] = useState(false);
const [savingRegistration, setSavingRegistration] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftLinuxDOEnabled, setDraftLinuxDOEnabled] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const [form] = Form.useForm<LinuxDOFormValues>();
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const [linuxdoData, registrationData] = await Promise.all([getAdminLinuxDOSetting(), getAdminRegistrationSetting()]);
if (requestVersion !== requestVersionRef.current) return;
setLinuxdo(linuxdoData.setting);
setRegistration(registrationData.setting);
setDirty(false);
setSaveError("");
if (announce) message.success("已重新读取当前登录与注册配置");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取登录与注册配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[form, message],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
useEffect(() => {
if (loading || !linuxdo || !registration) return;
form.setFieldsValue(toLinuxDOFormValues(linuxdo));
setDraftLinuxDOEnabled(linuxdo.enabled);
}, [form, linuxdo, loading, registration]);
const blocker = useBlocker(dirty && !savingLinuxDO);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || savingLinuxDO) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, savingLinuxDO]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃 Linux.do 登录调整?",
content: "当前表单有尚未保存的调整,离开后这些内容会丢失。用户注册状态不受影响。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => {
navigationConfirmOpenRef.current = false;
navigationTriggerRef.current = null;
blocker.proceed();
},
onCancel: () => {
navigationConfirmOpenRef.current = false;
blocker.reset();
window.requestAnimationFrame(() => {
const fallback = document.querySelector<HTMLButtonElement>(".admin-access-command-actions button");
const target = navigationTriggerRef.current?.isConnected ? navigationTriggerRef.current : fallback;
target?.focus();
navigationTriggerRef.current = null;
});
},
});
}, [blocker, modal]);
const resetLinuxDODraft = () => {
if (!linuxdo || savingLinuxDO) return;
form.setFieldsValue(toLinuxDOFormValues(linuxdo));
form.setFields([]);
setDraftLinuxDOEnabled(linuxdo.enabled);
setDirty(false);
setSaveError("");
message.info("已撤销 Linux.do 登录的未保存调整");
};
const requestRefresh = () => {
if (!dirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前 Linux.do 表单中的未保存内容,并以服务端配置为准。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false, true),
});
};
const toggleRegistration = async (enabled: boolean) => {
setSavingRegistration(true);
try {
const data = await updateAdminRegistrationSetting(enabled);
setRegistration(data.setting);
message.success(enabled ? "用户注册已开启" : "用户注册已关闭");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "更新注册设置失败";
message.error(errorMessage);
throw error;
} finally {
setSavingRegistration(false);
}
};
const requestRegistrationChange = (enabled: boolean) => {
if (!registration || enabled === registration.enabled || savingRegistration) return;
void toggleRegistration(enabled).catch(() => undefined);
};
const saveLinuxDO = async (values: LinuxDOFormValues) => {
const expected = normalizeLinuxDOFormValues(values);
setSavingLinuxDO(true);
setSaveError("");
try {
const result = await updateAdminLinuxDOSetting(expected);
if (!linuxDOResponseMatches(result.setting, expected)) throw new Error("服务端返回的 Linux.do 配置与本次保存内容不一致,请重新读取后核对");
setLinuxdo(result.setting);
setDirty(false);
message.success("Linux.do 登录配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存 Linux.do 配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSavingLinuxDO(false);
}
};
const submitLinuxDOSave = async () => {
let values: LinuxDOFormValues;
try {
values = await form.validateFields();
} catch {
return;
}
const validationError = validateLinuxDODraft(values, linuxdo);
if (validationError) {
message.error(validationError);
return;
}
try {
await saveLinuxDO(values);
} catch {
// 保存错误已在 saveLinuxDO 中就地提示。
}
};
const toggleLinuxDO = (enabled: boolean) => {
if (!linuxdo || savingLinuxDO) return;
form.setFieldValue("enabled", enabled);
setDraftLinuxDOEnabled(enabled);
setDirty(hasLinuxDOChanges({ ...form.getFieldsValue(true), enabled }, linuxdo));
setSaveError("");
};
if (loading && (!linuxdo || !registration)) {
return (
<div className="admin-settings-stack admin-access-settings" aria-label="正在读取登录与注册配置" role="status">
<div className="admin-access-loading-card">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
</div>
);
}
if (!linuxdo || !registration) {
return (
<div className="admin-settings-stack admin-access-settings">
<div className="admin-access-load-error" role="alert">
<span className="admin-access-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取登录与注册配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false, true)}>
重新读取
</Button>
</div>
</div>
);
}
return (
<div className="admin-settings-stack admin-access-settings">
<div className={cn("admin-access-command-bar", dirty && "is-dirty")}>
<div className="admin-access-command-copy" aria-live="polite">
<span className="admin-access-command-icon">
<ShieldCheck className="size-4" aria-hidden="true" />
</span>
<div>
<strong>{dirty ? "Linux.do 有未保存的调整" : "登录与注册设置"}</strong>
<p>{dirty ? "完成接入信息后保存生效。" : `新用户注册${registration.enabled ? "已开放" : "已关闭"} · Linux.do ${linuxdo.enabled ? "已启用" : "未启用"}`}</p>
</div>
</div>
<div className="admin-access-command-actions">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={savingLinuxDO} onClick={resetLinuxDODraft}>
撤销调整
</Button>
) : null}
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={savingLinuxDO || savingRegistration} onClick={requestRefresh}>
刷新状态
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-access-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的配置。`}</span>
</div>
) : null}
<div id="admin-access-registration" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-access-section admin-access-registration-section"
icon={<UserPlus className="size-4" aria-hidden="true" />}
title="1. 是否允许创建新账号"
description="先决定是否开放注册。关闭后已有账号仍可登录,已有数据不会删除。"
status={<AdminStatusBadge label={registration.enabled ? "已开放" : "已关闭"} tone={registration.enabled ? "success" : "neutral"} />}
>
<div className="admin-access-registration-policy">
<span className="admin-access-policy-icon">
<UsersRound className="size-5" aria-hidden="true" />
</span>
<div className="admin-access-policy-copy">
<div className="flex flex-wrap items-center gap-2">
<strong>允许创建新账号</strong>
<AdminStatusBadge label="切换即保存" tone="info" />
</div>
<p>关闭后,本地注册和未绑定账号的 Linux.do 首次登录都会被拒绝;已有账号及已绑定身份仍可继续登录。</p>
<span>{formatSettingTime(registration.updatedAt, "当前来自部署环境默认值")}</span>
</div>
<Switch checked={registration.enabled} loading={savingRegistration} disabled={loading || refreshing || savingLinuxDO} onChange={requestRegistrationChange} aria-label="允许创建新账号,切换后立即生效" />
</div>
</SettingsSectionCard>
</div>
<div id="admin-access-linuxdo" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-access-section admin-access-linuxdo-section"
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="2. 是否开放 Linux.do 登录"
description="先决定是否在登录与注册页展示 Linux.do。开启后再填写 OAuth 接入信息。"
status={<AdminStatusBadge label={draftLinuxDOEnabled ? (dirty ? "待启用" : "运行中") : dirty && linuxdo.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftLinuxDOEnabled ? "success" : "neutral"} />}
footer={
<>
<div className="admin-access-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>{draftLinuxDOEnabled ? "完整填写 OAuth 接入信息后保存" : "关闭后隐藏入口,已有账号绑定不受影响"}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={savingLinuxDO} onClick={resetLinuxDODraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={savingLinuxDO} disabled={!dirty || loading || refreshing} onClick={() => void submitLinuxDOSave()}>
{draftLinuxDOEnabled ? "保存并启用" : "保存并关闭"}
</Button>
</div>
</>
}
>
<Form
form={form}
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || savingLinuxDO}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftLinuxDOEnabled(Boolean(values.enabled));
setDirty(hasLinuxDOChanges(values, linuxdo));
setSaveError("");
}}
>
<div className="admin-access-provider-toggle">
<span className="admin-access-policy-icon">
<ShieldCheck className="size-5" aria-hidden="true" />
</span>
<div className="admin-access-policy-copy">
<div className="flex flex-wrap items-center gap-2">
<strong>在登录与注册页显示 Linux.do</strong>
<AdminStatusBadge label="保存后生效" tone="info" />
</div>
<p>开启后显示第三方登录入口;首次登录是否能创建账号仍受上方注册开关控制。</p>
{!draftLinuxDOEnabled ? <span>当前关闭,因此 OAuth 凭据和端点配置已收起。</span> : null}
</div>
<Form.Item noStyle name="enabled" valuePropName="checked">
<Switch aria-label="在登录与注册页显示 Linux.do" onChange={toggleLinuxDO} />
</Form.Item>
</div>
{draftLinuxDOEnabled ? (
<>
<div className="admin-access-form-section">
<FormSectionTitle icon={<KeyRound className="size-4" />} title="应用凭据" description="填写 Linux.do OAuth 应用的客户端信息;密钥只保存在服务端。" />
<div className="admin-access-form-grid is-credentials">
<Form.Item name="clientAuthMethod" label="Token 请求鉴权方式" rules={[{ required: true, message: "请选择鉴权方式" }]} extra="应用未特别要求时使用 Client Secret Post。">
<Select
options={[
{ label: "Client Secret Post(推荐)", value: "client_secret_post" },
{ label: "Client Secret Basic", value: "client_secret_basic" },
]}
/>
</Form.Item>
<Form.Item name="clientId" label="Client ID">
<Input autoComplete="off" placeholder="Linux.do OAuth 应用的 Client ID" />
</Form.Item>
<Form.Item name="clientSecret" label={linuxdo.hasClientSecret ? `Client Secret(${configuredSecretText})` : "Client Secret"}>
<Input.Password autoComplete="new-password" placeholder={linuxdo.hasClientSecret ? "留空保留原密钥" : "Linux.do OAuth 应用的 Client Secret"} />
</Form.Item>
</div>
</div>
<div className="admin-access-form-section">
<FormSectionTitle icon={<Globe2 className="size-4" />} title="OAuth 连接地址" description="授权、Token 和用户资料端点必须使用 HTTPS;本地回环回调可使用 HTTP。" />
<div className="admin-access-form-grid">
<Form.Item name="authorizationUrl" label="授权地址">
<Input inputMode="url" placeholder="https://connect.linux.do/oauth2/authorize" />
</Form.Item>
<Form.Item name="tokenUrl" label="Token 地址">
<Input inputMode="url" placeholder="https://connect.linux.do/oauth2/token" />
</Form.Item>
<Form.Item name="userInfoUrl" label="用户资料地址">
<Input inputMode="url" placeholder="https://connect.linux.do/api/user" />
</Form.Item>
<Form.Item name="redirectUrl" label="本站回调地址" extra="必须与 Linux.do OAuth 应用登记值完全一致;推荐使用 /oauth/linuxdo/callback。">
<Input inputMode="url" placeholder="https://你的域名/oauth/linuxdo/callback" />
</Form.Item>
<Form.Item name="scopes" label="授权范围(Scopes)" className="admin-access-form-span-full" extra="通常使用 openid、profile、email;按 Linux.do 应用实际授权范围填写。">
<Select mode="tags" tokenSeparators={[",", " "]} placeholder="输入后按回车添加" />
</Form.Item>
</div>
</div>
<details className="admin-access-advanced group">
<summary>
<span className="admin-access-advanced-icon">
<LockKeyhole className="size-4" aria-hidden="true" />
</span>
<span>
<strong>高级:用户资料字段映射</strong>
<small>指定从 Linux.do 响应中读取本地账号信息的字段路径。</small>
</span>
<AdminStatusBadge label="通常无需修改" tone="neutral" />
<ChevronDown className="size-4 shrink-0 transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="admin-access-form-grid is-mapping">
<Form.Item name="subjectField" label="唯一用户 ID 字段" extra="账号绑定的唯一依据,必须长期稳定。">
<Input placeholder="id" />
</Form.Item>
<Form.Item name="usernameField" label="用户名字段" extra="用于生成本站用户名。">
<Input placeholder="username" />
</Form.Item>
<Form.Item name="displayNameField" label="显示名称字段" extra="显示在用户菜单中的名称。">
<Input placeholder="name" />
</Form.Item>
<Form.Item name="emailField" label="邮箱字段" extra="没有或无效时允许留空。">
<Input placeholder="email" />
</Form.Item>
<Form.Item name="avatarField" label="头像地址字段" extra="支持 data.user.avatar_url 这类嵌套路径。">
<Input placeholder="avatar_url" />
</Form.Item>
</div>
</details>
</>
) : null}
</Form>
</SettingsSectionCard>
</div>
</div>
);
}
function FormSectionTitle({ icon, title, description }: { icon: ReactNode; title: string; description: string }) {
return (
<div className="admin-access-form-section-heading">
<span>{icon}</span>
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
function toLinuxDOFormValues(setting: LinuxDOSetting): LinuxDOFormValues {
return {
enabled: setting.enabled,
clientId: setting.clientId,
clientSecret: "",
authorizationUrl: setting.authorizationUrl,
tokenUrl: setting.tokenUrl,
userInfoUrl: setting.userInfoUrl,
redirectUrl: setting.redirectUrl,
scopes: setting.scopes || [],
clientAuthMethod: setting.clientAuthMethod,
subjectField: setting.subjectField,
usernameField: setting.usernameField,
displayNameField: setting.displayNameField,
emailField: setting.emailField,
avatarField: setting.avatarField,
};
}
function normalizeLinuxDOFormValues(values: LinuxDOFormValues): LinuxDOFormValues {
return {
enabled: Boolean(values.enabled),
clientId: values.clientId?.trim() || "",
clientSecret: values.clientSecret?.trim() || "",
authorizationUrl: values.authorizationUrl?.trim() || "",
tokenUrl: values.tokenUrl?.trim() || "",
userInfoUrl: values.userInfoUrl?.trim() || "",
redirectUrl: values.redirectUrl?.trim() || "",
scopes: [...new Set((values.scopes || []).map((value) => value.trim()).filter(Boolean))],
clientAuthMethod: values.clientAuthMethod || "client_secret_post",
subjectField: values.subjectField?.trim() || "id",
usernameField: values.usernameField?.trim() || "username",
displayNameField: values.displayNameField?.trim() || "name",
emailField: values.emailField?.trim() || "email",
avatarField: values.avatarField?.trim() || "avatar_url",
};
}
function hasLinuxDOChanges(values: LinuxDOFormValues, setting: LinuxDOSetting | null) {
if (!setting) return false;
const draft = normalizeLinuxDOFormValues(values);
const saved = normalizeLinuxDOFormValues(toLinuxDOFormValues(setting));
if (draft.clientSecret) return true;
return (Object.keys(saved) as Array<keyof LinuxDOFormValues>).some((key) => key !== "clientSecret" && JSON.stringify(draft[key]) !== JSON.stringify(saved[key]));
}
function validateLinuxDODraft(values: LinuxDOFormValues, setting: LinuxDOSetting | null) {
const draft = normalizeLinuxDOFormValues(values);
if (!draft.enabled) return "";
if (!draft.clientId || (!draft.clientSecret && !setting?.hasClientSecret) || !draft.authorizationUrl || !draft.tokenUrl || !draft.userInfoUrl || !draft.redirectUrl) return "启用 Linux.do 登录前请完整填写 Client、端点和回调配置";
for (const value of [draft.authorizationUrl, draft.tokenUrl, draft.userInfoUrl]) if (!isValidURL(value, true)) return "Linux.do 授权、Token 和用户资料地址必须是有效的 HTTPS URL";
if (!isValidRedirectURL(draft.redirectUrl)) return "Linux.do 回调地址必须使用 HTTPS,本地回环地址可使用 HTTP";
return "";
}
function linuxDOResponseMatches(setting: LinuxDOSetting, expected: LinuxDOFormValues) {
const actual = normalizeLinuxDOFormValues(toLinuxDOFormValues(setting));
const fields: Array<keyof LinuxDOFormValues> = ["enabled", "clientId", "authorizationUrl", "tokenUrl", "userInfoUrl", "redirectUrl", "scopes", "clientAuthMethod", "subjectField", "usernameField", "displayNameField", "emailField", "avatarField"];
if (expected.clientSecret && !setting.hasClientSecret) return false;
return fields.every((key) => JSON.stringify(actual[key]) === JSON.stringify(expected[key]));
}
function isValidURL(value: string, requireHTTPS = false) {
try {
const parsed = new URL(value);
return Boolean(parsed.host) && (!requireHTTPS || parsed.protocol === "https:");
} catch {
return false;
}
}
function isValidRedirectURL(value: string) {
try {
const parsed = new URL(value);
if (parsed.protocol === "https:" && parsed.host) return true;
return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
} catch {
return false;
}
}
function formatSettingTime(value: string | undefined, fallback: string) {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.getFullYear() < 2000) return fallback;
return `更新于 ${date.toLocaleString("zh-CN", { hour12: false })}`;
}