Remove API URL UI; add username/password change in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-24 16:46:53 +08:00
parent e24b4f297f
commit 0fca5f025e
6 changed files with 199 additions and 52 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
- 反代:[`https://dc.hyf2.cc`](https://dc.hyf2.cc) → 本机 `5155`PM2: `eth-hedge-api` - 反代:[`https://dc.hyf2.cc`](https://dc.hyf2.cc) → 本机 `5155`PM2: `eth-hedge-api`
- 默认登录:见服务器 `/opt/eth_hedge_sim/.env``AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123` - 默认登录:见服务器 `/opt/eth_hedge_sim/.env``AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123`
- 登录页 **API 地址** 默认同域即可(填 `https://dc.hyf2.cc` 亦可) - 可在「系统设置」修改用户名/密码;前端固定同源 API,无 API 地址配置项
## 一键部署 / 更新(禁止 scp 传代码) ## 一键部署 / 更新(禁止 scp 传代码)
+33 -3
View File
@@ -3,18 +3,25 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..config import Settings, get_settings from ..config import Settings, get_settings
from ..credentials import get_credentials, update_credentials
from .auth import LoginRequest, LoginResponse, issue_token, require_user from .auth import LoginRequest, LoginResponse, issue_token, require_user
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
class ChangeCredentialsRequest(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=4, max_length=128)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse: async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
user_ok = body.username == settings.auth_username user, pwd = get_credentials()
pass_ok = body.password == settings.auth_password if body.username != user or body.password != pwd:
if not (user_ok and pass_ok):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
token, ttl = issue_token(body.username, settings) token, ttl = issue_token(body.username, settings)
return LoginResponse( return LoginResponse(
@@ -34,3 +41,26 @@ async def me(username: Annotated[str, Depends(require_user)], settings: Annotate
"mode": settings.mode, "mode": settings.mode,
"sim": settings.is_sim, "sim": settings.is_sim,
} }
@router.post("/change-credentials", response_model=LoginResponse)
async def change_credentials(
body: ChangeCredentialsRequest,
username: Annotated[str, Depends(require_user)],
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
_cur_user, cur_pwd = get_credentials()
if body.current_password != cur_pwd:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
try:
update_credentials(new_username=body.new_username, new_password=body.new_password)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
token, ttl = issue_token(body.new_username.strip(), settings)
return LoginResponse(
token=token,
username=body.new_username.strip(),
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
)
+69
View File
@@ -0,0 +1,69 @@
"""运行时登录凭据:内存生效 + 持久化到 .env。"""
from __future__ import annotations
import re
from pathlib import Path
from threading import Lock
from .config import get_settings
_lock = Lock()
_username: str | None = None
_password: str | None = None
def _env_paths() -> list[Path]:
here = Path(__file__).resolve()
# backend/app/credentials.py -> repo root = parents[2]
root = here.parents[2]
return [root / ".env", Path.cwd() / ".env", Path.cwd().parent / ".env"]
def _ensure_loaded() -> None:
global _username, _password
if _username is not None and _password is not None:
return
s = get_settings()
_username = s.auth_username
_password = s.auth_password
def get_credentials() -> tuple[str, str]:
with _lock:
_ensure_loaded()
assert _username is not None and _password is not None
return _username, _password
def upsert_env_file(key: str, value: str) -> Path | None:
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
paths = _env_paths()
target = next((p for p in paths if p.is_file()), paths[0])
target.parent.mkdir(parents=True, exist_ok=True)
text = target.read_text(encoding="utf-8") if target.is_file() else ""
line = f"{key}={value}"
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
if pattern.search(text):
text = pattern.sub(line, text)
else:
if text and not text.endswith("\n"):
text += "\n"
text += line + "\n"
target.write_text(text, encoding="utf-8")
return target
def update_credentials(*, new_username: str, new_password: str) -> None:
global _username, _password
user = new_username.strip()
pwd = new_password
if not user or not pwd:
raise ValueError("用户名和密码不能为空")
with _lock:
upsert_env_file("AUTH_USERNAME", user)
upsert_env_file("AUTH_PASSWORD", pwd)
_username = user
_password = pwd
# 刷新 Settings 缓存,避免进程内读到旧值
get_settings.cache_clear()
+13 -8
View File
@@ -1,18 +1,11 @@
const API_KEY = "eth_hedge_api_base";
const TOKEN_KEY = "eth_hedge_token"; const TOKEN_KEY = "eth_hedge_token";
const USER_KEY = "eth_hedge_user"; const USER_KEY = "eth_hedge_user";
/** 始终同源(经 dc.hyf2.cc 反代),不再暴露可改 API 地址。 */
export function getApiBase(): string { export function getApiBase(): string {
const saved = localStorage.getItem(API_KEY);
if (saved && saved.trim()) return saved.trim().replace(/\/$/, "");
// same-origin default when UI is served by backend on :5155
return window.location.origin; return window.location.origin;
} }
export function setApiBase(url: string) {
localStorage.setItem(API_KEY, url.trim().replace(/\/$/, ""));
}
export function getToken(): string | null { export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY);
} }
@@ -25,6 +18,7 @@ export function setSession(token: string, username: string) {
export function clearSession() { export function clearSession() {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
localStorage.removeItem("eth_hedge_api_base");
} }
export function getUsername(): string | null { export function getUsername(): string | null {
@@ -80,6 +74,17 @@ export async function login(username: string, password: string) {
}); });
} }
export async function changeCredentials(input: {
current_password: string;
new_username: string;
new_password: string;
}) {
return apiFetch<LoginResult>("/api/auth/change-credentials", {
method: "POST",
body: JSON.stringify(input),
});
}
export type MarketSnapshot = { export type MarketSnapshot = {
connected: boolean; connected: boolean;
updated_at_ms: number | null; updated_at_ms: number | null;
+3 -22
View File
@@ -1,26 +1,19 @@
import { FormEvent, useMemo, useState } from "react"; import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getApiBase, login, setApiBase, setSession } from "../api/client"; import { login, setSession } from "../api/client";
export default function LoginPage() { export default function LoginPage() {
const nav = useNavigate(); const nav = useNavigate();
const [apiBase, setApi] = useState(getApiBase());
const [username, setUsername] = useState("admin"); const [username, setUsername] = useState("admin");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const hint = useMemo(
() => "默认同域 API。测试环境反代:https://dc.hyf2.cc",
[],
);
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setErr(""); setErr("");
setLoading(true); setLoading(true);
try { try {
setApiBase(apiBase);
const res = await login(username.trim(), password); const res = await login(username.trim(), password);
setSession(res.token, res.username); setSession(res.token, res.username);
nav("/plan", { replace: true }); nav("/plan", { replace: true });
@@ -35,19 +28,8 @@ export default function LoginPage() {
<div className="login-wrap"> <div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}> <form className="login-box" onSubmit={onSubmit}>
<h1>eth_hedge_sim</h1> <h1>eth_hedge_sim</h1>
<p> · API </p> <p></p>
{err ? <div className="err">{err}</div> : null} {err ? <div className="err">{err}</div> : null}
<div className="field">
<label htmlFor="api">API </label>
<input
id="api"
className="mono"
value={apiBase}
onChange={(e) => setApi(e.target.value)}
placeholder="https://dc.hyf2.cc"
autoComplete="url"
/>
</div>
<div className="field"> <div className="field">
<label htmlFor="user"></label> <label htmlFor="user"></label>
<input <input
@@ -72,7 +54,6 @@ export default function LoginPage() {
<button className="btn block" type="submit" disabled={loading}> <button className="btn block" type="submit" disabled={loading}>
{loading ? "登录中…" : "登录"} {loading ? "登录中…" : "登录"}
</button> </button>
<div className="hint">{hint}</div>
</form> </form>
</div> </div>
); );
+80 -18
View File
@@ -1,37 +1,99 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { getApiBase, setApiBase } from "../api/client"; import { changeCredentials, getUsername, setSession } from "../api/client";
export default function SettingsPage() { export default function SettingsPage() {
const [apiBase, setApi] = useState(getApiBase()); const [newUsername, setNewUsername] = useState(getUsername() || "admin");
const [saved, setSaved] = useState(false); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [err, setErr] = useState("");
const [ok, setOk] = useState("");
const [loading, setLoading] = useState(false);
function onSave(e: FormEvent) { async function onSave(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setApiBase(apiBase); setErr("");
setSaved(true); setOk("");
window.setTimeout(() => setSaved(false), 1500); if (newPassword !== confirmPassword) {
setErr("两次输入的新密码不一致");
return;
}
if (newPassword.length < 4) {
setErr("新密码至少 4 位");
return;
}
setLoading(true);
try {
const res = await changeCredentials({
current_password: currentPassword,
new_username: newUsername.trim(),
new_password: newPassword,
});
setSession(res.token, res.username);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setOk("用户名/密码已更新");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
} }
return ( return (
<div className="card" style={{ maxWidth: 560 }}> <div className="card" style={{ maxWidth: 560 }}>
<h2 style={{ marginTop: 0 }}></h2> <h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}> <p style={{ color: "var(--muted)" }}> .env</p>
API https://dc.hyf2.cc(同源可留空当前域名)。 {err ? <div className="err">{err}</div> : null}
</p> {ok ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{ok}</div> : null}
<form onSubmit={onSave}> <form onSubmit={onSave}>
<div className="field"> <div className="field">
<label htmlFor="api">API </label> <label htmlFor="user"></label>
<input <input
id="api" id="user"
className="mono" value={newUsername}
value={apiBase} onChange={(e) => setNewUsername(e.target.value)}
onChange={(e) => setApi(e.target.value)} autoComplete="username"
required
/> />
</div> </div>
<button className="btn" type="submit"> <div className="field">
<label htmlFor="cur"></label>
<input
id="cur"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="field">
<label htmlFor="np"></label>
<input
id="np"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="field">
<label htmlFor="cp"></label>
<input
id="cp"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存"}
</button> </button>
{saved ? <span style={{ marginLeft: 10, color: "var(--up)" }}></span> : null}
</form> </form>
</div> </div>
); );