diff --git a/modules/user_manager.py b/modules/user_manager.py index 20f439c..120eef6 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -543,6 +543,19 @@ class UserManager: self._save_users() return record + def reset_password(self, username: str, new_password: str) -> UserRecord: + """重置用户密码。管理员操作。""" + key = (username or "").strip().lower() + password = (new_password or "").strip() + if not password or len(password) < 8: + raise ValueError("密码长度至少 8 位。") + record = self._users.get(key) + if not record: + raise ValueError("用户不存在。") + record.password_hash = generate_password_hash(password) + self._save_users() + return record + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ diff --git a/server/admin.py b/server/admin.py index 9befe0b..9c8c9cd 100644 --- a/server/admin.py +++ b/server/admin.py @@ -294,6 +294,33 @@ def admin_invites_api(): return jsonify({"success": False, "error": str(exc)}), 500 +@admin_bp.route('/api/admin/users//reset-password', methods=['POST']) +@api_login_required +@admin_api_required +def admin_reset_user_password(username: str): + """管理员重置用户密码。""" + guard = _secondary_required() + if guard: + return guard + if not username: + return jsonify({"success": False, "error": "缺少用户名"}), 400 + payload = request.get_json(silent=True) or {} + new_password = (payload.get("password") or "").strip() + if not new_password: + return jsonify({"success": False, "error": "新密码不能为空"}), 400 + try: + record = user_manager.reset_password(username, new_password) + return jsonify({ + "success": True, + "data": {"username": record.username, "message": "密码已重置"} + }) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + logging.exception("reset-password API error") + return jsonify({"success": False, "error": str(exc)}), 500 + + def build_admin_dashboard_snapshot(): """ 复用对话模块的完整统计逻辑,返回带用量/Token/存储/上传等数据的仪表盘快照。 diff --git a/static/src/admin/AdminDashboardApp.vue b/static/src/admin/AdminDashboardApp.vue index 0c8aa6e..8ae9811 100644 --- a/static/src/admin/AdminDashboardApp.vue +++ b/static/src/admin/AdminDashboardApp.vue @@ -380,6 +380,86 @@ +
+

密码管理

+

管理员可重置任意用户的登录密码(包括自己)。

+ +
+
+ +
+
+ + {{ passwordTargetUser }} + {{ selectedUserEmail }} + + 搜索用户名或邮箱... + +
+
+ +
+
+ {{ user.username }} + {{ user.email || '—' }} + 管理员 +
+
+ 无匹配用户 +
+
+
+
+
+
+ + +
+
+ + +
+

{{ passwordResult }}

+

{{ passwordError }}

+
+
@@ -398,7 +478,7 @@ import { useSecondaryPass } from './useSecondaryPass'; type Snapshot = Record | null; -type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites'; +type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites' | 'passwords'; const { verified: secondaryVerified, @@ -426,6 +506,15 @@ const inviteError = ref(null); const newInviteCode = ref(''); const newInviteRemaining = ref('1'); const newInviteUnlimited = ref(false); +const passwordTargetUser = ref(''); +const passwordNewValue = ref(''); +const passwordSubmitting = ref(false); +const passwordResult = ref(''); +const passwordError = ref(''); +const passwordSearchQuery = ref(''); +const passwordDropdownOpen = ref(false); +const userSearchRef = ref(null); +const passwordSearchInputRef = ref(null); const sectionTabs: Array<{ id: SectionId; label: string }> = [ { id: 'overview', label: '系统总览' }, @@ -434,7 +523,8 @@ const sectionTabs: Array<{ id: SectionId; label: string }> = [ { id: 'containers', label: '容器' }, { id: 'uploads', label: '上传审计' }, { id: 'balance', label: '余额查询' }, - { id: 'invites', label: '邀请码' } + { id: 'invites', label: '邀请码' }, + { id: 'passwords', label: '密码管理' } ]; const isInitialLoading = computed(() => loading.value && !snapshot.value); @@ -566,6 +656,80 @@ const createInvite = async () => { } }; +const handleResetPassword = async () => { + const username = passwordTargetUser.value.trim(); + const password = passwordNewValue.value.trim(); + if (!username || !password) return; + if (password.length < 8) { + passwordError.value = '密码长度至少 8 位'; + passwordResult.value = ''; + return; + } + passwordSubmitting.value = true; + passwordResult.value = ''; + passwordError.value = ''; + try { + const csrfToken = await fetchCsrfToken(); + const resp = await fetch(`/api/admin/users/${encodeURIComponent(username)}/reset-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ password }) + }); + const payload = await resp.json(); + if (!resp.ok || !payload.success) { + throw new Error(payload.error || `请求失败:${resp.status}`); + } + passwordResult.value = `用户 ${username} 的密码已成功重置`; + passwordNewValue.value = ''; + } catch (error) { + passwordError.value = error instanceof Error ? error.message : String(error); + } finally { + passwordSubmitting.value = false; + } +}; + +const filteredUsers = computed(() => { + const query = passwordSearchQuery.value.trim().toLowerCase(); + if (!query) return users.value; + return users.value.filter( + (u: any) => + u.username.toLowerCase().includes(query) || + (u.email || '').toLowerCase().includes(query) + ); +}); + +const selectedUserEmail = computed(() => { + const user = users.value.find((u: any) => u.username === passwordTargetUser.value); + return user?.email || ''; +}); + +const togglePasswordDropdown = () => { + passwordDropdownOpen.value = !passwordDropdownOpen.value; + if (passwordDropdownOpen.value) { + passwordSearchQuery.value = ''; + // 聚焦搜索框 + setTimeout(() => { + passwordSearchInputRef.value?.focus(); + }, 50); + } +}; + +const selectPasswordUser = (user: any) => { + passwordTargetUser.value = user.username; + passwordSearchQuery.value = ''; + passwordDropdownOpen.value = false; +}; + +const handleClickOutside = (event: MouseEvent) => { + if (userSearchRef.value && !userSearchRef.value.contains(event.target as Node)) { + passwordDropdownOpen.value = false; + } +}; + const editInvite = async (item: any) => { const current = item?.remaining; const seed = current === null || typeof current === 'undefined' ? 'unlimited' : String(current); @@ -665,6 +829,7 @@ watch( ); onMounted(async () => { + document.addEventListener('click', handleClickOutside); await checkSecondary(); if (secondaryVerified.value) { fetchDashboard(false); @@ -686,6 +851,7 @@ watch(secondaryVerified, (val) => { }); onBeforeUnmount(() => { + document.removeEventListener('click', handleClickOutside); if (timer) { clearInterval(timer); } @@ -1566,4 +1732,228 @@ th { text-align: center; } } + +.password-form { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 480px; + margin-top: 12px; +} + +.password-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.password-field label { + font-size: 13px; + color: var(--claude-text-secondary); + font-weight: 600; +} + +.password-select, +.password-input { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(44, 32, 19, 0.2); + font-size: 14px; + background: rgba(255, 255, 255, 0.92); +} + +.password-input { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(44, 32, 19, 0.2); + font-size: 14px; + background: rgba(255, 255, 255, 0.92); +} + +/* 自定义用户搜索下拉 */ +.user-search-wrapper { + position: relative; +} + +.user-search-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(44, 32, 19, 0.2); + background: rgba(255, 255, 255, 0.92); + cursor: pointer; + min-height: 44px; + box-sizing: border-box; + transition: border-color 0.15s ease; +} + +.user-search-trigger:hover { + border-color: rgba(44, 32, 19, 0.4); +} + +.user-search-trigger.open { + border-color: rgba(189, 93, 58, 0.5); + border-radius: 12px 12px 0 0; +} + +.user-search-trigger.filled { + border-color: rgba(44, 32, 19, 0.35); +} + +.user-search-selected { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.user-search-selected strong { + font-size: 14px; + flex-shrink: 0; +} + +.user-search-selected .user-search-email { + font-size: 12px; + color: var(--claude-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-search-placeholder { + color: #9a8b7a; + font-size: 14px; +} + +.user-search-arrow { + font-size: 12px; + color: var(--claude-text-secondary); + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.user-search-trigger.open .user-search-arrow { + transform: rotate(180deg); +} + +.user-search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #fff; + border: 1px solid rgba(189, 93, 58, 0.5); + border-top: none; + border-radius: 0 0 12px 12px; + z-index: 100; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); + overflow: hidden; +} + +.user-search-input { + width: 100%; + box-sizing: border-box; + padding: 10px 14px; + border: none; + border-bottom: 1px solid rgba(118, 103, 84, 0.15); + font-size: 13px; + outline: none; + background: rgba(255, 255, 255, 0.95); +} + +.user-search-input::placeholder { + color: #b0a392; +} + +.user-search-list { + max-height: 220px; + overflow-y: auto; +} + +.user-search-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + cursor: pointer; + transition: background 0.1s ease; +} + +.user-search-item:hover { + background: rgba(218, 119, 86, 0.08); +} + +.user-search-item.active { + background: rgba(218, 119, 86, 0.14); +} + +.user-search-name { + font-size: 14px; + font-weight: 600; + flex-shrink: 0; +} + +.user-search-item .user-search-email { + font-size: 12px; + color: var(--claude-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-search-role { + font-size: 11px; + padding: 2px 6px; + border-radius: 6px; + background: rgba(189, 93, 58, 0.15); + color: #7c3418; + flex-shrink: 0; + margin-left: auto; +} + +.user-search-empty { + padding: 16px; + text-align: center; + color: var(--claude-text-secondary); + font-size: 13px; +} + +.password-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.password-actions .ghost-btn { + background: transparent; + border: 1px dashed rgba(44, 32, 19, 0.35); + padding: 10px 14px; + border-radius: 12px; + cursor: pointer; + box-shadow: none; + color: var(--claude-text-secondary); +} + +.password-actions .ghost-btn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + transform: none; +} + +.password-success { + color: var(--claude-success); + margin: 0; + font-weight: 600; +} + +.password-error-msg { + color: #b5473d; + margin: 0; +}