From fc69f51e232f786aa8def710af531f0687ef6a0c Mon Sep 17 00:00:00 2001 From: fish Date: Mon, 6 Jul 2026 12:49:20 +0800 Subject: [PATCH] =?UTF-8?q?serve=20=E5=A4=9A=E7=94=A8=E6=88=B7=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单密码→多用户:auth.json 格式迁移,login 支持用户名密码, 新增用户管理 API 和前端页面,会话关联 username, auth middleware 将当前用户注入 request.state。 Co-Authored-By: Claude --- serve/backend/app/api/auth.py | 134 ++++++++-- serve/backend/app/main.py | 4 +- serve/backend/app/services/auth.py | 337 +++++++++++++++++++----- serve/frontend/src/lib/api.ts | 24 +- serve/frontend/src/pages/Auth.tsx | 16 +- serve/frontend/src/pages/UserManage.tsx | 228 ++++++++++++++++ serve/frontend/src/router.tsx | 2 + 7 files changed, 649 insertions(+), 96 deletions(-) create mode 100644 serve/frontend/src/pages/UserManage.tsx diff --git a/serve/backend/app/api/auth.py b/serve/backend/app/api/auth.py index 05e7771..a3f4991 100644 --- a/serve/backend/app/api/auth.py +++ b/serve/backend/app/api/auth.py @@ -1,11 +1,15 @@ """访问认证 API。 端点: - GET /api/auth/status — 是否已设密码、当前会话是否有效 - POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占) - POST /api/auth/login — 登录(密码 → 会话 token, 含限流) - POST /api/auth/logout — 注销当前会话 - POST /api/auth/change-password — 改密码(需已登录) + GET /api/auth/status — 认证状态 + 当前用户信息 + POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占) + POST /api/auth/login — 登录(用户名+密码 → 会话 token, 含限流) + POST /api/auth/logout — 注销当前会话 + 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。 @@ -101,6 +105,25 @@ def _clear_login_fails(ip: str) -> 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): + username: str = Field(default="admin", min_length=1, max_length=64, description="用户名, 默认 admin") 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) +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") 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 { "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") def login(req: LoginIn, request: Request, response: Response) -> dict: - """登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。""" + """登录: 用户名+密码 → 会话 token(写 HttpOnly cookie)。含失败限流。""" ip = _client_ip(request) _check_login_rate_limit(ip) if not auth.is_configured(): raise HTTPException(status_code=409, detail="尚未设置访问密码") - token = auth.verify_and_create_session(req.password) - if not token: + result = auth.login(req.username, req.password) + if not result: _record_login_fail(ip) - raise HTTPException(status_code=401, detail="密码错误") + raise HTTPException(status_code=401, detail="用户名或密码错误") + token, username = result _clear_login_fails(ip) # HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效 response.set_cookie( @@ -176,7 +215,7 @@ def login(req: LoginIn, request: Request, response: Response) -> dict: path="/", secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS) ) - return {"ok": True, "authenticated": True} + return {"ok": True, "authenticated": True, "username": username} @router.post("/logout") @@ -191,23 +230,62 @@ def logout(request: Request, response: Response) -> dict: @router.post("/change-password") def change_password(req: ChangePasswordIn, request: Request) -> dict: - """修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。""" + """修改密码: 需验证旧密码。""" 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="请先登录") - if not auth.is_configured(): - 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) + if not auth.change_password(username, req.old_password, req.new_password): raise HTTPException(status_code=401, detail="旧密码错误") - # 临时 token 用完即弃 - auth.revoke_session(new_token) - # 改密码(set_password 会清空所有会话) - auth.set_password(req.new_password) - return {"ok": True, "message": "密码已修改, 请重新登录"} + 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} diff --git a/serve/backend/app/main.py b/serve/backend/app/main.py index b4cb706..19817ca 100644 --- a/serve/backend/app/main.py +++ b/serve/backend/app/main.py @@ -153,7 +153,9 @@ async def auth_middleware(request: Request, call_next): # 情况 3: 已设密码, 检查会话 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) # 未登录: 401(前端跳登录页) return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"}) diff --git a/serve/backend/app/services/auth.py b/serve/backend/app/services/auth.py index 77c7b35..ab7f6ee 100644 --- a/serve/backend/app/services/auth.py +++ b/serve/backend/app/services/auth.py @@ -1,15 +1,15 @@ -"""访问密码认证 — 单用户, 自托管场景。 +"""多用户访问密码认证 — 自托管场景。 设计: - 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。 即使 auth.json 泄露, 也无法逆向出明文密码。 - 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。 - 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。 + - 多用户: 支持多个账号, 数据共享不隔离; role=admin 可管理用户。 安全要点: - 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。 - 登录限流: 错5次锁5分钟(见 auth router 内存计数)。 - - 单密码, 不做多用户(避免重构全项目数据层)。 """ from __future__ import annotations @@ -29,13 +29,20 @@ _PBKDF2_ITER = 200_000 _SALT_LEN = 16 _TOKEN_BYTES = 32 -# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率) +# 会话有效期: 30 天(自托管, 长一点减少重登频率) SESSION_TTL = 30 * 24 * 3600 -_lock = threading.Lock() -# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。 -_sessions: dict[str, float] = {} +# schema 版本 +_CURRENT_SCHEMA_VERSION = 2 +_lock = threading.Lock() +# 内存中的有效会话: { token: {username, expire} }。进程重启后从磁盘恢复。 +_sessions: dict[str, dict] = {} + + +# ================================================================ +# 存储 +# ================================================================ def _path() -> Path: from app.config import settings @@ -63,6 +70,10 @@ def _save(data: dict) -> None: pass +# ================================================================ +# 密码工具 +# ================================================================ + def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: """返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。""" 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: - """是否已设置访问密码。""" - d = _load() - return bool(d.get("password_hash")) + """是否已有用户。""" + data = _ensure_loaded() + 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: 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) with _lock: - _sessions.clear() # 改密码 = 旧会话全部失效 - _save({ + data = _ensure_loaded() + users = data.setdefault("users", {}) + if username in users: + raise ValueError(f"用户 '{username}' 已存在") + users[username] = { "password_hash": hash_hex, "password_salt": salt_hex, - "updated_at": int(time.time()), - "sessions": {}, # 清空持久化会话 + "role": role, + "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: - """首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码。 + """首次初始化: 若环境变量 AUTH_PASSWORD 已配置且无用户, 则创建 admin 用户。 公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。 - 明文密码只在内存/配置中, 经 set_password() 哈希后写入 auth.json (chmod 0600)。 一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。 Returns: @@ -123,74 +326,86 @@ def bootstrap_from_env() -> bool: pwd = (settings.auth_password or "").strip() if not pwd: return False - if is_configured(): - # 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码) + data = _ensure_loaded() + if data.get("users"): return False try: - set_password(pwd) - logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)") + create_user("admin", pwd, role="admin") + logger.info("admin user bootstrapped from AUTH_PASSWORD env (one-time)") return True except ValueError as e: - # 密码不合规 (< 6 位), 记日志但不阻断启动 logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e) 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 +# ================================================================ +# 兼容旧版 +# ================================================================ - -def revoke_session(token: str) -> None: - """注销会话(登出)。""" +# set_password 保留为 create_user 别名(旧 api/auth.py 中可能使用) +def set_password(password: str) -> None: + """兼容旧版: 创建/更新 admin 用户密码。""" with _lock: - _sessions.pop(token, None) - _persist_sessions_locked() + data = _ensure_loaded() + 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: - """检查会话是否有效(存在且未过期)。过期则清理。""" - if not token: - 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 + """兼容旧版: 检查会话是否有效。""" + return get_session_user(token) is not None +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: """把当前内存会话写回 auth.json(需持锁调用)。""" 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) def _restore_sessions() -> None: """启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。""" with _lock: - d = _load() + data = _ensure_loaded() now = time.time() - saved = d.get("sessions") or {} - for token, expire in saved.items(): - if isinstance(expire, (int, float)) and expire > now: - _sessions[token] = expire + saved = data.get("sessions") or {} + for token, session in saved.items(): + if isinstance(session, dict): + 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): - # 有过期会话被清理, 落盘一次 _persist_sessions_locked() diff --git a/serve/frontend/src/lib/api.ts b/serve/frontend/src/lib/api.ts index ceef787..4fd71e4 100644 --- a/serve/frontend/src/lib/api.ts +++ b/serve/frontend/src/lib/api.ts @@ -589,16 +589,16 @@ export const api = { // ===== Auth (访问认证) ===== 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) => request<{ ok: boolean }>('/api/auth/setup', { method: 'POST', body: JSON.stringify({ password }), }), - authLogin: (password: string) => - request<{ ok: boolean }>('/api/auth/login', { + authLogin: (username: string, password: string) => + request<{ ok: boolean; username?: string }>('/api/auth/login', { method: 'POST', - body: JSON.stringify({ password }), + body: JSON.stringify({ username, password }), }), authLogout: () => request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }), @@ -607,6 +607,22 @@ export const api = { method: 'POST', 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('/api/settings'), saveTickflowKey: (api_key: string) => diff --git a/serve/frontend/src/pages/Auth.tsx b/serve/frontend/src/pages/Auth.tsx index 68a2686..b0ccf4b 100644 --- a/serve/frontend/src/pages/Auth.tsx +++ b/serve/frontend/src/pages/Auth.tsx @@ -20,6 +20,7 @@ import { cn } from '@/lib/cn' export function Auth() { const navigate = useNavigate() + const [username, setUsername] = useState('admin') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用 const [showPwd, setShowPwd] = useState(false) @@ -43,7 +44,7 @@ export function Auth() { if (isSetup) { return api.authSetup(password) } - return api.authLogin(password) + return api.authLogin(username, password) }, onSuccess: () => { // 成功: 跳回原页面(或首页) @@ -112,6 +113,17 @@ export function Auth() {
+ {/* 用户名(仅登录模式) */} + {!isSetup && ( + 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" + /> + )} {/* 密码输入 */}
setPassword(e.target.value)} 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" /> +
+ + {/* 创建用户表单 */} + + {showCreate && ( + +
+ 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" + /> + 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" + /> + +
+ + +
+
+
+ )} +
+ + {/* 用户列表 */} + {isLoading ? ( +
+ ) : users.length === 0 ? ( + + ) : ( +
+ {users.map(u => ( +
+
+ +
+
+
+ {u.username} + + {u.role === 'admin' ? <>admin : 'viewer'} + +
+
+ + {u.role !== 'admin' && ( + + )} +
+ ))} +
+ )} + + {/* 重置密码弹窗 */} + + {resetTarget && ( + setResetTarget(null)} + > + e.stopPropagation()} + > +

重置 {resetTarget} 的密码

+ 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" + /> +
+ + +
+
+
+ )} +
+ + + ) +} diff --git a/serve/frontend/src/router.tsx b/serve/frontend/src/router.tsx index 7e8c4c7..6df4fb6 100644 --- a/serve/frontend/src/router.tsx +++ b/serve/frontend/src/router.tsx @@ -15,6 +15,7 @@ import { Review } from './pages/Review' import { LimitUpLadder } from './pages/LimitUpLadder' import { Branding } from './pages/Branding' import { AiSettings } from './pages/AiSettings' +import { UserManage } from './pages/UserManage' import { Settings } from './pages/Settings' import { Indices } from './pages/Indices' import { Dev } from './pages/Dev' @@ -81,6 +82,7 @@ export const router = createBrowserRouter([ // 旧路由兼容重定向 { path: 'settings/keys', element: }, { path: 'settings/ai', element: }, + { path: 'settings/users', element: }, { path: 'settings/queries', element: }, ], },