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