Add password validation with i18n support and GitHub button improvements

- Add password field to UserUpdate schema for password change on edit
- Implement password validation in update_user endpoint
- Add i18n translations for password validation errors (ru/en)
- Update SecurityService.validate_password to support language parameter
- Add GitHub icon and repository name 'pvemanager' to header button
- Fix internal server error when updating user password
- Clean up hardcoded translations in frontend
This commit is contained in:
2025-12-15 20:09:50 +05:00
parent 52ec402c03
commit 2b78d93710
15 changed files with 326 additions and 22 deletions

View File

@@ -317,7 +317,8 @@ def register_user(
)
# Validate password
is_valid, errors = SecurityService.validate_password(db, user_data.password)
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -446,7 +447,8 @@ def change_password(
)
# Validate new password
is_valid, errors = SecurityService.validate_password(db, new_password)
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, new_password, lang)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -93,6 +93,7 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, min_length=1, max_length=100)
role_id: Optional[int] = None
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
@@ -383,7 +384,8 @@ async def create_user(
raise HTTPException(status_code=400, detail="Email already exists")
# Validate password
is_valid, errors = SecurityService.validate_password(db, user_data.password)
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
@@ -467,6 +469,15 @@ async def update_user(
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.password is not None:
# Validate password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
user.hashed_password = get_password_hash(user_data.password)
user.last_password_change = utcnow()
if user_data.role_id is not None:
role = db.query(Role).filter(Role.id == user_data.role_id).first()
if not role:
@@ -588,7 +599,8 @@ async def reset_user_password(
raise HTTPException(status_code=404, detail="User not found")
# Validate password
is_valid, errors = SecurityService.validate_password(db, password_data.new_password)
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, password_data.new_password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))

View File

@@ -3940,6 +3940,28 @@ class I18nService:
"en": "Require Special Characters"
},
# Password validation errors
"password_error_min_length": {
"ru": "Пароль должен быть не менее {min_length} символов",
"en": "Password must be at least {min_length} characters"
},
"password_error_uppercase": {
"ru": "Пароль должен содержать хотя бы одну заглавную букву",
"en": "Password must contain at least one uppercase letter"
},
"password_error_lowercase": {
"ru": "Пароль должен содержать хотя бы одну строчную букву",
"en": "Password must contain at least one lowercase letter"
},
"password_error_number": {
"ru": "Пароль должен содержать хотя бы одну цифру",
"en": "Password must contain at least one number"
},
"password_error_special": {
"ru": "Пароль должен содержать хотя бы один спецсимвол",
"en": "Password must contain at least one special character"
},
# IP Blacklist
"ip_blacklist": {
"ru": "Чёрный список IP",

View File

@@ -22,6 +22,9 @@ class UserUpdate(BaseModel):
full_name: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, min_length=6, max_length=100)
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
role_id: Optional[int] = None
require_password_change: Optional[bool] = None
class UserResponse(UserBase):

View File

@@ -568,11 +568,13 @@ class SecurityService:
# ==================== Password Validation ====================
@classmethod
def validate_password(cls, db: Session, password: str) -> Tuple[bool, list]:
def validate_password(cls, db: Session, password: str, lang: str = "en") -> Tuple[bool, list]:
"""
Validate password against security requirements.
Returns (is_valid, list_of_errors)
"""
from ..i18n import t
errors = []
min_length = cls.get_setting_int(db, "password_min_length", 8)
@@ -582,18 +584,18 @@ class SecurityService:
require_special = cls.get_setting_bool(db, "password_require_special", False)
if len(password) < min_length:
errors.append(f"Password must be at least {min_length} characters")
errors.append(t("password_error_min_length", lang, min_length=min_length))
if require_upper and not any(c.isupper() for c in password):
errors.append("Password must contain at least one uppercase letter")
errors.append(t("password_error_uppercase", lang))
if require_lower and not any(c.islower() for c in password):
errors.append("Password must contain at least one lowercase letter")
errors.append(t("password_error_lowercase", lang))
if require_numbers and not any(c.isdigit() for c in password):
errors.append("Password must contain at least one number")
errors.append(t("password_error_number", lang))
if require_special and not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
errors.append("Password must contain at least one special character")
errors.append(t("password_error_special", lang))
return len(errors) == 0, errors

