gateway/modules/routes/routeDataUsers.py
2026-04-26 08:31:35 +02:00

866 lines
35 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
User routes for the backend API.
Implements the endpoints for user management.
MULTI-TENANT: User management requires RequestContext.
- mandateId from X-Mandate-Id header determines which users are visible
- isPlatformAdmin can see all users across mandates
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
from typing import List, Dict, Any, Optional
from fastapi import status
from pydantic import BaseModel
import logging
import json
# Import interfaces and models
import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.auth import limiter, getRequestContext, RequestContext
# Import the attribute definition and helper functions
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.i18nRegistry import apiRouteContext
from modules.routes.routeHelpers import enrichRowsWithFkLabels
routeApiMsg = apiRouteContext("routeDataUsers")
# Configure logger
logger = logging.getLogger(__name__)
def _usersToDicts(items) -> list:
return [u.model_dump() if hasattr(u, "model_dump") else (dict(u) if not isinstance(u, dict) else u) for u in items]
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
"""
Check if the current user has admin rights for the target user.
PlatformAdmin can manage all users. MandateAdmin can manage users in their mandates.
Works without X-Mandate-Id header (admin pages don't send it).
"""
if context.isPlatformAdmin:
return True
# Find mandates where current user is admin
rootInterface = getRootInterface()
userId = str(context.user.id)
userMandates = rootInterface.getUserMandates(userId)
adminMandateIds = []
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
return False
# Check if target user is in any of the admin's mandates
targetMandates = rootInterface.getUserMandates(targetUserId)
for tm in targetMandates:
if str(getattr(tm, 'mandateId', '')) in adminMandateIds:
return True
return False
def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False):
"""Unified handler for mode=filterValues and mode=ids across all user scoping branches."""
from modules.routes.routeHelpers import (
handleFilterValuesInMemory, handleIdsInMemory,
handleFilterValuesMode, handleIdsMode,
parseCrossFilterPagination,
)
try:
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
requestLang = getattr(context.user, "language", None)
if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
if idsMode:
return handleIdsInMemory(items, paginationJson)
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
if context.isPlatformAdmin:
rootInterface = getRootInterface()
if idsMode:
return handleIdsMode(rootInterface.db, UserInDB, paginationJson)
crossPagination = parseCrossFilterPagination(column, paginationJson)
try:
from fastapi.responses import JSONResponse
values = rootInterface.db.getDistinctColumnValues(UserInDB, column, crossPagination)
return JSONResponse(content=sorted(values, key=lambda v: v.lower()))
except Exception:
users = appInterface.getAllUsers()
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
return []
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
uniqueUserIds = list({
(um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
for um in (allUM or [])
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
})
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
items = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
if idsMode:
return handleIdsInMemory(items, paginationJson)
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in _getUserFilterOrIds: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
router = APIRouter(
prefix="/api/users",
tags=["Manage Users"],
responses={404: {"description": "Not found"}}
)
# ============================================================================
# OPTIONS ENDPOINTS (for dropdowns)
# ============================================================================
@router.get("/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
def get_user_options(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get user options for select dropdowns.
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
Returns standardized format: [{ value, label }]
"""
try:
appInterface = interfaceDbApp.getInterface(context.user)
if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result.items if hasattr(result, 'items') else result
elif context.isPlatformAdmin:
users = appInterface.getAllUsers()
else:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
return [
{"value": user.id, "label": user.fullName or user.username or user.email or user.id}
for user in users
]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting user options: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get user options: {str(e)}")
# ============================================================================
# CRUD ENDPOINTS
# ============================================================================
@router.get("/")
@limiter.limit("30/minute")
def get_users(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
context: RequestContext = Depends(getRequestContext)
):
"""
Get users with optional pagination, sorting, and filtering.
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
SysAdmin without mandateId sees all users.
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/users/ (no pagination - returns all users in mandate)
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
"""
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
return _getUserFilterOrIds(context, pagination, column=column)
if mode == "ids":
return _getUserFilterOrIds(context, pagination, idsMode=True)
try:
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
if context.mandateId:
# Get users for specific mandate using getUsersByMandate
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
if paginationParams and hasattr(result, 'items'):
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
return {
"items": enriched,
"pagination": PaginationMetadata(
currentPage=result.currentPage,
pageSize=result.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
else:
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
enriched = enrichRowsWithFkLabels(_usersToDicts(users), User)
return {"items": enriched, "pagination": None}
elif context.isPlatformAdmin:
# PlatformAdmin without mandateId — DB-level pagination via interface
result = appInterface.getAllUsers(paginationParams)
if paginationParams and hasattr(result, 'items'):
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
return {
"items": enriched,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
else:
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
enriched = enrichRowsWithFkLabels(_usersToDicts(users), User)
return {"items": enriched, "pagination": None}
else:
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
# Find mandates where user has admin role
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("No admin access to any mandate")
)
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
uniqueUserIds = list({
(um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
for um in (allUM or [])
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
})
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
allUsers = [
u.model_dump() if hasattr(u, 'model_dump') else vars(u)
for u in batchUsers.values()
]
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams)
enriched = enrichRowsWithFkLabels(filteredUsers, User)
if paginationParams:
import math
totalItems = len(enriched)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
return {
"items": enriched[startIdx:endIdx],
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
else:
return {"items": enriched, "pagination": None}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting users: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get users: {str(e)}"
)
@router.get("/{userId}", response_model=User)
@limiter.limit("30/minute")
def get_user(
request: Request,
userId: str = Path(..., description="ID of the user"),
context: RequestContext = Depends(getRequestContext)
) -> User:
"""
Get a specific user by ID.
MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin.
"""
try:
appInterface = interfaceDbApp.getInterface(context.user)
# Get user without filtering by enabled status
user = appInterface.getUser(userId)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin)
if context.mandateId and not context.isPlatformAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("User not in your mandate")
)
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting user {userId}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get user: {str(e)}"
)
class CreateUserRequest(BaseModel):
"""Request body for creating a new user"""
username: str
email: Optional[str] = None
fullName: Optional[str] = None
language: str = "de"
enabled: bool = True
isSysAdmin: bool = False
isPlatformAdmin: bool = False
password: Optional[str] = None
@router.post("", response_model=User)
@limiter.limit("10/minute")
def create_user(
request: Request,
userData: CreateUserRequest = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> User:
"""
Create a new user.
MULTI-TENANT: User is created and automatically added to the current mandate.
Privileged platform flags (isSysAdmin, isPlatformAdmin) may only be set
by a Platform Admin. Non-PlatformAdmin requests have these flags reset
to False with a warning.
"""
appInterface = interfaceDbApp.getInterface(context.user)
callerIsPlatformAdmin = context.isPlatformAdmin
requestedSysAdmin = bool(userData.isSysAdmin) and callerIsPlatformAdmin
requestedPlatformAdmin = bool(userData.isPlatformAdmin) and callerIsPlatformAdmin
if (userData.isSysAdmin or userData.isPlatformAdmin) and not callerIsPlatformAdmin:
logger.warning(
f"Non-PlatformAdmin {context.user.id} attempted to create user with "
f"privileged flags (isSysAdmin={userData.isSysAdmin}, "
f"isPlatformAdmin={userData.isPlatformAdmin}); flags reset to False"
)
newUser = appInterface.createUser(
username=userData.username,
password=userData.password,
email=userData.email,
fullName=userData.fullName,
language=userData.language,
enabled=userData.enabled,
authenticationAuthority=AuthAuthority.LOCAL,
isSysAdmin=requestedSysAdmin,
isPlatformAdmin=requestedPlatformAdmin,
)
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
if context.mandateId:
userRole = appInterface.getRoleByLabel("user")
if not userRole:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("No 'user' role found in system — cannot assign user to mandate")
)
appInterface.createUserMandate(
userId=str(newUser.id),
mandateId=str(context.mandateId),
roleIds=[str(userRole.id)]
)
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
return newUser
@router.put("/{userId}", response_model=User)
@limiter.limit("10/minute")
def update_user(
request: Request,
userId: str = Path(..., description="ID of the user to update"),
userData: Dict[str, Any] = Body(..., description="Partial user payload — only the fields present in the request body are updated."),
context: RequestContext = Depends(getRequestContext)
) -> User:
"""
Update an existing user (PARTIAL update).
The request body is treated as a **partial** patch: only the keys actually
sent are applied; missing keys leave the stored value untouched. This is
intentional — sending a full ``User`` body would overwrite unrelated fields
(e.g. ``isSysAdmin``/``isPlatformAdmin``) with Pydantic defaults whenever a
client only ships a subset, which has historically caused privileged flags
to flip silently when toggling a single inline cell.
Self-service: Users can update their own profile (language, fullName, etc.).
Admin: MandateAdmin can update users in their mandates.
PlatformAdmin can update any user.
Privileged flag changes (isSysAdmin, isPlatformAdmin) require:
- caller has isPlatformAdmin=True, AND
- target is NOT the caller (Self-Protection).
"""
isSelfUpdate = str(context.user.id) == str(userId)
if not isSelfUpdate and not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to update other users")
)
rootInterface = getRootInterface()
existingUser = rootInterface.getUser(userId)
if not existingUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
if not isinstance(userData, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("User update payload must be a JSON object")
)
# Defensive: drop ``id`` from payload — userId comes from the path and
# tampering with it from the body must never silently rebind the row.
sanitizedPayload = {k: v for k, v in userData.items() if k != "id"}
callerIsPlatformAdmin = context.isPlatformAdmin
allowAdminFlagChange = callerIsPlatformAdmin and not isSelfUpdate
updatedUser = rootInterface.updateUser(
userId, sanitizedPayload, allowAdminFlagChange=allowAdminFlagChange
)
if not updatedUser:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("Error updating the user")
)
return updatedUser
@router.post("/{userId}/reset-password")
@limiter.limit("5/minute")
def reset_user_password(
request: Request,
userId: str = Path(..., description="ID of the user to reset password for"),
newPassword: str = Body(..., embed=True),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Reset user password (Admin only).
MULTI-TENANT: MandateAdmin can reset passwords for users in their mandates. SysAdmin for all.
"""
try:
# Check admin permission (SysAdmin or MandateAdmin for this user)
if not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to reset passwords")
)
# Get user interface
appInterface = interfaceDbApp.getInterface(context.user)
# Validate password strength
if len(newPassword) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("Password must be at least 8 characters long")
)
# Reset password
success = appInterface.resetUserPassword(userId, newPassword)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("Failed to reset password")
)
# SECURITY: Automatically revoke all tokens for the user after password reset
try:
revoked_count = appInterface.revokeTokensByUser(
userId=userId,
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=context.user.id,
reason="password_reset"
)
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
except Exception as e:
logger.error(f"Failed to revoke tokens after password reset for user {userId}: {str(e)}")
# Don't fail the password reset if token revocation fails
# Log password reset
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_reset",
details=f"Reset password for user {userId}",
ipAddress=request.client.host if request.client else None,
success=True
)
except Exception:
pass
return {
"message": "Password reset successfully",
"user_id": userId
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resetting password: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password reset failed: {str(e)}"
)
@router.post("/change-password")
@limiter.limit("5/minute")
def change_password(
request: Request,
currentPassword: str = Body(..., embed=True),
newPassword: str = Body(..., embed=True),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Change current user's password.
MULTI-TENANT: User changes their own password (no mandate restriction).
"""
try:
# Get user interface
appInterface = interfaceDbApp.getInterface(context.user)
# Verify current password
if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=routeApiMsg("Current password is incorrect")
)
# Validate new password strength
if len(newPassword) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("New password must be at least 8 characters long")
)
# Change password
success = appInterface.resetUserPassword(str(context.user.id), newPassword)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("Failed to change password")
)
# SECURITY: Automatically revoke all tokens for the user after password change
try:
revoked_count = appInterface.revokeTokensByUser(
userId=str(context.user.id),
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=context.user.id,
reason="password_change"
)
logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change")
except Exception as e:
logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}")
# Don't fail the password change if token revocation fails
# Log password change
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_change",
details="User changed their own password",
ipAddress=request.client.host if request.client else None,
success=True
)
except Exception:
pass
return {
"message": "Password changed successfully. Please log in again with your new password."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error changing password: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password change failed: {str(e)}"
)
@router.post("/{userId}/send-password-link")
@limiter.limit("10/minute")
def send_password_link(
request: Request,
userId: str = Path(..., description="ID of the user to send password setup link"),
frontendUrl: str = Body(..., embed=True),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Send password setup/reset link to a user (admin function).
MULTI-TENANT: MandateAdmin can send to users in their mandates. SysAdmin for all.
This allows admins to send a magic link to users to set or reset their password.
Used when creating users without password or to help users who forgot their password.
Args:
userId: ID of the user to send the link to
frontendUrl: The frontend URL to use in the magic link
"""
try:
from modules.shared.configuration import APP_CONFIG
# Check admin permission (SysAdmin or MandateAdmin for this user)
if not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to send password links")
)
# Get user interface
appInterface = interfaceDbApp.getInterface(context.user)
# Get target user
targetUser = appInterface.getUser(userId)
if not targetUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=routeApiMsg("User not found")
)
# Check if user has an email
if not targetUser.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("User has no email address configured")
)
# Use root interface for token operations
rootInterface = getRootInterface()
# Generate reset token
token, expires = rootInterface.generateResetTokenAndExpiry()
# Set reset token (don't clear password - user might have one already)
rootInterface.setResetToken(userId, token, expires, clearPassword=False)
# Send email with magic link
baseUrl = frontendUrl.rstrip("/")
magicLink = f"{baseUrl}/reset?token={token}"
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try:
from modules.routes.routeSecurityLocal import buildAuthEmailHtml, sendAuthEmail
emailSubject = "PowerOn - Passwort setzen"
emailHtml = buildAuthEmailHtml(
greeting=f"Hallo {targetUser.fullName or targetUser.username}",
bodyLines=[
"Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.",
"",
f"Ihr Benutzername: {targetUser.username}",
"",
"Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
],
buttonText="Passwort setzen",
buttonUrl=magicLink,
footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.",
)
emailSent = sendAuthEmail(
recipient=targetUser.email,
subject=emailSubject,
message="",
userId=str(targetUser.id),
htmlOverride=emailHtml,
)
if not emailSent:
logger.warning(f"Failed to send password setup email to {targetUser.email}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("Failed to send email")
)
except HTTPException:
raise
except Exception as emailErr:
logger.error(f"Error sending password setup email: {str(emailErr)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to send email: {str(emailErr)}"
)
# Log the action
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="send_password_link",
details=f"Sent password setup link to user {userId} ({targetUser.email})"
)
except Exception:
pass
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}")
return {
"message": f"Password setup link sent to {targetUser.email}",
"userId": userId,
"email": targetUser.email
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending password link: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to send password link: {str(e)}"
)
@router.delete("/{userId}", response_model=Dict[str, Any])
@limiter.limit("10/minute")
def delete_user(
request: Request,
userId: str = Path(..., description="ID of the user to delete"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Delete a user.
MULTI-TENANT: Can only delete users in the same mandate (unless PlatformAdmin).
"""
appInterface = interfaceDbApp.getInterface(context.user)
# Check if the user exists
existingUser = appInterface.getUser(userId)
if not existingUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin)
if context.mandateId and not context.isPlatformAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Cannot delete user outside your mandate")
)
# Delete UserMandate entries for this user first
userMandates = appInterface.getUserMandates(userId)
for um in userMandates:
appInterface.deleteUserMandate(userId, str(um.mandateId))
success = appInterface.deleteUser(userId)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("Error deleting the user")
)
return {"message": f"User with ID {userId} successfully deleted"}