From 48db28b8fb29465ec91fec00c06b39c002c10968 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 5 Dec 2025 06:58:45 -0600 Subject: [PATCH] Add local user login alongside Microsoft SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show both Microsoft SSO and local login options on login page - Add admin user management: create local users with password - Add admin password reset for local users only - Add self-service password change on profile page for local users - Display auth provider (Local/SSO) in admin user table - New API endpoints: POST /api/admin/users, POST /api/admin/users/{email}/reset-password, POST /api/users/change-password - SSO and local auth remain separate (no dual-auth) - Minimum 8 character password requirement 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crud.py | 64 +++++++ main.py | 80 ++++++++- models.py | 15 ++ templates/admin/dashboard.html | 294 +++++++++++++++++++++++++++++++-- templates/login.html | 84 +++++++--- templates/profile.html | 124 ++++++++++++++ 6 files changed, 618 insertions(+), 43 deletions(-) diff --git a/crud.py b/crud.py index 04b9b3b..127a69d 100644 --- a/crud.py +++ b/crud.py @@ -329,6 +329,70 @@ async def delete_user(user_id: str): except: return False +async def admin_create_local_user(email: str, password: str, full_name: str = None, is_admin: bool = False): + """Create a new local user (admin only)""" + # Check if user already exists + existing = await get_user_by_email(email) + if existing: + raise ValueError("User with this email already exists") + + # Validate password length + if len(password) < 8: + raise ValueError("Password must be at least 8 characters") + + return await create_user( + email=email, + password=password, + full_name=full_name, + is_admin=is_admin, + auth_provider="local" + ) + +async def admin_reset_user_password(user_id: str, new_password: str): + """Reset password for a local user (admin only)""" + user = await get_user_by_id(user_id) + if not user: + raise ValueError("User not found") + + if user.get("auth_provider") != "local": + raise ValueError("Cannot reset password for SSO users") + + if len(new_password) < 8: + raise ValueError("Password must be at least 8 characters") + + hashed = hash_password(new_password) + result = await users_collection.update_one( + {"_id": ObjectId(user_id)}, + {"$set": {"hashed_password": hashed, "updated_at": datetime.utcnow()}} + ) + return result.modified_count > 0 + +async def change_user_password(user_id: str, current_password: str, new_password: str): + """Change password for current user (requires current password verification)""" + user = await get_user_by_id(user_id) + if not user: + raise ValueError("User not found") + + if user.get("auth_provider") != "local": + raise ValueError("Cannot change password for SSO users") + + if not user.get("hashed_password"): + raise ValueError("User has no password set") + + # Verify current password + if not verify_password(current_password, user["hashed_password"]): + raise ValueError("Current password is incorrect") + + if len(new_password) < 8: + raise ValueError("New password must be at least 8 characters") + + hashed = hash_password(new_password) + result = await users_collection.update_one( + {"_id": ObjectId(user_id)}, + {"$set": {"hashed_password": hashed, "updated_at": datetime.utcnow()}} + ) + return result.modified_count > 0 + async def get_agent_by_name(agent_name: str): """Get agent by exact name match globally""" return await agents_collection.find_one({"agent_name": agent_name}) diff --git a/main.py b/main.py index c295d34..138d3a5 100644 --- a/main.py +++ b/main.py @@ -933,14 +933,15 @@ async def toggle_admin_view(request: Request, current_user: dict = Depends(get_c # Admin endpoints @app.get("/api/admin/users", response_model=List[models.UserResponse]) async def get_all_users(current_user: dict = Depends(require_admin)): - + users = await crud.get_all_users() return [ models.UserResponse( email=user["email"], full_name=user.get("full_name"), is_active=user["is_active"], - is_admin=user["is_admin"] + is_admin=user["is_admin"], + auth_provider=user.get("auth_provider", "local") ) for user in users ] @@ -962,9 +963,82 @@ async def update_user(email: str, user_update: models.UserUpdate, current_user: email=updated_user["email"], full_name=updated_user.get("full_name"), is_active=updated_user["is_active"], - is_admin=updated_user["is_admin"] + is_admin=updated_user["is_admin"], + auth_provider=updated_user.get("auth_provider", "local") ) +@app.post("/api/admin/users", response_model=models.UserResponse) +async def admin_create_user( + user_data: models.AdminUserCreate, + current_user: dict = Depends(require_admin) +): + """Create a new local user (admin only)""" + try: + created_user = await crud.admin_create_local_user( + email=user_data.email, + password=user_data.password, + full_name=user_data.full_name, + is_admin=user_data.is_admin + ) + return models.UserResponse( + email=created_user["email"], + full_name=created_user.get("full_name"), + is_active=created_user["is_active"], + is_admin=created_user["is_admin"], + auth_provider=created_user.get("auth_provider", "local") + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/admin/users/{email}/reset-password") +async def admin_reset_password( + email: str, + password_data: models.AdminPasswordReset, + current_user: dict = Depends(require_admin) +): + """Reset password for a local user (admin only)""" + user = await crud.get_user_by_email(email) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.get("auth_provider") != "local": + raise HTTPException( + status_code=400, + detail="Cannot reset password for SSO users. This user authenticates via Microsoft." + ) + + try: + success = await crud.admin_reset_user_password(str(user["_id"]), password_data.new_password) + if success: + return {"message": "Password reset successfully"} + raise HTTPException(status_code=500, detail="Failed to reset password") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/users/change-password") +async def change_password( + password_data: models.PasswordChange, + current_user: dict = Depends(get_current_user_from_cookie) +): + """Change password for current logged-in user""" + if current_user.get("auth_provider") != "local": + raise HTTPException( + status_code=400, + detail="Cannot change password for SSO users. Your account uses Microsoft authentication." + ) + + try: + success = await crud.change_user_password( + str(current_user["_id"]), + password_data.current_password, + password_data.new_password + ) + if success: + return {"message": "Password changed successfully"} + raise HTTPException(status_code=500, detail="Failed to change password") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + @app.get("/api/admin/agents", response_model=List[models.AiAgentResponse]) async def get_all_agents_admin(current_user: dict = Depends(require_admin)): diff --git a/models.py b/models.py index 1a768ad..bf675eb 100644 --- a/models.py +++ b/models.py @@ -50,6 +50,7 @@ class UserResponse(BaseModel): full_name: Optional[str] = None is_active: bool is_admin: bool + auth_provider: Optional[str] = "local" class UserUpdate(BaseModel): full_name: Optional[str] = None @@ -60,6 +61,20 @@ class Token(BaseModel): access_token: str token_type: str = "bearer" +# Admin user management models +class AdminUserCreate(BaseModel): + email: EmailStr + full_name: Optional[str] = None + password: str = Field(..., min_length=8) + is_admin: bool = False + +class AdminPasswordReset(BaseModel): + new_password: str = Field(..., min_length=8) + +class PasswordChange(BaseModel): + current_password: str + new_password: str = Field(..., min_length=8) + # Agent models for creation and response class AiAgentCreate(BaseModel): agent_name: str diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index a72c2ab..3ea73d2 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -95,10 +95,15 @@
-
Agent Management
-
- - +
Users Management
+
+ +
+ + +
@@ -108,10 +113,10 @@ User Email + Auth Type Status Agents - Created Actions @@ -188,6 +193,7 @@
+ + + + + + ${user.email} + + + ${isLocalAuth ? 'Local' : 'SSO'} + + ${user.is_admin ? 'Admin' : 'User'} @@ -641,23 +759,20 @@ function displayUsers(users) { ${userAgents} - Recently - - - - - `; }).join(''); - + tbody.innerHTML = usersHtml; } @@ -786,14 +901,30 @@ async function viewAgentDetails(agentId) { function editUser(email) { const user = allUsers.find(u => u.email === email); if (!user) return; - + + const authProvider = user.auth_provider || 'local'; + const isLocalAuth = authProvider === 'local'; + // Populate the edit form document.getElementById('editUserId').value = user.email; document.getElementById('editUserEmail').value = user.email; document.getElementById('editUserFullName').value = user.full_name || ''; document.getElementById('editUserIsActive').checked = user.is_active; document.getElementById('editUserIsAdmin').checked = user.is_admin; - + document.getElementById('editUserAuthProvider').value = authProvider; + + // Update auth display + const authDisplay = document.getElementById('editUserAuthDisplay'); + if (isLocalAuth) { + authDisplay.innerHTML = 'Local (Email/Password)'; + } else { + authDisplay.innerHTML = 'Microsoft SSO'; + } + + // Show/hide password reset section based on auth provider + const passwordResetSection = document.getElementById('passwordResetSection'); + passwordResetSection.style.display = isLocalAuth ? 'block' : 'none'; + // Show the modal const modal = new bootstrap.Modal(document.getElementById('editUserModal')); modal.show(); @@ -1076,5 +1207,134 @@ function logout() { localStorage.removeItem('access_token'); window.location.href = '{{ base_path }}/'; } + +// Create User Functions +function showCreateUserModal() { + // Clear the form + document.getElementById('createUserForm').reset(); + + // Show the modal + const modal = new bootstrap.Modal(document.getElementById('createUserModal')); + modal.show(); +} + +async function handleCreateUserSubmit(e) { + e.preventDefault(); + + const email = document.getElementById('createUserEmail').value; + const fullName = document.getElementById('createUserFullName').value; + const password = document.getElementById('createUserPassword').value; + const passwordConfirm = document.getElementById('createUserPasswordConfirm').value; + const isAdmin = document.getElementById('createUserIsAdmin').checked; + + // Validate passwords match + if (password !== passwordConfirm) { + showError('Passwords do not match'); + return; + } + + // Validate password length + if (password.length < 8) { + showError('Password must be at least 8 characters'); + return; + } + + try { + const response = await fetch('{{ base_path }}/api/admin/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ + email: email, + full_name: fullName || null, + password: password, + is_admin: isAdmin + }) + }); + + if (response.ok) { + // Hide the modal + const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal')); + modal.hide(); + + // Reload data and show success + await loadAdminData(); + showSuccess('User created successfully'); + } else { + const error = await response.json(); + showError(error.detail || 'Failed to create user'); + } + } catch (error) { + showError('Failed to create user'); + } +} + +// Reset Password Functions +function showResetPasswordModal() { + const email = document.getElementById('editUserId').value; + + // Set the email in the reset password modal + document.getElementById('resetPasswordEmail').value = email; + document.getElementById('resetPasswordUserEmail').textContent = email; + + // Clear password fields + document.getElementById('newPassword').value = ''; + document.getElementById('newPasswordConfirm').value = ''; + + // Hide edit modal and show reset password modal + const editModal = bootstrap.Modal.getInstance(document.getElementById('editUserModal')); + editModal.hide(); + + const resetModal = new bootstrap.Modal(document.getElementById('resetPasswordModal')); + resetModal.show(); +} + +async function handleResetPasswordSubmit(e) { + e.preventDefault(); + + const email = document.getElementById('resetPasswordEmail').value; + const newPassword = document.getElementById('newPassword').value; + const newPasswordConfirm = document.getElementById('newPasswordConfirm').value; + + // Validate passwords match + if (newPassword !== newPasswordConfirm) { + showError('Passwords do not match'); + return; + } + + // Validate password length + if (newPassword.length < 8) { + showError('Password must be at least 8 characters'); + return; + } + + try { + const response = await fetch(`{{ base_path }}/api/admin/users/${encodeURIComponent(email)}/reset-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ + new_password: newPassword + }) + }); + + if (response.ok) { + // Hide the modal + const modal = bootstrap.Modal.getInstance(document.getElementById('resetPasswordModal')); + modal.hide(); + + showSuccess('Password reset successfully'); + } else { + const error = await response.json(); + showError(error.detail || 'Failed to reset password'); + } + } catch (error) { + showError('Failed to reset password'); + } +} {% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index a2220e2..0d7aadc 100644 --- a/templates/login.html +++ b/templates/login.html @@ -16,6 +16,20 @@

Sign in to your account

+ {% if error %} + + {% endif %} + + {% if success %} + + {% endif %} + {% if msal_enabled %}
@@ -27,45 +41,44 @@
- {% if show_local_login %}
- or continue with email + or sign in with email
{% endif %} - {% endif %} - {% if show_local_login %}
+ {% if msal_enabled %}

- - Administrator & Development Access + + For partner organizations without Microsoft SSO

- + {% endif %} +
-
- +
-
- -
-

- Local login is for administrators and development use only -

-
- {% endif %}
@@ -157,6 +163,38 @@ border-color: var(--primary-color); } +.auth-divider { + display: flex; + align-items: center; + text-align: center; +} + +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #dee2e6; +} + +.auth-divider-text { + padding: 0 1rem; + color: #6c757d; + font-size: 0.875rem; +} + +.btn-microsoft { + background-color: #fff; + border: 1px solid #dee2e6; + color: #333; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-microsoft:hover { + background-color: #f8f9fa; + border-color: #c1c1c1; +} @media (max-width: 576px) { .card-body { diff --git a/templates/profile.html b/templates/profile.html index 2b8b4f2..cef5f19 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -61,6 +61,18 @@ {{ current_user.created_at.strftime('%B %Y') if current_user.created_at else 'Unknown' }} +
+
+ Authentication Method + + {% if current_user.auth_provider == 'azure_ad' %} + Microsoft SSO + {% else %} + Local (Email/Password) + {% endif %} + +
+
@@ -69,6 +81,55 @@ + + {% if current_user.auth_provider != 'azure_ad' %} +
+
+
+
+
Change Password
+
+
+
+
+
+
+ + +
+
+ + +
Password must be at least 8 characters.
+
+
+ + +
+ + +
+
+
+
Password Tips
+
    +
  • Use at least 8 characters
  • +
  • Include a mix of letters, numbers, and symbols
  • +
  • Avoid using personal information
  • +
  • Don't reuse passwords from other sites
  • +
