diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 562f51db..906b11e7 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Depends, Request, Query from typing import List, Dict, Any, Optional from fastapi import status import logging -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser from modules.datamodels.datamodelUam import User @@ -40,9 +40,12 @@ class InvitationCreate(BaseModel): Supports two modes: - Mandate-level: featureInstanceId omitted, roleIds are mandate-level roles (user, viewer, admin) - Feature-instance-level: featureInstanceId required, roleIds are instance-level roles + + Email is required for new users; targetUsername is optional. + At least one of email or targetUsername must be provided. """ - targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)") - email: Optional[str] = Field(None, description="Email address to send invitation link (optional)") + targetUsername: Optional[str] = Field(None, description="Username of the user to invite (must match on acceptance)") + email: Optional[str] = Field(None, description="Email address to send invitation link (required for new users)") featureInstanceId: Optional[str] = Field(None, description="Feature instance to grant access to (optional for mandate-level invitations)") roleIds: List[str] = Field(..., description="Role IDs: mandate-level (user, viewer, admin) or instance-level") frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)") @@ -59,6 +62,14 @@ class InvitationCreate(BaseModel): description="Maximum number of times this invitation can be used" ) + @model_validator(mode="after") + def require_email_or_username(self): + email_val = (self.email or "").strip() + username_val = (self.targetUsername or "").strip() + if not email_val and not username_val: + raise ValueError("At least one of email or targetUsername must be provided") + return self + class InvitationResponse(BaseModel): """Response model for invitation""" @@ -67,7 +78,7 @@ class InvitationResponse(BaseModel): mandateId: str featureInstanceId: Optional[str] roleIds: List[str] - targetUsername: str + targetUsername: Optional[str] email: Optional[str] createdBy: str createdAt: float @@ -88,6 +99,7 @@ class InvitationValidation(BaseModel): mandateId: Optional[str] mandateName: Optional[str] = None featureInstanceId: Optional[str] + featureInstanceName: Optional[str] = None roleIds: List[str] roleLabels: List[str] = [] targetUsername: Optional[str] = None @@ -184,14 +196,73 @@ def create_invitation( # Calculate expiration time currentTime = getUtcTimestamp() expiresAt = currentTime + (data.expiresInHours * 3600) - - # Create invitation + + # Create invitation (targetUsername optional when email provided) + target_username_val = (data.targetUsername or "").strip() or None + email_val = (data.email or "").strip() or None + + # Silent duplicate check: only for existing users (identified by username) + # Username is unique; email can exist multiple times, so we do NOT look up by email + target_user_id = None + if target_username_val: + u = rootInterface.getUserByUsername(target_username_val) + if u: + target_user_id = str(u.id) + + if target_user_id: + if data.featureInstanceId: + existing_access = rootInterface.getFeatureAccess(target_user_id, data.featureInstanceId) + if existing_access: + logger.info(f"Invitation skipped: user {target_user_id} already has access to feature instance {data.featureInstanceId}") + return InvitationResponse( + id="duplicate-skipped", + token="", + mandateId=mandateId, + featureInstanceId=data.featureInstanceId, + roleIds=data.roleIds, + targetUsername=target_username_val, + email=email_val, + createdBy=str(context.user.id), + createdAt=currentTime, + expiresAt=expiresAt, + usedBy=None, + usedAt=None, + revokedAt=None, + maxUses=data.maxUses, + currentUses=0, + inviteUrl="", + emailSent=False + ) + else: + existing_membership = rootInterface.getUserMandate(target_user_id, mandateId) + if existing_membership: + logger.info(f"Invitation skipped: user {target_user_id} already member of mandate {mandateId}") + return InvitationResponse( + id="duplicate-skipped", + token="", + mandateId=mandateId, + featureInstanceId=None, + roleIds=data.roleIds, + targetUsername=target_username_val, + email=email_val, + createdBy=str(context.user.id), + createdAt=currentTime, + expiresAt=expiresAt, + usedBy=None, + usedAt=None, + revokedAt=None, + maxUses=data.maxUses, + currentUses=0, + inviteUrl="", + emailSent=False + ) + invitation = Invitation( mandateId=mandateId, featureInstanceId=data.featureInstanceId or None, roleIds=data.roleIds, - targetUsername=data.targetUsername, - email=data.email, + targetUsername=target_username_val, + email=email_val, createdBy=str(context.user.id), expiresAt=expiresAt, maxUses=data.maxUses @@ -207,21 +278,30 @@ def create_invitation( # Send email if email address is provided emailSent = False - if data.email: + if email_val: try: from modules.connectors.connectorMessagingEmail import ConnectorMessagingEmail - # Get mandate name for the email - mandate = rootInterface.getMandate(str(context.mandateId)) + # Get mandate and optionally instance for the email + mandate = rootInterface.getMandate(str(mandateId)) mandateName = (mandate.label or mandate.name) if mandate else "PowerOn" - + # Only use username for existing users; new users set it when they register + display_name = target_username_val if target_username_val else "Neuer/e Nutzer/in" + instance = rootInterface.getFeatureInstance(data.featureInstanceId) if data.featureInstanceId else None + instance_label = (instance.label or instance.featureCode) if instance else None + emailConnector = ConnectorMessagingEmail() - emailSubject = f"Einladung zu {mandateName}" + if instance_label: + emailSubject = f"Einladung zur Feature-Instanz {instance_label}" + invite_text = f"der Feature-Instanz {instance_label} (Mandant: {mandateName}) beizutreten" + else: + emailSubject = f"Einladung zu {mandateName}" + invite_text = f"dem Mandanten {mandateName} beizutreten" emailBody = f"""

