Control settings tabs, hide default creds hint after change, LAN passwordless login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 13:04:07 +08:00
parent 8034464c2f
commit d46eaf43ab
9 changed files with 515 additions and 144 deletions
+75 -6
View File
@@ -1,13 +1,52 @@
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../api";
import { apiFetch, getToken, login, setSession } from "../api";
type LoginMeta = {
show_default_hint: boolean;
lan_login_available: boolean;
};
export default function LoginPage() {
const nav = useNavigate();
const [username, setUsername] = useState("admin");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const [showHint, setShowHint] = useState(false);
const [lanTrying, setLanTrying] = useState(true);
useEffect(() => {
if (getToken()) {
nav("/monitor", { replace: true });
return;
}
let cancelled = false;
(async () => {
try {
const meta = await apiFetch<LoginMeta>("/api/auth/login-meta");
if (cancelled) return;
setShowHint(!!meta.show_default_hint);
if (meta.lan_login_available) {
const res = await apiFetch<{ token: string; username: string }>(
"/api/auth/lan-login",
{ method: "POST", body: "{}" },
);
if (cancelled) return;
setSession(res.token, res.username);
nav("/monitor", { replace: true });
return;
}
} catch {
/* 免登失败则走普通登录 */
} finally {
if (!cancelled) setLanTrying(false);
}
})();
return () => {
cancelled = true;
};
}, [nav]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
@@ -23,15 +62,43 @@ export default function LoginPage() {
}
}
if (lanTrying) {
return (
<div className="login-wrap">
<div className="login-box">
<h1></h1>
<p className="meta"></p>
</div>
</div>
);
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<form
className="login-box"
onSubmit={onSubmit}
autoComplete="off"
data-lpignore="true"
>
<h1></h1>
<p className="meta"> · admin / admin123</p>
<p className="meta">
{showHint ? " · 默认 admin / admin123" : ""}
</p>
{err ? <div className="err">{err}</div> : null}
<label>
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
name="control-login-user"
required
/>
</label>
<label>
@@ -39,6 +106,8 @@ export default function LoginPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
name="control-login-pass"
required
/>
</label>
+246 -134
View File
@@ -1,7 +1,10 @@
import { FormEvent, useEffect, useState } from "react";
import { apiFetch, getUsername, setSession, type NodeCard } from "../api";
import { apiFetch, setSession, type NodeCard } from "../api";
type Tab = "account" | "add" | "list";
export default function SettingsPage() {
const [tab, setTab] = useState<Tab>("account");
const [nodes, setNodes] = useState<NodeCard[]>([]);
const [name, setName] = useState("");
const [baseUrl, setBaseUrl] = useState("https://");
@@ -11,19 +14,33 @@ export default function SettingsPage() {
null,
);
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
const [newUsername, setNewUsername] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [credBusy, setCredBusy] = useState(false);
const [showHint, setShowHint] = useState(false);
const [lanBypass, setLanBypass] = useState(false);
const [lanBusy, setLanBusy] = useState(false);
async function load() {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
setNodes(r.nodes || []);
}
async function loadMe() {
const m = await apiFetch<{
show_default_hint?: boolean;
lan_bypass_enabled?: boolean;
}>("/api/auth/me");
setShowHint(!!m.show_default_hint);
setLanBypass(!!m.lan_bypass_enabled);
}
useEffect(() => {
load().catch((ex) => setErr(ex instanceof Error ? ex.message : String(ex)));
Promise.all([load(), loadMe()]).catch((ex) =>
setErr(ex instanceof Error ? ex.message : String(ex)),
);
}, []);
async function onAdd(e: FormEvent) {
@@ -38,6 +55,7 @@ export default function SettingsPage() {
setName("");
setOk("已添加策略机");
await load();
setTab("list");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
@@ -53,21 +71,24 @@ export default function SettingsPage() {
}
setCredBusy(true);
try {
const r = await apiFetch<{ token: string; username: string }>(
"/api/auth/change-credentials",
{
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
new_username: newUsername.trim(),
new_password: newPassword,
}),
},
);
const r = await apiFetch<{
token: string;
username: string;
show_default_hint?: boolean;
}>("/api/auth/change-credentials", {
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
new_username: newUsername.trim(),
new_password: newPassword,
}),
});
setSession(r.token, r.username);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setNewUsername("");
setShowHint(!!r.show_default_hint);
setOk("中控账号已更新");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
@@ -76,6 +97,31 @@ export default function SettingsPage() {
}
}
async function onToggleLan(next: boolean) {
setErr("");
setOk("");
setLanBusy(true);
try {
const r = await apiFetch<{ lan_bypass_enabled: boolean }>(
"/api/auth/lan-bypass",
{
method: "PUT",
body: JSON.stringify({ enabled: next }),
},
);
setLanBypass(!!r.lan_bypass_enabled);
setOk(
next
? "已开启局域网免登录(仅私网 IP 生效)"
: "已关闭局域网免登录",
);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLanBusy(false);
}
}
async function genToken(id: number) {
setErr("");
setOk("");
@@ -97,6 +143,7 @@ export default function SettingsPage() {
setErr("");
try {
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
setOk("已删除");
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
@@ -109,133 +156,198 @@ export default function SettingsPage() {
<p className="meta">
访 Token Token
</p>
<div className="settings-tabs" role="tablist">
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
onClick={() => setTab("account")}
>
</button>
<button
type="button"
className={tab === "add" ? "tab active" : "tab"}
onClick={() => setTab("add")}
>
</button>
<button
type="button"
className={tab === "list" ? "tab active" : "tab"}
onClick={() => setTab("list")}
>
</button>
</div>
{err ? <div className="err">{err}</div> : null}
{ok ? <div className="ok">{ok}</div> : null}
<form className="add-form" onSubmit={onSaveCreds}>
<h3></h3>
<p className="meta"> admin / admin123</p>
<label>
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={6}
required
/>
</label>
<label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={6}
required
/>
</label>
<button className="btn" type="submit" disabled={credBusy}>
{credBusy ? "保存中…" : "保存账号"}
</button>
</form>
{lastToken ? (
<div className="token-box">
<div>
#{lastToken.id} Token
</div>
<code className="mono">{lastToken.token}</code>
<button
type="button"
className="btn ghost"
onClick={() => {
void navigator.clipboard.writeText(lastToken.token);
setOk("已复制到剪贴板");
}}
>
{tab === "account" ? (
<form className="add-form" onSubmit={onSaveCreds} autoComplete="off">
<h3></h3>
{showHint ? (
<p className="meta"></p>
) : (
<p className="meta"></p>
)}
<label className="switch-row">
<span>
<span className="meta switch-hint">
192.168/10/172.16 访
</span>
</span>
<button
type="button"
className={`switch ${lanBypass ? "on" : ""}`}
disabled={lanBusy}
aria-pressed={lanBypass}
onClick={() => void onToggleLan(!lanBypass)}
>
<span className="switch-knob" />
</button>
</label>
<label>
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
autoComplete="off"
name="control-new-user"
placeholder="输入新用户名"
required
/>
</label>
<label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="new-password"
name="control-cur-pass"
required
/>
</label>
<label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
name="control-new-pass"
minLength={6}
required
/>
</label>
<label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
name="control-confirm-pass"
minLength={6}
required
/>
</label>
<button className="btn" type="submit" disabled={credBusy}>
{credBusy ? "保存中…" : "保存账号"}
</button>
</div>
</form>
) : null}
<form className="add-form" onSubmit={onAdd}>
<h3></h3>
<label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 云机-A"
required
/>
</label>
<label>
Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dc.hyf2.cc"
required
/>
</label>
<button className="btn" type="submit">
</button>
</form>
{tab === "add" ? (
<form className="add-form" onSubmit={onAdd}>
<h3></h3>
<label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 云机-A"
required
/>
</label>
<label>
Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dc.hyf2.cc"
required
/>
</label>
<button className="btn" type="submit">
</button>
</form>
) : null}
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th>URL</th>
<th>Token</th>
<th></th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<td className="mono">{n.base_url}</td>
<td>{n.token_configured ? "已生成" : "无"}</td>
<td className="row-actions">
<button type="button" className="btn" onClick={() => void genToken(n.id)}>
Token
</button>
<button
type="button"
className="btn ghost"
onClick={() => void remove(n.id)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{tab === "list" ? (
<div>
{lastToken ? (
<div className="token-box">
<div> #{lastToken.id} Token</div>
<code className="mono">{lastToken.token}</code>
<button
type="button"
className="btn ghost"
onClick={() => {
void navigator.clipboard.writeText(lastToken.token);
setOk("已复制到剪贴板");
}}
>
</button>
</div>
) : null}
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th>URL</th>
<th>Token</th>
<th></th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<td className="mono">{n.base_url}</td>
<td>{n.token_configured ? "已生成" : "无"}</td>
<td className="row-actions">
<button
type="button"
className="btn"
onClick={() => void genToken(n.id)}
>
Token
</button>
<button
type="button"
className="btn ghost"
onClick={() => void remove(n.id)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!nodes.length ? <p className="meta"></p> : null}
</div>
) : null}
</div>
);
}