+
+
+
+
+
+
+
+
+ {% endif %} +
@@ -230,8 +291,71 @@ let userAgents = []; document.addEventListener('DOMContentLoaded', function() { loadUserStats(); loadRecentActivity(); + + // Set up password change form handler if it exists + const changePasswordForm = document.getElementById('changePasswordForm'); + if (changePasswordForm) { + changePasswordForm.addEventListener('submit', handlePasswordChange); + } }); +async function handlePasswordChange(e) { + e.preventDefault(); + + const currentPassword = document.getElementById('currentPassword').value; + const newPassword = document.getElementById('newPassword').value; + const confirmNewPassword = document.getElementById('confirmNewPassword').value; + const alertDiv = document.getElementById('passwordChangeAlert'); + + // Reset alert + alertDiv.classList.add('d-none'); + alertDiv.classList.remove('alert-success', 'alert-danger'); + + // Validate passwords match + if (newPassword !== confirmNewPassword) { + showPasswordAlert('New passwords do not match', 'danger'); + return; + } + + // Validate password length + if (newPassword.length < 8) { + showPasswordAlert('New password must be at least 8 characters', 'danger'); + return; + } + + try { + const response = await fetch('{{ base_path }}/api/users/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }); + + if (response.ok) { + showPasswordAlert('Password changed successfully!', 'success'); + // Clear the form + document.getElementById('changePasswordForm').reset(); + } else { + const error = await response.json(); + showPasswordAlert(error.detail || 'Failed to change password', 'danger'); + } + } catch (error) { + showPasswordAlert('Failed to change password. Please try again.', 'danger'); + } +} + +function showPasswordAlert(message, type) { + const alertDiv = document.getElementById('passwordChangeAlert'); + alertDiv.textContent = message; + alertDiv.classList.remove('d-none', 'alert-success', 'alert-danger'); + alertDiv.classList.add(`alert-${type}`); +} + async function loadUserStats() { try { const response = await fetch('{{ base_path }}/api/agents', {