View File

@@ -19,6 +19,12 @@ VERSION_FILE = os.path.join(PROJECT_ROOT, "VERSION")
# Для fallback - путь внутри контейнера где может быть VERSION
CONTAINER_VERSION_FILE = "/app/VERSION"
# GitHub token для доступа к приватным репозиториям (опционально)
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
# Отключить проверку обновлений (для приватных репозиториев без токена)
DISABLE_UPDATE_CHECK = os.environ.get('DISABLE_UPDATE_CHECK', 'false').lower() == 'true'
# Статус обновления (in-memory)
update_status = {
"is_updating": False,
@@ -139,9 +145,16 @@ async def check_for_updates() -> Dict[str, Any]:
"changelog": None,
"error": None,
"git_available": is_git_available(),
"project_mounted": is_project_mounted()
"project_mounted": is_project_mounted(),
"disabled": DISABLE_UPDATE_CHECK
}
# Если проверка обновлений отключена
if DISABLE_UPDATE_CHECK:
result["error"] = "Update check is disabled"
result["latest_version"] = current_version
return result
# Получаем URL репозитория
repo_url = None
if result["project_mounted"]:
@@ -175,25 +188,45 @@ async def check_for_updates() -> Dict[str, Any]:
owner, repo = "markmorado", "pvemanager"
try:
# Подготавливаем заголовки для запроса
headers = {}
if GITHUB_TOKEN:
headers["Authorization"] = f"token {GITHUB_TOKEN}"
# Используем GitHub API для получения файлов
async with httpx.AsyncClient(timeout=30.0) as client:
# Получить VERSION из main ветки
version_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/VERSION"
version_response = await client.get(version_url)
# Пробуем оба варианта: main и master
version_url = None
version_response = None
if version_response.status_code == 200:
for branch in ["main", "master"]:
test_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/VERSION"
test_response = await client.get(test_url, headers=headers)
if test_response.status_code == 200:
version_url = test_url
version_response = test_response
break
if version_response and version_response.status_code == 200:
result["latest_version"] = version_response.text.strip()
elif version_response and version_response.status_code == 404:
result["error"] = "Repository is private or not accessible. Set DISABLE_UPDATE_CHECK=true to hide this error."
result["latest_version"] = current_version
return result
else:
result["error"] = f"Failed to fetch VERSION file: HTTP {version_response.status_code}"
result["error"] = f"Failed to fetch VERSION file: HTTP {version_response.status_code if version_response else 'unknown'}"
result["latest_version"] = current_version
return result
# Сравнить версии
if result["latest_version"] != current_version:
result["update_available"] = True
# Получить CHANGELOG.md
changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/CHANGELOG.md"
changelog_response = await client.get(changelog_url)
# Получить CHANGELOG.md (используем ту же ветку, что и для VERSION)
branch = version_url.split("/")[-2] if version_url else "main"
changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/CHANGELOG.md"
changelog_response = await client.get(changelog_url, headers=headers)
if changelog_response.status_code == 200:
# Извлекаем только последнюю версию из changelog

View File

