diff --git a/lawrisk/api/auth.py b/lawrisk/api/auth.py
index 945ce12..19afdaf 100644
--- a/lawrisk/api/auth.py
+++ b/lawrisk/api/auth.py
@@ -16,7 +16,11 @@ from flask import (
url_for,
)
-from lawrisk.services.auth_service import get_user_by_username, verify_password
+from lawrisk.services.auth_service import (
+ get_user_by_username,
+ update_user_account,
+ verify_password,
+)
auth_bp = Blueprint("auth", __name__, url_prefix="/fs-ai-asistant/api/workflow/lawrisk")
@@ -233,3 +237,103 @@ def current_user_endpoint() -> Response:
if not user:
return jsonify({"authenticated": False}), 401
return jsonify({"authenticated": True, "user": user})
+
+
+def _validate_password_complexity(password: str) -> Optional[str]:
+ """验证密码复杂度:最小8位,必须包含字母和数字"""
+ if len(password) < 8:
+ return "密码长度至少为 8 位"
+ if not (any(c.isalpha() for c in password) and any(c.isdigit() for c in password)):
+ return "密码必须同时包含字母和数字"
+ return None
+
+
+@auth_bp.post("/v2/auth/change-password")
+@login_required
+def change_password_endpoint() -> Response:
+ """Allow authenticated users to change their own password."""
+ try:
+ payload = request.get_json(silent=True) or {}
+ current_password = (payload.get("current_password", "") if payload else "").strip()
+ new_password = (payload.get("new_password", "") if payload else "").strip()
+
+ # Validate required fields
+ if not current_password:
+ return jsonify({"error": "请输入当前密码"}), 400
+ if not new_password:
+ return jsonify({"error": "请输入新密码"}), 400
+
+ # Get current user from session
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "用户未登录"}), 401
+
+ print(f"DEBUG: Session user = {user}")
+
+ # Get complete user data including password hash
+ username = user.get("username")
+ if not username:
+ return jsonify({"error": "无效的用户信息"}), 400
+
+ # 使用 get_user_by_username 获取完整用户数据(包括密码哈希)
+ user_data = get_user_by_username(username)
+ if not user_data:
+ return jsonify({"error": "用户不存在"}), 404
+
+ # Debug logging
+ print(f"DEBUG: username = {username}")
+ print(f"DEBUG: password_hash exists = {'password_hash' in user_data}")
+ print(f"DEBUG: password_hash length = {len(user_data.get('password_hash', ''))}")
+ print(f"DEBUG: current_password provided = {bool(current_password)}")
+
+ # Verify current password
+ password_hash = user_data.get("password_hash", "")
+ if not password_hash:
+ print("ERROR: password_hash is empty!")
+ return jsonify({"error": "系统错误:无法获取密码哈希"}), 500
+
+ if not verify_password(current_password, password_hash):
+ print("ERROR: Password verification failed!")
+ print(f"DEBUG: Hash preview: {password_hash[:20]}..." if len(password_hash) > 20 else f"DEBUG: Hash: {password_hash}")
+ return jsonify({"error": "当前密码错误"}), 401
+
+ print("DEBUG: Password verified successfully!")
+
+ # Get user_id for update operation
+ user_id = user_data.get("id")
+ if not user_id:
+ return jsonify({"error": "无法获取用户ID"}), 400
+
+ # Validate new password complexity
+ complexity_error = _validate_password_complexity(new_password)
+ if complexity_error:
+ return jsonify({"error": complexity_error}), 400
+
+ # Check if new password is same as current
+ if verify_password(new_password, user_data.get("password_hash", "")):
+ return jsonify({"error": "新密码不能与当前密码相同"}), 400
+
+ # Update password
+ print(f"DEBUG: Attempting to update password for user_id = {user_id}")
+ update_result = update_user_account(
+ user_id=user_id,
+ password=new_password,
+ )
+ print(f"DEBUG: Update result = {update_result}")
+
+ if not update_result:
+ print("ERROR: Password update returned None/False")
+ return jsonify({"error": "密码修改失败,请稍后重试"}), 500
+
+ # Note: update_user_account already logs the operation internally
+ print("DEBUG: Password change completed successfully")
+ return jsonify({"message": "密码修改成功"}), 200
+
+ except Exception as e:
+ # Log full error details
+ import traceback
+ print(f"ERROR: Exception in change_password_endpoint")
+ print(f"ERROR: Type: {type(e).__name__}")
+ print(f"ERROR: Message: {str(e)}")
+ print(f"ERROR: Traceback:\n{traceback.format_exc()}")
+ return jsonify({"error": "密码修改失败,请稍后重试"}), 500
diff --git a/lawrisk/services/auth_service.py b/lawrisk/services/auth_service.py
index 83a6db3..431c663 100644
--- a/lawrisk/services/auth_service.py
+++ b/lawrisk/services/auth_service.py
@@ -363,6 +363,7 @@ def get_user_by_id(user_id: str) -> Optional[Dict[str, Any]]:
au.role,
au.grade,
au.is_active,
+ au.password_hash,
au.service_department_id,
au.department_role,
au.created_at,
diff --git a/static/db_admin.html b/static/db_admin.html
index 10e62f7..6a71b7f 100644
--- a/static/db_admin.html
+++ b/static/db_admin.html
@@ -1089,6 +1089,30 @@
background: #5568d3;
}
+ .btn-change-password {
+ background: white;
+ color: #374151;
+ padding: 6px 16px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ }
+
+ .btn-change-password:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ background: #f9fafb;
+ border-color: #9ca3af;
+ }
+
+ .btn-change-password:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
.btn-secondary {
background: #e2e8f0;
color: #334155;
@@ -1215,6 +1239,32 @@
animation: modalFadeIn 0.3s;
}
+ .password-strength-indicator {
+ margin-top: 8px;
+ font-size: 12px;
+ color: #6b7280;
+ }
+
+ .password-strength-indicator.weak { color: #ef4444; }
+ .password-strength-indicator.medium { color: #f59e0b; }
+ .password-strength-indicator.strong { color: #10b981; }
+
+ .password-error {
+ color: #ef4444;
+ font-size: 12px;
+ margin-top: 5px;
+ display: none;
+ }
+
+ .password-error.show {
+ display: block;
+ }
+
+ input.error {
+ border-color: #ef4444 !important;
+ background-color: #fef2f2;
+ }
+
.import-modal-content {
width: 1200px;
max-width: 98vw;
@@ -2453,6 +2503,7 @@
+
@@ -3005,6 +3056,267 @@
});
}
+ // 密码修改相关
+ function openPasswordModal() {
+ const passwordModal = document.getElementById('passwordModal');
+ if (!passwordModal) return;
+
+ passwordModal.classList.add('show');
+ document.body.style.overflow = 'hidden';
+ document.getElementById('passwordChangeForm').reset();
+ clearPasswordErrors();
+ updatePasswordStrength('');
+
+ // 绑定模态框内的事件
+ bindPasswordModalEvents();
+
+ setTimeout(() => document.getElementById('currentPassword').focus(), 100);
+ }
+
+ function closePasswordModal() {
+ const passwordModal = document.getElementById('passwordModal');
+ if (!passwordModal) return;
+
+ passwordModal.classList.remove('show');
+ document.body.style.overflow = '';
+ document.getElementById('passwordChangeForm').reset();
+ clearPasswordErrors();
+ updatePasswordStrength('');
+ }
+
+ function clearPasswordErrors() {
+ document.querySelectorAll('.password-error').forEach(el => {
+ el.textContent = '';
+ el.classList.remove('show');
+ });
+
+ // 清除输入框的错误样式
+ document.querySelectorAll('#passwordChangeForm input').forEach(input => {
+ input.classList.remove('error');
+ });
+ }
+
+ function updatePasswordStrength(password) {
+ const indicator = document.getElementById('passwordStrength');
+ if (!password) {
+ indicator.textContent = '';
+ indicator.className = 'password-strength-indicator';
+ return;
+ }
+ let strength = 0;
+ if (password.length >= 8) strength++;
+ if (password.length >= 12) strength++;
+ if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
+ if (/\d/.test(password)) strength++;
+ if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) strength++;
+
+ if (strength <= 2) {
+ indicator.textContent = '密码强度:弱';
+ indicator.className = 'password-strength-indicator weak';
+ } else if (strength <= 3) {
+ indicator.textContent = '密码强度:中';
+ indicator.className = 'password-strength-indicator medium';
+ } else {
+ indicator.textContent = '密码强度:强';
+ indicator.className = 'password-strength-indicator strong';
+ }
+ }
+
+ function validatePasswordForm() {
+ console.log('=== validatePasswordForm called ===');
+ let isValid = true;
+ const errors = []; // 收集所有错误消息
+
+ clearPasswordErrors();
+
+ const currentPassword = document.getElementById('currentPassword').value.trim();
+ const newPassword = document.getElementById('newPassword').value.trim();
+ const confirmPassword = document.getElementById('confirmPassword').value.trim();
+
+ console.log('currentPassword:', currentPassword ? '***' : '(empty)');
+ console.log('newPassword:', newPassword ? '***' : '(empty)');
+ console.log('confirmPassword:', confirmPassword ? '***' : '(empty)');
+
+ if (!currentPassword) {
+ console.log('Validation failed: current password is empty');
+ showError('currentPasswordError', '请输入当前密码');
+ errors.push('请输入当前密码');
+ isValid = false;
+ }
+
+ if (!newPassword) {
+ console.log('Validation failed: new password is empty');
+ showError('newPasswordError', '请输入新密码');
+ errors.push('请输入新密码');
+ isValid = false;
+ } else if (newPassword.length < 8) {
+ console.log('Validation failed: new password too short');
+ showError('newPasswordError', '新密码长度至少为 8 位');
+ errors.push('新密码长度至少为 8 位');
+ isValid = false;
+ } else if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(newPassword)) {
+ console.log('Validation failed: new password complexity');
+ showError('newPasswordError', '新密码必须同时包含字母和数字');
+ errors.push('新密码必须同时包含字母和数字');
+ isValid = false;
+ } else if (newPassword === currentPassword) {
+ console.log('Validation failed: passwords are the same');
+ showError('newPasswordError', '新密码不能与当前密码相同');
+ errors.push('新密码不能与当前密码相同');
+ isValid = false;
+ }
+
+ if (!confirmPassword) {
+ console.log('Validation failed: confirm password is empty');
+ showError('confirmPasswordError', '请确认新密码');
+ errors.push('请确认新密码');
+ isValid = false;
+ } else if (newPassword !== confirmPassword) {
+ console.log('Validation failed: passwords do not match');
+ showError('confirmPasswordError', '两次输入的密码不一致');
+ errors.push('两次输入的密码不一致');
+ isValid = false;
+ }
+
+ console.log('Validation result:', isValid);
+ console.log('Errors collected:', errors);
+
+ // 如果有错误,在控制台和 alert 中显示
+ if (!isValid && errors.length > 0) {
+ const errorMessage = errors.join('\n• ');
+ console.log('Showing error alert:', errorMessage);
+ // 延迟一点显示 alert,确保 DOM 更新
+ setTimeout(() => {
+ alert('❌ 表单验证失败:\n\n• ' + errorMessage);
+ }, 100);
+ }
+
+ console.log('=== validatePasswordForm end ===');
+ return isValid;
+ }
+
+ function showError(elementId, message) {
+ console.log(`showError called: ${elementId} = "${message}"`);
+ const el = document.getElementById(elementId);
+ if (!el) {
+ console.error(`Element not found: ${elementId}`);
+ alert(`错误:${message}`); // 降级到 alert
+ return;
+ }
+ el.textContent = message;
+ el.classList.add('show');
+
+ // 给对应的输入框添加错误样式
+ const inputId = elementId.replace('Error', '');
+ const inputEl = document.getElementById(inputId);
+ if (inputEl) {
+ inputEl.classList.add('error');
+ }
+
+ console.log(`Error shown successfully for ${elementId}`);
+ }
+
+ async function handleSubmitPasswordChange() {
+ console.log('handleSubmitPasswordChange called');
+
+ if (!validatePasswordForm()) {
+ console.log('Form validation failed');
+ // validatePasswordForm 已经显示了详细的错误消息
+ return;
+ }
+
+ console.log('Form validation passed');
+
+ const currentPassword = document.getElementById('currentPassword').value.trim();
+ const newPassword = document.getElementById('newPassword').value.trim();
+ const submitPasswordChange = document.getElementById('submitPasswordChange');
+
+ if (!submitPasswordChange) {
+ console.error('Submit button not found');
+ return;
+ }
+
+ console.log('Starting password change request...');
+
+ submitPasswordChange.disabled = true;
+ submitPasswordChange.textContent = '提交中...';
+
+ try {
+ const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/v2/auth/change-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ current_password: currentPassword,
+ new_password: newPassword
+ })
+ });
+
+ console.log('Response status:', response.status);
+ const data = await response.json();
+ console.log('Response data:', data);
+
+ if (response.ok) {
+ alert('✅ 密码修改成功!');
+ closePasswordModal();
+ } else {
+ alert(`❌ 密码修改失败:${data.error || '未知错误'}`);
+ }
+ } catch (error) {
+ console.error('Password change error:', error);
+ alert('❌ 密码修改失败,请稍后重试');
+ } finally {
+ submitPasswordChange.disabled = false;
+ submitPasswordChange.textContent = '确认修改';
+ }
+ }
+
+ // 事件绑定
+ const changePasswordBtn = document.getElementById('changePasswordBtn');
+
+ console.log('changePasswordBtn:', changePasswordBtn);
+
+ if (changePasswordBtn) {
+ changePasswordBtn.addEventListener('click', function(e) {
+ console.log('changePasswordBtn clicked');
+ openPasswordModal();
+ });
+ }
+
+ // 延迟绑定模态框内的事件(因为模态框在页面底部)
+ function bindPasswordModalEvents() {
+ const submitPasswordChange = document.getElementById('submitPasswordChange');
+ console.log('bindPasswordModalEvents - submitPasswordChange:', submitPasswordChange);
+
+ if (submitPasswordChange && !submitPasswordChange.hasAttribute('data-bound')) {
+ submitPasswordChange.addEventListener('click', function(e) {
+ console.log('submitPasswordChange clicked');
+ e.preventDefault();
+ e.stopPropagation();
+ handleSubmitPasswordChange();
+ });
+ submitPasswordChange.setAttribute('data-bound', 'true');
+ console.log('submitPasswordChange event bound');
+ }
+ }
+
+ document.getElementById('newPassword')?.addEventListener('input', (e) => {
+ updatePasswordStrength(e.target.value);
+ });
+
+ document.addEventListener('click', (e) => {
+ const passwordModal = document.getElementById('passwordModal');
+ if (passwordModal && e.target === passwordModal) {
+ closePasswordModal();
+ }
+ });
+
+ document.addEventListener('keydown', (e) => {
+ const passwordModal = document.getElementById('passwordModal');
+ if (e.key === 'Escape' && passwordModal && passwordModal.classList.contains('show')) {
+ closePasswordModal();
+ }
+ });
+
// 导航状态管理
let currentStep = 1; // 1=区划, 2=事项, 3=详情
let historyStack = []; // 历史记录栈
@@ -8118,6 +8430,40 @@
}
});
+
+
+