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:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -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...",
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user