Remove API URL UI; add username/password change in settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 传代码)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user