@@ -549,6 +549,55 @@ header h2 {
display: block;
}
/* GitHub Button */
.github-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: var(--bg-surface);
color: var(--text-primary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition-fast);
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.github-btn:hover {
background: var(--bg-surface-elevated);
color: var(--text-primary);
border-color: var(--text-muted);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.github-star-icon {
flex-shrink: 0;
opacity: 0.8;
}
.github-btn:hover .github-star-icon {
opacity: 1;
color: #fbbf24;
}
.github-text {
font-weight: 500;
}
.github-stars {
font-variant-numeric: tabular-nums;
min-width: 30px;
text-align: center;
font-weight: 600;
padding-left: 8px;
border-left: 1px solid var(--border-default);
}
/* ============================================
Main Content
============================================ */
@@ -1625,6 +1674,21 @@ table tr:hover {
width: 36px;
height: 36px;
}
/* Compact GitHub button on very small screens */
.github-btn {
padding: 7px 10px;
gap: 6px;
}
.github-text {
display: none;
}
.github-stars {
font-size: 0.75rem;
padding-left: 6px;
}
}
/* ============================================

View File

@@ -32,6 +32,15 @@ const translations = {
language_changed: "Язык изменён. Страница будет перезагружена...",
error_occurred: "Произошла ошибка",
confirm_delete: "Вы уверены, что хотите удалить?",
// Updates
update_check_disabled: "Проверка обновлений отключена",
checking_updates: "Проверка обновлений...",
check_updates: "Проверить обновления",
update_available: "Доступно обновление",
no_updates: "Установлена последняя версия",
update_warning: "Панель будет недоступна во время обновления",
updating_system: "Обновление системы...",
},
en: {
@@ -62,6 +71,15 @@ const translations = {
language_changed: "Language changed. Page will reload...",
error_occurred: "An error occurred",
confirm_delete: "Are you sure you want to delete?",
// Updates
update_check_disabled: "Update check is disabled",
checking_updates: "Checking for updates...",
check_updates: "Check for Updates",
update_available: "Update available",
no_updates: "You have the latest version",
update_warning: "Panel will be unavailable during update",
updating_system: "Updating system...",
}
};

View File

@@ -610,6 +610,16 @@ body.update-in-progress .layout {
</button>
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
<div class="header-actions">
<!-- GitHub Stars Button -->
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on GitHub">
<i class="fa-brands fa-github"></i>
<span class="github-text">pvemanager</span>
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
</svg>
<span class="github-stars" id="githubStars">...</span>
</a>
<!-- Theme Toggle Button -->
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -851,6 +861,36 @@ body.update-in-progress .layout {
}
});
// Load GitHub stars count
async function loadGitHubStars() {
try {
const response = await fetch('https://api.github.com/repos/markmorado/pvemanager', {
headers: {
'Accept': 'application/vnd.github.v3+json'
}
});
if (response.ok) {
const data = await response.json();
const starsElement = document.getElementById('githubStars');
if (starsElement) {
// Format number with K for thousands
const stars = data.stargazers_count;
const formatted = stars >= 1000
? (stars / 1000).toFixed(1) + 'K'
: stars.toString();
starsElement.textContent = formatted;
}
}
} catch (error) {
console.error('Failed to load GitHub stars:', error);
const starsElement = document.getElementById('githubStars');
if (starsElement) {
starsElement.textContent = '★';
}
}
}
// Load unread notification count
async function loadNotificationCount() {
try {
@@ -1268,6 +1308,9 @@ body.update-in-progress .layout {
// Refresh count every 30 seconds
setInterval(loadNotificationCount, 30000);
// Load GitHub stars count
loadGitHubStars();
}
// Add click handlers for sidebar menu items to update page title

View File

@@ -715,6 +715,12 @@
color: var(--danger);
}
.update-status-box.warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.update-changelog-box {
margin-bottom: var(--spacing-lg);
}
@@ -1797,9 +1803,16 @@ async function checkForUpdates() {
if (data.error) {
statusBox.style.display = 'block';
statusBox.className = 'update-status-box error';
statusBox.querySelector('#update-status-message').innerHTML =
'<i class="fas fa-exclamation-circle"></i> ' + data.error;
// Если проверка отключена, показываем warning вместо ошибки
if (data.disabled || data.error.includes('disabled')) {
statusBox.className = 'update-status-box warning';
statusBox.querySelector('#update-status-message').innerHTML =
'<i class="fas fa-info-circle"></i> ' + (t('update_check_disabled') || 'Проверка обновлений отключена');
} else {
statusBox.className = 'update-status-box error';
statusBox.querySelector('#update-status-message').innerHTML =
'<i class="fas fa-exclamation-circle"></i> ' + data.error;
}
updateBtn.style.display = 'none';
changelogBox.style.display = 'none';
} else if (data.update_available) {