serve 多用户认证体系
单密码→多用户:auth.json 格式迁移,login 支持用户名密码, 新增用户管理 API 和前端页面,会话关联 username, auth middleware 将当前用户注入 request.state。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+103
-25
@@ -1,11 +1,15 @@
|
|||||||
"""访问认证 API。
|
"""访问认证 API。
|
||||||
|
|
||||||
端点:
|
端点:
|
||||||
GET /api/auth/status — 是否已设密码、当前会话是否有效
|
GET /api/auth/status — 认证状态 + 当前用户信息
|
||||||
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
||||||
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
|
POST /api/auth/login — 登录(用户名+密码 → 会话 token, 含限流)
|
||||||
POST /api/auth/logout — 注销当前会话
|
POST /api/auth/logout — 注销当前会话
|
||||||
POST /api/auth/change-password — 改密码(需已登录)
|
POST /api/auth/change-password — 改密码(需已登录)
|
||||||
|
GET /api/auth/users — 用户列表(需 admin)
|
||||||
|
POST /api/auth/users — 创建用户(需 admin)
|
||||||
|
DELETE /api/auth/users/{username} — 删除用户(需 admin)
|
||||||
|
POST /api/auth/users/{username}/reset-password — 重置密码(需 admin)
|
||||||
|
|
||||||
安全:
|
安全:
|
||||||
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
|
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
|
||||||
@@ -101,6 +105,25 @@ def _clear_login_fails(ip: str) -> None:
|
|||||||
_fail_counter.pop(ip, None)
|
_fail_counter.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_user(request: Request) -> str:
|
||||||
|
"""从 cookie 获取当前登录的 username, 未登录抛 401。"""
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
username = auth.get_session_user(token or "")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401, detail="请先登录")
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(request: Request) -> str:
|
||||||
|
"""要求当前用户为 admin, 否则抛 403。"""
|
||||||
|
username = _get_current_user(request)
|
||||||
|
token = request.cookies.get(COOKIE_NAME, "")
|
||||||
|
role = auth.get_session_role(token)
|
||||||
|
if role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 端点
|
# 端点
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -110,6 +133,7 @@ class PasswordIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LoginIn(BaseModel):
|
class LoginIn(BaseModel):
|
||||||
|
username: str = Field(default="admin", min_length=1, max_length=64, description="用户名, 默认 admin")
|
||||||
password: str = Field(min_length=1, max_length=128)
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,13 +142,27 @@ class ChangePasswordIn(BaseModel):
|
|||||||
new_password: str = Field(min_length=6, max_length=128)
|
new_password: str = Field(min_length=6, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserIn(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||||
|
password: str = Field(min_length=6, max_length=128)
|
||||||
|
role: str = Field(default="viewer", pattern=r"^(admin|viewer)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordIn(BaseModel):
|
||||||
|
new_password: str = Field(min_length=6, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def auth_status(request: Request) -> dict:
|
def auth_status(request: Request) -> dict:
|
||||||
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
|
"""认证状态: 是否已设密码 + 当前请求是否已登录 + 用户信息。"""
|
||||||
token = request.cookies.get(COOKIE_NAME)
|
token = request.cookies.get(COOKIE_NAME) or ""
|
||||||
|
username = auth.get_session_user(token)
|
||||||
|
role = auth.get_session_role(token)
|
||||||
return {
|
return {
|
||||||
"configured": auth.is_configured(),
|
"configured": auth.is_configured(),
|
||||||
"authenticated": bool(token and auth.is_valid_session(token)),
|
"authenticated": bool(username),
|
||||||
|
"username": username or None,
|
||||||
|
"role": role or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,18 +191,19 @@ def setup_password(req: PasswordIn, request: Request) -> dict:
|
|||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
def login(req: LoginIn, request: Request, response: Response) -> dict:
|
def login(req: LoginIn, request: Request, response: Response) -> dict:
|
||||||
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
"""登录: 用户名+密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
_check_login_rate_limit(ip)
|
_check_login_rate_limit(ip)
|
||||||
|
|
||||||
if not auth.is_configured():
|
if not auth.is_configured():
|
||||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||||
|
|
||||||
token = auth.verify_and_create_session(req.password)
|
result = auth.login(req.username, req.password)
|
||||||
if not token:
|
if not result:
|
||||||
_record_login_fail(ip)
|
_record_login_fail(ip)
|
||||||
raise HTTPException(status_code=401, detail="密码错误")
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
token, username = result
|
||||||
_clear_login_fails(ip)
|
_clear_login_fails(ip)
|
||||||
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
|
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -176,7 +215,7 @@ def login(req: LoginIn, request: Request, response: Response) -> dict:
|
|||||||
path="/",
|
path="/",
|
||||||
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
|
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
|
||||||
)
|
)
|
||||||
return {"ok": True, "authenticated": True}
|
return {"ok": True, "authenticated": True, "username": username}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
@@ -191,23 +230,62 @@ def logout(request: Request, response: Response) -> dict:
|
|||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
def change_password(req: ChangePasswordIn, request: Request) -> dict:
|
def change_password(req: ChangePasswordIn, request: Request) -> dict:
|
||||||
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
|
"""修改密码: 需验证旧密码。"""
|
||||||
token = request.cookies.get(COOKIE_NAME)
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
if not (token and auth.is_valid_session(token)):
|
username = auth.get_session_user(token or "")
|
||||||
|
if not username:
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
raise HTTPException(status_code=401, detail="请先登录")
|
||||||
|
|
||||||
if not auth.is_configured():
|
if not auth.change_password(username, req.old_password, req.new_password):
|
||||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
|
||||||
|
|
||||||
# 验证旧密码
|
|
||||||
new_token = auth.verify_and_create_session(req.old_password)
|
|
||||||
if not new_token:
|
|
||||||
ip = _client_ip(request)
|
|
||||||
_record_login_fail(ip)
|
|
||||||
raise HTTPException(status_code=401, detail="旧密码错误")
|
raise HTTPException(status_code=401, detail="旧密码错误")
|
||||||
# 临时 token 用完即弃
|
|
||||||
auth.revoke_session(new_token)
|
|
||||||
|
|
||||||
# 改密码(set_password 会清空所有会话)
|
return {"ok": True, "message": "密码已修改"}
|
||||||
auth.set_password(req.new_password)
|
|
||||||
return {"ok": True, "message": "密码已修改, 请重新登录"}
|
|
||||||
|
# ================================================================
|
||||||
|
# 用户管理 (admin only)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(request: Request) -> dict:
|
||||||
|
"""用户列表(需 admin)。"""
|
||||||
|
_require_admin(request)
|
||||||
|
users = auth.list_users()
|
||||||
|
return {"users": users}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
def create_user(req: CreateUserIn, request: Request) -> dict:
|
||||||
|
"""创建用户(需 admin)。"""
|
||||||
|
_require_admin(request)
|
||||||
|
try:
|
||||||
|
auth.create_user(req.username, req.password, role=req.role)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
return {"ok": True, "username": req.username}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{username}")
|
||||||
|
def delete_user(username: str, request: Request) -> dict:
|
||||||
|
"""删除用户(需 admin, 不能删除 admin 自身)。"""
|
||||||
|
_require_admin(request)
|
||||||
|
try:
|
||||||
|
ok = auth.delete_user(username)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{username}/reset-password")
|
||||||
|
def reset_password(username: str, req: ResetPasswordIn, request: Request) -> dict:
|
||||||
|
"""重置用户密码(需 admin)。"""
|
||||||
|
_require_admin(request)
|
||||||
|
try:
|
||||||
|
ok = auth.admin_reset_password(username, req.new_password)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
|
|
||||||
# 情况 3: 已设密码, 检查会话
|
# 情况 3: 已设密码, 检查会话
|
||||||
token = request.cookies.get(auth_api.COOKIE_NAME)
|
token = request.cookies.get(auth_api.COOKIE_NAME)
|
||||||
if token and auth_service.is_valid_session(token):
|
username = auth_service.get_session_user(token or "")
|
||||||
|
if username:
|
||||||
|
request.state.username = username
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
# 未登录: 401(前端跳登录页)
|
# 未登录: 401(前端跳登录页)
|
||||||
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
|
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""访问密码认证 — 单用户, 自托管场景。
|
"""多用户访问密码认证 — 自托管场景。
|
||||||
|
|
||||||
设计:
|
设计:
|
||||||
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
|
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
|
||||||
即使 auth.json 泄露, 也无法逆向出明文密码。
|
即使 auth.json 泄露, 也无法逆向出明文密码。
|
||||||
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
|
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
|
||||||
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
|
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
|
||||||
|
- 多用户: 支持多个账号, 数据共享不隔离; role=admin 可管理用户。
|
||||||
|
|
||||||
安全要点:
|
安全要点:
|
||||||
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
|
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
|
||||||
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
|
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
|
||||||
- 单密码, 不做多用户(避免重构全项目数据层)。
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,13 +29,20 @@ _PBKDF2_ITER = 200_000
|
|||||||
_SALT_LEN = 16
|
_SALT_LEN = 16
|
||||||
_TOKEN_BYTES = 32
|
_TOKEN_BYTES = 32
|
||||||
|
|
||||||
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
|
# 会话有效期: 30 天(自托管, 长一点减少重登频率)
|
||||||
SESSION_TTL = 30 * 24 * 3600
|
SESSION_TTL = 30 * 24 * 3600
|
||||||
|
|
||||||
_lock = threading.Lock()
|
# schema 版本
|
||||||
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
|
_CURRENT_SCHEMA_VERSION = 2
|
||||||
_sessions: dict[str, float] = {}
|
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
# 内存中的有效会话: { token: {username, expire} }。进程重启后从磁盘恢复。
|
||||||
|
_sessions: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 存储
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
def _path() -> Path:
|
def _path() -> Path:
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -63,6 +70,10 @@ def _save(data: dict) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 密码工具
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||||
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
|
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
|
||||||
if salt is None:
|
if salt is None:
|
||||||
@@ -83,36 +94,228 @@ def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 密码管理
|
# 数据迁移 (v1 → v2)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
|
def _migrate_from_v1(data: dict) -> dict | None:
|
||||||
|
"""检测旧版单用户格式并迁移。返回迁移后的新数据, 无需迁移则返回 None。"""
|
||||||
|
if data.get("schema_version") == _CURRENT_SCHEMA_VERSION:
|
||||||
|
return None
|
||||||
|
if not data.get("password_hash"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("检测到旧版 auth.json, 正在迁移到多用户格式...")
|
||||||
|
new_data = {
|
||||||
|
"schema_version": _CURRENT_SCHEMA_VERSION,
|
||||||
|
"users": {},
|
||||||
|
"sessions": {},
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 将旧密码迁移为 admin 用户
|
||||||
|
salt = data.get("password_salt", "")
|
||||||
|
phash = data.get("password_hash", "")
|
||||||
|
new_data["users"]["admin"] = {
|
||||||
|
"password_hash": phash,
|
||||||
|
"password_salt": salt,
|
||||||
|
"role": "admin",
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 迁移旧 sessions
|
||||||
|
old_sessions = data.get("sessions") or {}
|
||||||
|
now = time.time()
|
||||||
|
for token, expire in old_sessions.items():
|
||||||
|
if isinstance(expire, (int, float)) and expire > now:
|
||||||
|
new_data["sessions"][token] = {"username": "admin", "expire": expire}
|
||||||
|
|
||||||
|
logger.info("auth.json 迁移完成: admin 用户, %d 个会话恢复", len(new_data["sessions"]))
|
||||||
|
return new_data
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 用户管理
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
def _ensure_loaded() -> dict:
|
||||||
|
"""加载数据, 必要时执行迁移。"""
|
||||||
|
data = _load()
|
||||||
|
migrated = _migrate_from_v1(data)
|
||||||
|
if migrated is not None:
|
||||||
|
data = migrated
|
||||||
|
_save(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def is_configured() -> bool:
|
def is_configured() -> bool:
|
||||||
"""是否已设置访问密码。"""
|
"""是否已有用户。"""
|
||||||
d = _load()
|
data = _ensure_loaded()
|
||||||
return bool(d.get("password_hash"))
|
return bool(data.get("users"))
|
||||||
|
|
||||||
|
|
||||||
def set_password(password: str) -> None:
|
def create_user(username: str, password: str, role: str = "viewer") -> None:
|
||||||
"""设置/修改访问密码。清空所有现有会话(强制重新登录)。"""
|
"""创建用户。username 已存在则抛出 ValueError。"""
|
||||||
if len(password) < 6:
|
if len(password) < 6:
|
||||||
raise ValueError("密码至少 6 位")
|
raise ValueError("密码至少 6 位")
|
||||||
|
if not username or not username.strip():
|
||||||
|
raise ValueError("用户名不能为空")
|
||||||
|
username = username.strip()
|
||||||
|
if not username.replace("_", "").replace("-", "").isalnum():
|
||||||
|
raise ValueError("用户名只能包含字母、数字、下划线和连字符")
|
||||||
|
|
||||||
salt_hex, hash_hex = _hash_password(password)
|
salt_hex, hash_hex = _hash_password(password)
|
||||||
with _lock:
|
with _lock:
|
||||||
_sessions.clear() # 改密码 = 旧会话全部失效
|
data = _ensure_loaded()
|
||||||
_save({
|
users = data.setdefault("users", {})
|
||||||
|
if username in users:
|
||||||
|
raise ValueError(f"用户 '{username}' 已存在")
|
||||||
|
users[username] = {
|
||||||
"password_hash": hash_hex,
|
"password_hash": hash_hex,
|
||||||
"password_salt": salt_hex,
|
"password_salt": salt_hex,
|
||||||
"updated_at": int(time.time()),
|
"role": role,
|
||||||
"sessions": {}, # 清空持久化会话
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
|
data["updated_at"] = int(time.time())
|
||||||
|
_save(data)
|
||||||
|
logger.info("user created: %s (role=%s)", username, role)
|
||||||
|
|
||||||
|
|
||||||
|
def list_users() -> list[dict]:
|
||||||
|
"""返回用户列表(不含密码哈希)。"""
|
||||||
|
data = _ensure_loaded()
|
||||||
|
users = data.get("users") or {}
|
||||||
|
result = []
|
||||||
|
for name, info in users.items():
|
||||||
|
result.append({
|
||||||
|
"username": name,
|
||||||
|
"role": info.get("role", "viewer"),
|
||||||
|
"created_at": info.get("created_at"),
|
||||||
})
|
})
|
||||||
logger.info("access password set")
|
result.sort(key=lambda u: u["created_at"] or 0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(username: str) -> bool:
|
||||||
|
"""删除用户。admin 用户不可删除。返回是否成功。"""
|
||||||
|
with _lock:
|
||||||
|
data = _ensure_loaded()
|
||||||
|
users = data.get("users") or {}
|
||||||
|
if username not in users:
|
||||||
|
return False
|
||||||
|
if users[username].get("role") == "admin":
|
||||||
|
raise ValueError("不能删除 admin 用户")
|
||||||
|
del users[username]
|
||||||
|
# 清除该用户的所有会话
|
||||||
|
sessions = data.get("sessions") or {}
|
||||||
|
data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username}
|
||||||
|
_sessions.clear() # 重新加载时同步
|
||||||
|
data["updated_at"] = int(time.time())
|
||||||
|
_save(data)
|
||||||
|
logger.info("user deleted: %s", username)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(username: str, old_password: str, new_password: str) -> bool:
|
||||||
|
"""用户自己改密码。验证旧密码, 返回是否成功。"""
|
||||||
|
if len(new_password) < 6:
|
||||||
|
raise ValueError("密码至少 6 位")
|
||||||
|
with _lock:
|
||||||
|
data = _ensure_loaded()
|
||||||
|
users = data.get("users") or {}
|
||||||
|
user = users.get(username)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if not _verify_password(old_password, user.get("password_salt", ""), user.get("password_hash", "")):
|
||||||
|
return False
|
||||||
|
salt_hex, hash_hex = _hash_password(new_password)
|
||||||
|
user["password_hash"] = hash_hex
|
||||||
|
user["password_salt"] = salt_hex
|
||||||
|
data["updated_at"] = int(time.time())
|
||||||
|
_save(data)
|
||||||
|
# 改密码不清除会话(与单用户模式不同, 多用户下只清自己的)
|
||||||
|
logger.info("password changed: %s", username)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def admin_reset_password(username: str, new_password: str) -> bool:
|
||||||
|
"""管理员重置用户密码。清除该用户所有会话。"""
|
||||||
|
if len(new_password) < 6:
|
||||||
|
raise ValueError("密码至少 6 位")
|
||||||
|
with _lock:
|
||||||
|
data = _ensure_loaded()
|
||||||
|
users = data.get("users") or {}
|
||||||
|
if username not in users:
|
||||||
|
return False
|
||||||
|
salt_hex, hash_hex = _hash_password(new_password)
|
||||||
|
users[username]["password_hash"] = hash_hex
|
||||||
|
users[username]["password_salt"] = salt_hex
|
||||||
|
# 清除被重置用户的所有会话
|
||||||
|
sessions = data.get("sessions") or {}
|
||||||
|
data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username}
|
||||||
|
data["updated_at"] = int(time.time())
|
||||||
|
_save(data)
|
||||||
|
logger.info("password reset by admin: %s", username)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 会话管理
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
def login(username: str, password: str) -> tuple[str, str] | None:
|
||||||
|
"""验证密码, 成功则创建会话并返回 (token, username), 失败返回 None。"""
|
||||||
|
data = _ensure_loaded()
|
||||||
|
users = data.get("users") or {}
|
||||||
|
user = users.get(username)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not _verify_password(password, user.get("password_salt", ""), user.get("password_hash", "")):
|
||||||
|
return None
|
||||||
|
token = _secrets.token_urlsafe(_TOKEN_BYTES)
|
||||||
|
expire = time.time() + SESSION_TTL
|
||||||
|
with _lock:
|
||||||
|
_sessions[token] = {"username": username, "expire": expire}
|
||||||
|
_persist_sessions_locked()
|
||||||
|
return token, username
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session(token: str) -> None:
|
||||||
|
"""注销会话(登出)。"""
|
||||||
|
with _lock:
|
||||||
|
_sessions.pop(token, None)
|
||||||
|
_persist_sessions_locked()
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user(token: str) -> str | None:
|
||||||
|
"""返回 token 对应的 username, 无效/过期则返回 None。过期自动清理。"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
with _lock:
|
||||||
|
session = _sessions.get(token)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
if time.time() > session["expire"]:
|
||||||
|
_sessions.pop(token, None)
|
||||||
|
_persist_sessions_locked()
|
||||||
|
return None
|
||||||
|
return session["username"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_role(token: str) -> str | None:
|
||||||
|
"""返回 token 对应用户的 role, 无效返回 None。"""
|
||||||
|
username = get_session_user(token)
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
data = _ensure_loaded()
|
||||||
|
users = data.get("users") or {}
|
||||||
|
user = users.get(username)
|
||||||
|
return user.get("role") if user else None
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_from_env() -> bool:
|
def bootstrap_from_env() -> bool:
|
||||||
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码。
|
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且无用户, 则创建 admin 用户。
|
||||||
|
|
||||||
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。
|
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。
|
||||||
明文密码只在内存/配置中, 经 set_password() 哈希后写入 auth.json (chmod 0600)。
|
|
||||||
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。
|
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -123,74 +326,86 @@ def bootstrap_from_env() -> bool:
|
|||||||
pwd = (settings.auth_password or "").strip()
|
pwd = (settings.auth_password or "").strip()
|
||||||
if not pwd:
|
if not pwd:
|
||||||
return False
|
return False
|
||||||
if is_configured():
|
data = _ensure_loaded()
|
||||||
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码)
|
if data.get("users"):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
set_password(pwd)
|
create_user("admin", pwd, role="admin")
|
||||||
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)")
|
logger.info("admin user bootstrapped from AUTH_PASSWORD env (one-time)")
|
||||||
return True
|
return True
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# 密码不合规 (< 6 位), 记日志但不阻断启动
|
|
||||||
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
|
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def verify_and_create_session(password: str) -> str | None:
|
# ================================================================
|
||||||
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
|
# 兼容旧版
|
||||||
d = _load()
|
# ================================================================
|
||||||
if not d.get("password_hash"):
|
|
||||||
return None
|
|
||||||
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
|
|
||||||
return None
|
|
||||||
token = _secrets.token_urlsafe(_TOKEN_BYTES)
|
|
||||||
expire = time.time() + SESSION_TTL
|
|
||||||
with _lock:
|
|
||||||
_sessions[token] = expire
|
|
||||||
_persist_sessions_locked()
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
# set_password 保留为 create_user 别名(旧 api/auth.py 中可能使用)
|
||||||
def revoke_session(token: str) -> None:
|
def set_password(password: str) -> None:
|
||||||
"""注销会话(登出)。"""
|
"""兼容旧版: 创建/更新 admin 用户密码。"""
|
||||||
with _lock:
|
with _lock:
|
||||||
_sessions.pop(token, None)
|
data = _ensure_loaded()
|
||||||
_persist_sessions_locked()
|
users = data.get("users") or {}
|
||||||
|
if "admin" in users:
|
||||||
|
salt_hex, hash_hex = _hash_password(password)
|
||||||
|
users["admin"]["password_hash"] = hash_hex
|
||||||
|
users["admin"]["password_salt"] = salt_hex
|
||||||
|
data["updated_at"] = int(time.time())
|
||||||
|
_save(data)
|
||||||
|
_sessions.clear()
|
||||||
|
logger.info("admin password updated via set_password")
|
||||||
|
else:
|
||||||
|
create_user("admin", password, role="admin")
|
||||||
|
|
||||||
|
|
||||||
def is_valid_session(token: str) -> bool:
|
def is_valid_session(token: str) -> bool:
|
||||||
"""检查会话是否有效(存在且未过期)。过期则清理。"""
|
"""兼容旧版: 检查会话是否有效。"""
|
||||||
if not token:
|
return get_session_user(token) is not None
|
||||||
return False
|
|
||||||
with _lock:
|
|
||||||
expire = _sessions.get(token)
|
|
||||||
if expire is None:
|
|
||||||
return False
|
|
||||||
if time.time() > expire:
|
|
||||||
_sessions.pop(token, None)
|
|
||||||
_persist_sessions_locked()
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
|
def verify_and_create_session(password: str) -> str | None:
|
||||||
|
"""兼容旧版: 作为 admin 登录, 返回 token。"""
|
||||||
|
result = login("admin", password)
|
||||||
|
if result:
|
||||||
|
return result[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 启动恢复
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
def _persist_sessions_locked() -> None:
|
def _persist_sessions_locked() -> None:
|
||||||
"""把当前内存会话写回 auth.json(需持锁调用)。"""
|
"""把当前内存会话写回 auth.json(需持锁调用)。"""
|
||||||
d = _load()
|
d = _load()
|
||||||
d["sessions"] = {t: exp for t, exp in _sessions.items()}
|
d.setdefault("users", {})
|
||||||
|
d["sessions"] = dict(_sessions)
|
||||||
|
if "schema_version" not in d:
|
||||||
|
d["schema_version"] = _CURRENT_SCHEMA_VERSION
|
||||||
_save(d)
|
_save(d)
|
||||||
|
|
||||||
|
|
||||||
def _restore_sessions() -> None:
|
def _restore_sessions() -> None:
|
||||||
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
|
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
|
||||||
with _lock:
|
with _lock:
|
||||||
d = _load()
|
data = _ensure_loaded()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
saved = d.get("sessions") or {}
|
saved = data.get("sessions") or {}
|
||||||
for token, expire in saved.items():
|
for token, session in saved.items():
|
||||||
if isinstance(expire, (int, float)) and expire > now:
|
if isinstance(session, dict):
|
||||||
_sessions[token] = expire
|
expire = session.get("expire", 0)
|
||||||
|
username = session.get("username", "")
|
||||||
|
elif isinstance(session, (int, float)):
|
||||||
|
# 兼容旧版: 旧格式 {token: expire} 映射到 admin
|
||||||
|
expire = session
|
||||||
|
username = "admin"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if expire > now:
|
||||||
|
_sessions[token] = {"username": username, "expire": expire}
|
||||||
if len(_sessions) != len(saved):
|
if len(_sessions) != len(saved):
|
||||||
# 有过期会话被清理, 落盘一次
|
|
||||||
_persist_sessions_locked()
|
_persist_sessions_locked()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -589,16 +589,16 @@ export const api = {
|
|||||||
|
|
||||||
// ===== Auth (访问认证) =====
|
// ===== Auth (访问认证) =====
|
||||||
authStatus: () =>
|
authStatus: () =>
|
||||||
request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'),
|
request<{ configured: boolean; authenticated: boolean; username?: string | null; role?: string | null }>('/api/auth/status'),
|
||||||
authSetup: (password: string) =>
|
authSetup: (password: string) =>
|
||||||
request<{ ok: boolean }>('/api/auth/setup', {
|
request<{ ok: boolean }>('/api/auth/setup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
}),
|
}),
|
||||||
authLogin: (password: string) =>
|
authLogin: (username: string, password: string) =>
|
||||||
request<{ ok: boolean }>('/api/auth/login', {
|
request<{ ok: boolean; username?: string }>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ username, password }),
|
||||||
}),
|
}),
|
||||||
authLogout: () =>
|
authLogout: () =>
|
||||||
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
|
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||||
@@ -607,6 +607,22 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||||||
}),
|
}),
|
||||||
|
authUsers: () =>
|
||||||
|
request<{ users: { username: string; role: string; created_at?: number }[] }>('/api/auth/users'),
|
||||||
|
authCreateUser: (username: string, password: string, role: string) =>
|
||||||
|
request<{ ok: boolean }>('/api/auth/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password, role }),
|
||||||
|
}),
|
||||||
|
authDeleteUser: (username: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
authResetPassword: (username: string, newPassword: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_password: newPassword }),
|
||||||
|
}),
|
||||||
|
|
||||||
settings: () => request<SettingsState>('/api/settings'),
|
settings: () => request<SettingsState>('/api/settings'),
|
||||||
saveTickflowKey: (api_key: string) =>
|
saveTickflowKey: (api_key: string) =>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { cn } from '@/lib/cn'
|
|||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [username, setUsername] = useState('admin')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
||||||
const [showPwd, setShowPwd] = useState(false)
|
const [showPwd, setShowPwd] = useState(false)
|
||||||
@@ -43,7 +44,7 @@ export function Auth() {
|
|||||||
if (isSetup) {
|
if (isSetup) {
|
||||||
return api.authSetup(password)
|
return api.authSetup(password)
|
||||||
}
|
}
|
||||||
return api.authLogin(password)
|
return api.authLogin(username, password)
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// 成功: 跳回原页面(或首页)
|
// 成功: 跳回原页面(或首页)
|
||||||
@@ -112,6 +113,17 @@ export function Auth() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
{/* 用户名(仅登录模式) */}
|
||||||
|
{!isSetup && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="用户名"
|
||||||
|
autoFocus
|
||||||
|
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* 密码输入 */}
|
{/* 密码输入 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -119,7 +131,7 @@ export function Auth() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
placeholder="访问密码"
|
placeholder="访问密码"
|
||||||
autoFocus
|
autoFocus={isSetup}
|
||||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Plus, Trash2, Key, Loader2, Shield, User, Check } from 'lucide-react'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { toast } from '@/components/Toast'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
export function UserManage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [newRole, setNewRole] = useState('viewer')
|
||||||
|
const [resetTarget, setResetTarget] = useState<string | null>(null)
|
||||||
|
const [resetPwd, setResetPwd] = useState('')
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['auth-users'],
|
||||||
|
queryFn: () => api.authUsers(),
|
||||||
|
})
|
||||||
|
const users = data?.users ?? []
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: () => api.authCreateUser(newUsername, newPassword, newRole),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast('用户已创建', 'success')
|
||||||
|
setShowCreate(false)
|
||||||
|
setNewUsername('')
|
||||||
|
setNewPassword('')
|
||||||
|
setNewRole('viewer')
|
||||||
|
qc.invalidateQueries({ queryKey: ['auth-users'] })
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast(err?.message || '创建失败', 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (username: string) => api.authDeleteUser(username),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast('用户已删除', 'success')
|
||||||
|
qc.invalidateQueries({ queryKey: ['auth-users'] })
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast(err?.message || '删除失败', 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetMut = useMutation({
|
||||||
|
mutationFn: () => api.authResetPassword(resetTarget!, resetPwd),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast('密码已重置', 'success')
|
||||||
|
setResetTarget(null)
|
||||||
|
setResetPwd('')
|
||||||
|
qc.invalidateQueries({ queryKey: ['auth-users'] })
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast(err?.message || '重置失败', 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="用户管理"
|
||||||
|
subtitle="管理面板访问账号"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="px-8 py-6 max-w-2xl space-y-4">
|
||||||
|
{/* 操作栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted">{users.length} 个用户</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(v => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
添加用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 创建用户表单 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCreate && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="rounded-card border border-border bg-surface p-4 space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={e => setNewUsername(e.target.value)}
|
||||||
|
placeholder="用户名"
|
||||||
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
placeholder="密码 (至少 6 位)"
|
||||||
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newRole}
|
||||||
|
onChange={e => setNewRole(e.target.value)}
|
||||||
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||||
|
>
|
||||||
|
<option value="viewer">普通用户 (viewer)</option>
|
||||||
|
<option value="admin">管理员 (admin)</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(false)}
|
||||||
|
className="rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createMut.mutate()}
|
||||||
|
disabled={createMut.isPending || !newUsername || newPassword.length < 6}
|
||||||
|
className="inline-flex items-center gap-1 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{createMut.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 用户列表 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState icon={User} title="暂无用户" hint="添加第一个用户以允许访问" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.username} className="flex items-center gap-3 rounded-card border border-border bg-surface p-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{u.username}</span>
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-1.5 py-px text-[9px] font-medium',
|
||||||
|
u.role === 'admin' ? 'bg-purple-400/10 text-purple-400' : 'bg-accent/10 text-accent',
|
||||||
|
)}>
|
||||||
|
{u.role === 'admin' ? <><Shield className="h-2.5 w-2.5 mr-0.5" />admin</> : 'viewer'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setResetTarget(u.username); setResetPwd('') }}
|
||||||
|
className="rounded p-1.5 text-muted hover:text-accent hover:bg-elevated transition-colors"
|
||||||
|
title="重置密码"
|
||||||
|
>
|
||||||
|
<Key className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{u.role !== 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`确定删除用户「${u.username}」?`)) deleteMut.mutate(u.username)
|
||||||
|
}}
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
className="rounded p-1.5 text-muted hover:text-danger hover:bg-danger/10 transition-colors disabled:opacity-50"
|
||||||
|
title="删除用户"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 重置密码弹窗 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{resetTarget && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={() => setResetTarget(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.96, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.96, opacity: 0 }}
|
||||||
|
className="w-full max-w-sm rounded-card border border-border bg-surface p-5 shadow-2xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-medium text-foreground mb-3">重置 {resetTarget} 的密码</h3>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={resetPwd}
|
||||||
|
onChange={e => setResetPwd(e.target.value)}
|
||||||
|
placeholder="新密码 (至少 6 位)"
|
||||||
|
autoFocus
|
||||||
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setResetTarget(null)}
|
||||||
|
className="rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => resetMut.mutate()}
|
||||||
|
disabled={resetMut.isPending || resetPwd.length < 6}
|
||||||
|
className="inline-flex items-center gap-1 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{resetMut.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||||
|
确认重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Review } from './pages/Review'
|
|||||||
import { LimitUpLadder } from './pages/LimitUpLadder'
|
import { LimitUpLadder } from './pages/LimitUpLadder'
|
||||||
import { Branding } from './pages/Branding'
|
import { Branding } from './pages/Branding'
|
||||||
import { AiSettings } from './pages/AiSettings'
|
import { AiSettings } from './pages/AiSettings'
|
||||||
|
import { UserManage } from './pages/UserManage'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
import { Indices } from './pages/Indices'
|
import { Indices } from './pages/Indices'
|
||||||
import { Dev } from './pages/Dev'
|
import { Dev } from './pages/Dev'
|
||||||
@@ -81,6 +82,7 @@ export const router = createBrowserRouter([
|
|||||||
// 旧路由兼容重定向
|
// 旧路由兼容重定向
|
||||||
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> },
|
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> },
|
||||||
{ path: 'settings/ai', element: <AiSettings /> },
|
{ path: 'settings/ai', element: <AiSettings /> },
|
||||||
|
{ path: 'settings/users', element: <UserManage /> },
|
||||||
{ path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
|
{ path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user