Sie wurden eingeladen!

-

Hallo {data.targetUsername},

-

Sie wurden eingeladen, dem Mandanten {mandateName} beizutreten.

+

Hallo {display_name},

+

Sie wurden eingeladen, {invite_text}.

Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:

@@ -244,14 +324,14 @@ def create_invitation( """ emailConnector.send( - recipient=data.email, + recipient=email_val, subject=emailSubject, message=emailBody ) emailSent = True - logger.info(f"Invitation email sent to {data.email} for user {data.targetUsername}") + logger.info(f"Invitation email sent to {email_val} for user {target_username_val or 'email-only'}") except Exception as emailError: - logger.warning(f"Failed to send invitation email to {data.email}: {emailError}") + logger.warning(f"Failed to send invitation email to {email_val}: {emailError}") # Don't fail the invitation creation if email fails # Update the invitation record with emailSent status @@ -263,31 +343,37 @@ def create_invitation( ) createdRecord["emailSent"] = True - # If the target user already exists, create an in-app notification + # If the target user already exists (identified by username), create an in-app notification + # Only look up by username - email is not used for "existing user" since new users are invited by email try: - existingUser = rootInterface.getUserByUsername(data.targetUsername) + existingUser = None + if target_username_val: + existingUser = rootInterface.getUserByUsername(target_username_val) if existingUser: from modules.routes.routeNotifications import createInvitationNotification - # Get mandate name for notification - mandate = rootInterface.getMandate(str(context.mandateId)) + mandate = rootInterface.getMandate(str(mandateId)) mandateName = (mandate.label or mandate.name) if mandate else "PowerOn" inviterName = context.user.fullName or context.user.username + instance = rootInterface.getFeatureInstance(data.featureInstanceId) if data.featureInstanceId else None + instance_label = (instance.label or instance.featureCode) if instance else None createInvitationNotification( userId=str(existingUser.id), invitationId=str(createdRecord.get("id")), mandateName=mandateName, - inviterName=inviterName + inviterName=inviterName, + featureInstanceName=instance_label ) - logger.info(f"Created notification for existing user {data.targetUsername}") + logger.info(f"Created notification for existing user {target_username_val or email_val}") except Exception as notifError: - logger.warning(f"Failed to create notification for user {data.targetUsername}: {notifError}") + logger.warning(f"Failed to create notification for user {target_username_val or email_val}: {notifError}") # Don't fail the invitation if notification fails + target_desc = f"feature instance {data.featureInstanceId}" if data.featureInstanceId else f"mandate {mandateId}" logger.info( - f"User {context.user.id} created invitation for user {data.targetUsername} " - f"to mandate {context.mandateId}, expires in {data.expiresInHours}h" + f"User {context.user.id} created invitation for user {target_username_val or email_val} " + f"to {target_desc}, expires in {data.expiresInHours}h" ) return InvitationResponse( @@ -560,12 +646,20 @@ def validate_invitation( if role: roleLabels.append(role.roleLabel) + # Get feature instance label for display + featureInstanceName = None + if invitation.featureInstanceId: + instance = rootInterface.getFeatureInstance(str(invitation.featureInstanceId)) + if instance: + featureInstanceName = instance.label or instance.featureCode + return InvitationValidation( valid=True, reason=None, mandateId=str(mandateId) if mandateId else None, mandateName=mandateName, featureInstanceId=str(invitation.featureInstanceId) if invitation.featureInstanceId else None, + featureInstanceName=featureInstanceName, roleIds=roleIds, roleLabels=roleLabels, targetUsername=targetUsername, @@ -634,15 +728,33 @@ def accept_invitation( detail="Invitation has reached maximum uses" ) - # Validate username matches - the invitation is bound to a specific user + # Validate user matches - invitation is bound by username or email targetUsername = invitation.targetUsername - if targetUsername and currentUser.username != targetUsername: - logger.warning( - f"User {currentUser.username} tried to accept invitation meant for {targetUsername}" - ) + invitationEmail = (invitation.email or "").strip().lower() if invitation.email else None + currentUserEmail = (currentUser.email or "").strip().lower() if getattr(currentUser, "email", None) else None + + if targetUsername: + if currentUser.username != targetUsername: + logger.warning( + f"User {currentUser.username} tried to accept invitation meant for {targetUsername}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Diese Einladung ist für Benutzer '{targetUsername}' bestimmt" + ) + elif invitationEmail: + if not currentUserEmail or currentUserEmail != invitationEmail: + logger.warning( + f"User {currentUser.username} (email: {currentUserEmail}) tried to accept invitation meant for {invitationEmail}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Diese Einladung ist für die E-Mail-Adresse '{invitationEmail}' bestimmt" + ) + else: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Diese Einladung ist für Benutzer '{targetUsername}' bestimmt" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has no target user or email" ) mandateId = str(invitation.mandateId) if invitation.mandateId else None diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py index f1a279fe..3d3c791d 100644 --- a/modules/routes/routeNotifications.py +++ b/modules/routes/routeNotifications.py @@ -93,17 +93,23 @@ def createInvitationNotification( userId: str, invitationId: str, mandateName: str, - inviterName: str + inviterName: str, + featureInstanceName: Optional[str] = None ) -> UserNotification: """ Create a notification for a pending invitation. Called when an invitation is created for an existing user. + If featureInstanceName is set, the message refers to the feature instance; otherwise to the mandate. """ + if featureInstanceName: + msg = f"{inviterName} hat Sie zur Feature-Instanz '{featureInstanceName}' eingeladen." + else: + msg = f"{inviterName} hat Sie zu '{mandateName}' eingeladen." return _createNotification( userId=userId, notificationType=NotificationType.INVITATION, title="Neue Einladung", - message=f"{inviterName} hat Sie zu '{mandateName}' eingeladen.", + message=msg, referenceType="Invitation", referenceId=invitationId, icon="mail", @@ -397,11 +403,26 @@ def _handleInvitationAction( detail="Invitation not found" ) - # Verify username matches - if invitation.targetUsername != currentUser.username: + # Verify user matches (username or email) + targetUsername = (invitation.targetUsername or "").strip() or None + invitationEmail = (invitation.email or "").strip().lower() if invitation.email else None + currentUserEmail = (currentUser.email or "").strip().lower() if getattr(currentUser, "email", None) else None + if targetUsername: + if currentUser.username != targetUsername: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This invitation is for a different user" + ) + elif invitationEmail: + if not currentUserEmail or currentUserEmail != invitationEmail: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This invitation is for a different user" + ) + else: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="This invitation is for a different user" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has no target user or email" ) # Check if invitation is still valid @@ -428,37 +449,51 @@ def _handleInvitationAction( ) if actionId == "accept": - # Accept the invitation - assign roles and mandate access mandateId = str(invitation.mandateId) if invitation.mandateId else None roleIds = list(invitation.roleIds or []) - - # Ensure user gets the system "user" role for access to public UI elements (e.g. playground) - userRole = rootInterface.getRoleByLabel("user") - if userRole: - userRoleId = str(userRole.id) - if userRoleId and userRoleId not in roleIds: - roleIds = roleIds + [userRoleId] - logger.debug(f"Added system 'user' role {userRoleId} to invitation roles") - - # Get mandate name for result message - mandate = rootInterface.getMandate(mandateId) if mandateId else None - mandateName = (mandate.label or mandate.name) if mandate else mandateId - - # Check if user already has this mandate - existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId) if mandateId else None - - if existingMembership: - # Update existing membership with new roles via interface - # Note: roleIds on UserMandate is deprecated - roles should be assigned via UserMandateRole - logger.info(f"User {currentUser.id} already has membership in mandate {mandateId}, adding roles via UserMandateRole") - # Add roles via junction table - for roleId in roleIds: - rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId) + featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None + + if featureInstanceId: + # Feature-instance invitation: create FeatureAccess (or add roles to existing) + # Do NOT add system "user" role - instance roles only; createFeatureAccess auto-assigns mandate user via Regel 4 + existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId) + if existingAccess: + for roleId in roleIds: + try: + rootInterface.addRoleToFeatureAccess(str(existingAccess.id), roleId) + except Exception: + pass # Role might already be assigned + logger.info(f"User {currentUser.id} already had feature access, added roles to instance {featureInstanceId}") + else: + rootInterface.createFeatureAccess( + userId=str(currentUser.id), + featureInstanceId=featureInstanceId, + roleIds=roleIds + ) + logger.info(f"User {currentUser.id} granted feature access to instance {featureInstanceId}") + instance = rootInterface.getFeatureInstance(featureInstanceId) + displayName = (instance.label or instance.featureCode) if instance else featureInstanceId + resultMessage = f"Einladung angenommen. Sie haben jetzt Zugang zur Feature-Instanz '{displayName}'." else: - # Create new user-mandate relationship via interface - rootInterface.createUserMandate(str(currentUser.id), mandateId, roleIds) - logger.info(f"Created UserMandate for user {currentUser.id} in mandate {mandateId}") - + # Mandate-level invitation: assign roles and mandate access + userRole = rootInterface.getRoleByLabel("user") + if userRole: + userRoleId = str(userRole.id) + if userRoleId and userRoleId not in roleIds: + roleIds = roleIds + [userRoleId] + logger.debug(f"Added system 'user' role {userRoleId} to invitation roles") + mandate = rootInterface.getMandate(mandateId) if mandateId else None + mandateName = (mandate.label or mandate.name) if mandate else mandateId + existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId) if mandateId else None + if existingMembership: + logger.info(f"User {currentUser.id} already has membership in mandate {mandateId}, adding roles via UserMandateRole") + for roleId in roleIds: + rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId) + else: + rootInterface.createUserMandate(str(currentUser.id), mandateId, roleIds) + logger.info(f"Created UserMandate for user {currentUser.id} in mandate {mandateId}") + resultMessage = f"Einladung angenommen. Sie haben jetzt Zugang zu '{mandateName}'." + # Mark invitation as used rootInterface.db.recordModify( model_class=Invitation, @@ -469,9 +504,9 @@ def _handleInvitationAction( "currentUses": currentUses + 1 } ) - - logger.info(f"User {currentUser.id} accepted invitation {invitationId} for mandate {mandateId}") - return f"Einladung angenommen. Sie haben jetzt Zugang zu '{mandateName}'." + target_desc = f"feature instance {featureInstanceId}" if featureInstanceId else f"mandate {mandateId}" + logger.info(f"User {currentUser.id} accepted invitation {invitationId} for {target_desc}") + return resultMessage elif actionId == "decline": # Decline the invitation