serve 多用户认证体系

单密码→多用户:auth.json 格式迁移,login 支持用户名密码,
新增用户管理 API 和前端页面,会话关联 username,
auth middleware 将当前用户注入 request.state。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 12:49:20 +08:00
parent 22049582df
commit fc69f51e23
7 changed files with 649 additions and 96 deletions
+106 -28
View File
@@ -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}
+3 -1
View File
@@ -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": "未登录或会话已过期"})
+276 -61
View File
@@ -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()
+20 -4
View File
@@ -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<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
+14 -2
View File
@@ -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() {
</div>
<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">
<input
@@ -119,7 +131,7 @@ export function Auth() {
value={password}
onChange={e => 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"
/>
<button
+228
View File
@@ -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>
</>
)
}
+2
View File
@@ -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: <Navigate to="/settings?tab=account" replace /> },
{ path: 'settings/ai', element: <AiSettings /> },
{ path: 'settings/users', element: <UserManage /> },
{ path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
],
},