@@ -525,6 +525,43 @@ def serialize_datetime(obj):
525525 return obj
526526
527527
528+ def validate_password_strength(password: str) -> tuple[bool, str]:
529+ """Validate password meets strength requirements.
530+
531+ Uses configurable settings from config.py for password policy.
532+
533+ Args:
534+ password: Password to validate
535+
536+ Returns:
537+ tuple: (is_valid, error_message)
538+ """
539+ min_length = getattr(settings, "password_min_length", 8)
540+ require_uppercase = getattr(settings, "password_require_uppercase", False)
541+ require_lowercase = getattr(settings, "password_require_lowercase", False)
542+ require_numbers = getattr(settings, "password_require_numbers", False)
543+ require_special = getattr(settings, "password_require_special", False)
544+
545+ if len(password) < min_length:
546+ return False, f"Password must be at least {min_length} characters long"
547+
548+ if require_uppercase and not any(c.isupper() for c in password):
549+ return False, "Password must contain at least one uppercase letter (A-Z)"
550+
551+ if require_lowercase and not any(c.islower() for c in password):
552+ return False, "Password must contain at least one lowercase letter (a-z)"
553+
554+ if require_numbers and not any(c.isdigit() for c in password):
555+ return False, "Password must contain at least one number (0-9)"
556+
557+ # Match the special character set used in EmailAuthService
558+ special_chars = '!@#$%^&*(),.?":{}|<>'
559+ if require_special and not any(c in special_chars for c in password):
560+ return False, f"Password must contain at least one special character ({special_chars})"
561+
562+ return True, ""
563+
564+
528565admin_router = APIRouter(prefix="/admin", tags=["Admin UI"])
529566
530567####################
@@ -4734,17 +4771,24 @@ async def admin_create_user(
47344771
47354772 form = await request.form()
47364773
4774+ # Validate password strength
4775+ password = str(form.get("password", ""))
4776+ if password:
4777+ is_valid, error_msg = validate_password_strength(password)
4778+ if not is_valid:
4779+ return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400)
4780+
47374781 # First-Party
47384782
47394783 auth_service = EmailAuthService(db)
47404784
47414785 # Create new user
47424786 new_user = await auth_service.create_user(
4743- email=str(form.get("email", "")), password=str(form.get(" password", "")) , full_name=str(form.get("full_name", "")), is_admin=form.get("is_admin") == "on", auth_provider="local"
4787+ email=str(form.get("email", "")), password=password, full_name=str(form.get("full_name", "")), is_admin=form.get("is_admin") == "on", auth_provider="local"
47444788 )
47454789
47464790 # If the user was created with the default password, force password change
4747- if str(form.get(" password", "")) == settings.default_user_password.get_secret_value(): # nosec B105
4791+ if password == settings.default_user_password.get_secret_value(): # nosec B105
47484792 new_user.password_change_required = True
47494793 db.commit()
47504794
@@ -4847,7 +4891,7 @@ async def admin_get_user_edit(
48474891 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password (leave empty to keep current)</label>
48484892 <input type="password" name="password" id="password-field"
48494893 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"
4850- oninput="validatePasswordMatch()">
4894+ oninput="validatePasswordRequirements(); validatePasswordMatch(); ">
48514895 </div>
48524896 <div>
48534897 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
@@ -4856,6 +4900,109 @@ async def admin_get_user_edit(
48564900 oninput="validatePasswordMatch()">
48574901 <div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div>
48584902 </div>
4903+ <!-- Password Requirements -->
4904+ <div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
4905+ <div class="flex items-start">
4906+ <svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
4907+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
4908+ </svg>
4909+ <div class="ml-3 flex-1">
4910+ <h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
4911+ <div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
4912+ <div class="flex items-center" id="req-length">
4913+ <span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
4914+ <span>At least {settings.password_min_length} characters long</span>
4915+ </div>
4916+ {'<div class="flex items-center" id="req-uppercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains uppercase letters (A-Z)</span></div>' if settings.password_require_uppercase else ''}
4917+ {'<div class="flex items-center" id="req-lowercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains lowercase letters (a-z)</span></div>' if settings.password_require_lowercase else ''}
4918+ {'<div class="flex items-center" id="req-numbers"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains numbers (0-9)</span></div>' if settings.password_require_numbers else ''}
4919+ {'<div class="flex items-center" id="req-special"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains special characters (!@#$%^&*(),.?":{{}}|<>)</span></div>' if settings.password_require_special else ''}
4920+ </div>
4921+ </div>
4922+ </div>
4923+ </div>
4924+
4925+ <script>
4926+ // Password policy settings injected from backend
4927+ const passwordPolicy = {{
4928+ minLength: {settings.password_min_length},
4929+ requireUppercase: {'true' if settings.password_require_uppercase else 'false'},
4930+ requireLowercase: {'true' if settings.password_require_lowercase else 'false'},
4931+ requireNumbers: {'true' if settings.password_require_numbers else 'false'},
4932+ requireSpecial: {'true' if settings.password_require_special else 'false'}
4933+ }};
4934+
4935+ function updateRequirementIcon(elementId, isValid) {{
4936+ const req = document.getElementById(elementId);
4937+ if (req) {{
4938+ const icon = req.querySelector('span');
4939+ if (isValid) {{
4940+ icon.className = 'inline-flex items-center justify-center w-4 h-4 bg-green-500 text-white rounded-full text-xs mr-2';
4941+ icon.textContent = '✓';
4942+ }} else {{
4943+ icon.className = 'inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2';
4944+ icon.textContent = '✗';
4945+ }}
4946+ }}
4947+ }}
4948+
4949+ function validatePasswordRequirements() {{
4950+ const password = document.getElementById('password-field')?.value || '';
4951+
4952+ // Check length requirement (always required)
4953+ const lengthCheck = password.length >= passwordPolicy.minLength;
4954+ updateRequirementIcon('req-length', lengthCheck);
4955+
4956+ // Check uppercase requirement (if enabled)
4957+ const uppercaseCheck = !passwordPolicy.requireUppercase || /[A-Z]/.test(password);
4958+ updateRequirementIcon('req-uppercase', /[A-Z]/.test(password));
4959+
4960+ // Check lowercase requirement (if enabled)
4961+ const lowercaseCheck = !passwordPolicy.requireLowercase || /[a-z]/.test(password);
4962+ updateRequirementIcon('req-lowercase', /[a-z]/.test(password));
4963+
4964+ // Check numbers requirement (if enabled)
4965+ const numbersCheck = !passwordPolicy.requireNumbers || /[0-9]/.test(password);
4966+ updateRequirementIcon('req-numbers', /[0-9]/.test(password));
4967+
4968+ // Check special character requirement (if enabled) - matches backend set
4969+ const specialCheck = !passwordPolicy.requireSpecial || /[!@#$%^&*(),.?":{{}}|<>]/.test(password);
4970+ updateRequirementIcon('req-special', /[!@#$%^&*(),.?":{{}}|<>]/.test(password));
4971+
4972+ // Enable/disable submit button based on active requirements
4973+ const submitButton = document.querySelector('#user-edit-modal-content button[type="submit"]');
4974+ const allRequirementsMet = lengthCheck && uppercaseCheck && lowercaseCheck && numbersCheck && specialCheck;
4975+ const passwordEmpty = password.length === 0;
4976+
4977+ if (submitButton) {{
4978+ // Allow submission if password is empty (keep current) or if all requirements are met
4979+ if (passwordEmpty || allRequirementsMet) {{
4980+ submitButton.disabled = false;
4981+ submitButton.className = 'px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500';
4982+ }} else {{
4983+ submitButton.disabled = true;
4984+ submitButton.className = 'px-4 py-2 text-sm font-medium text-white bg-gray-400 border border-transparent rounded-md cursor-not-allowed';
4985+ }}
4986+ }}
4987+ }}
4988+
4989+ function validatePasswordMatch() {{
4990+ const password = document.getElementById('password-field')?.value || '';
4991+ const confirmPassword = document.getElementById('confirm-password-field')?.value || '';
4992+ const matchMessage = document.getElementById('password-match-message');
4993+
4994+ if (password && confirmPassword && password !== confirmPassword) {{
4995+ matchMessage?.classList.remove('hidden');
4996+ }} else {{
4997+ matchMessage?.classList.add('hidden');
4998+ }}
4999+ }}
5000+
5001+ // Initialize validation on page load
5002+ document.addEventListener('DOMContentLoaded', function() {{
5003+ validatePasswordRequirements();
5004+ }});
5005+ </script>
48595006 <div class="flex justify-end space-x-3">
48605007 <button type="button" onclick="hideUserEditModal()"
48615008 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
@@ -4927,8 +5074,15 @@ async def admin_update_user(
49275074 fn_val = form.get("full_name")
49285075 pw_val = form.get("password")
49295076 full_name = fn_val if isinstance(fn_val, str) else None
4930- password = pw_val if isinstance(pw_val, str) else None
4931- await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, password=password if password else None)
5077+ password = pw_val.strip() if isinstance(pw_val, str) and pw_val.strip() else None
5078+
5079+ # Validate password if provided
5080+ if password:
5081+ is_valid, error_msg = validate_password_strength(password)
5082+ if not is_valid:
5083+ return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400)
5084+
5085+ await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, password=password)
49325086
49335087 # Return success message with auto-close and refresh
49345088 success_html = """
0 commit comments