From a787cdf6bf7a6c0b036387d6009f9f30183d6891 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 30 Mar 2026 23:03:36 +0200
Subject: [PATCH] fixed onboarding flow
---
modules/datamodels/datamodelSubscription.py | 1 +
modules/features/automation/mainAutomation.py | 2 +-
.../features/automation2/mainAutomation2.py | 19 +-
modules/features/commcoach/mainCommcoach.py | 34 +-
.../commcoach/tests/test_mainCommcoach.py | 2 +-
.../neutralization/mainNeutralization.py | 37 +-
modules/features/realEstate/mainRealEstate.py | 53 ++-
.../teamsbot/interfaceFeatureTeamsbot.py | 15 -
modules/features/teamsbot/mainTeamsbot.py | 22 +-
modules/features/trustee/mainTrustee.py | 49 +-
modules/interfaces/interfaceDbApp.py | 63 ++-
modules/interfaces/interfaceDbBilling.py | 11 -
modules/interfaces/interfaceDbManagement.py | 23 +-
modules/routes/routeDataUsers.py | 39 +-
modules/routes/routeInvitations.py | 51 +--
modules/routes/routeSecurityLocal.py | 424 ++++++++++++------
modules/routes/routeStore.py | 14 +-
.../services/serviceChat/mainServiceChat.py | 12 +-
.../mainServiceGeneration.py | 2 +-
19 files changed, 550 insertions(+), 323 deletions(-)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index c5547c0a..8fcf10f2 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -31,6 +31,7 @@ OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIA
ALLOWED_TRANSITIONS = {
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE),
+ (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED),
(SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE),
diff --git a/modules/features/automation/mainAutomation.py b/modules/features/automation/mainAutomation.py
index 4bb30f7f..d56804fd 100644
--- a/modules/features/automation/mainAutomation.py
+++ b/modules/features/automation/mainAutomation.py
@@ -227,7 +227,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap
+ "autoCreateInstance": False,
}
diff --git a/modules/features/automation2/mainAutomation2.py b/modules/features/automation2/mainAutomation2.py
index 08038e68..c0bee3fe 100644
--- a/modules/features/automation2/mainAutomation2.py
+++ b/modules/features/automation2/mainAutomation2.py
@@ -60,12 +60,25 @@ RESOURCE_OBJECTS = [
]
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "automation2-viewer",
+ "description": {
+ "en": "Automation2 Viewer - View workflows (read-only)",
+ "de": "Automation2 Betrachter - Workflows ansehen (nur lesen)",
+ "fr": "Visualiseur Automation2 - Consulter les workflows (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.automation2.workflows", "view": True},
+ {"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
{
"roleLabel": "automation2-user",
"description": {
"en": "Automation2 User - Use automation2 flow builder",
"de": "Automation2 Benutzer - Flow-Builder nutzen",
- "fr": "Utilisateur Automation2 - Utiliser le flow builder"
+ "fr": "Utilisateur Automation2 - Utiliser le flow builder",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True},
@@ -75,7 +88,7 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
- ]
+ ],
},
{
"roleLabel": "automation2-admin",
@@ -188,7 +201,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True,
+ "autoCreateInstance": False,
}
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index e8abcee8..9d949e13 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -109,12 +109,27 @@ RESOURCE_OBJECTS = [
]
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "commcoach-viewer",
+ "description": {
+ "en": "Communication Coach Viewer - View coaching data (read-only)",
+ "de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
+ "fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
+ {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
+ {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
{
"roleLabel": "commcoach-user",
"description": {
"en": "Communication Coach User - Can manage own coaching contexts and sessions",
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
- "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions"
+ "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
@@ -132,7 +147,20 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
- ]
+ ],
+ },
+ {
+ "roleLabel": "commcoach-admin",
+ "description": {
+ "en": "Communication Coach Admin - All UI and API actions; data scoped to own records",
+ "de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
+ "fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres",
+ },
+ "accessRules": [
+ {"context": "UI", "item": None, "view": True},
+ {"context": "RESOURCE", "item": None, "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
+ ],
},
]
@@ -142,7 +170,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True,
+ "autoCreateInstance": False,
}
diff --git a/modules/features/commcoach/tests/test_mainCommcoach.py b/modules/features/commcoach/tests/test_mainCommcoach.py
index 85d85cf6..6be563b6 100644
--- a/modules/features/commcoach/tests/test_mainCommcoach.py
+++ b/modules/features/commcoach/tests/test_mainCommcoach.py
@@ -31,7 +31,7 @@ class TestFeatureDefinition:
assert defn["code"] == "commcoach"
assert "label" in defn
assert "icon" in defn
- assert defn["autoCreateInstance"] is True
+ assert defn["autoCreateInstance"] is False
class TestRbacObjects:
diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py
index d32b441f..bfe97a13 100644
--- a/modules/features/neutralization/mainNeutralization.py
+++ b/modules/features/neutralization/mainNeutralization.py
@@ -45,34 +45,55 @@ RESOURCE_OBJECTS = [
# Template roles for this feature
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "neutralization-viewer",
+ "description": {
+ "en": "Neutralization Viewer - View neutralization data (read-only)",
+ "de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
+ "fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
+ {
+ "roleLabel": "neutralization-user",
+ "description": {
+ "en": "Neutralization User - Use neutralization tools and manage own data",
+ "de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
+ "fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
+ {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
+ ],
+ },
{
"roleLabel": "neutralization-admin",
"description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
- "fr": "Administrateur neutralisation - Accès complet aux paramètres et données"
+ "fr": "Administrateur neutralisation - Accès complet aux paramètres et données",
},
"accessRules": [
- # Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
- # Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
- ]
+ ],
},
{
"roleLabel": "neutralization-analyst",
"description": {
"en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
- "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation"
+ "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation",
},
"accessRules": [
- # UI access to specific views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
- # Group-level DATA access (read-only for sensitive config)
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
- ]
+ ],
},
]
diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py
index 2ae2378b..dfe310d5 100644
--- a/modules/features/realEstate/mainRealEstate.py
+++ b/modules/features/realEstate/mainRealEstate.py
@@ -39,52 +39,57 @@ RESOURCE_OBJECTS = [
# Template roles for this feature with AccessRules
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "realestate-viewer",
+ "description": {
+ "en": "Real Estate Viewer - View property information (read-only)",
+ "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
+ "fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
+ {
+ "roleLabel": "realestate-user",
+ "description": {
+ "en": "Real Estate User - Create and manage own property records",
+ "de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
+ "fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
+ {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
+ ],
+ },
{
"roleLabel": "realestate-admin",
"description": {
"en": "Real Estate Administrator - Full access to all property data and settings",
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
- "fr": "Administrateur immobilier - Accès complet aux données et paramètres"
+ "fr": "Administrateur immobilier - Accès complet aux données et paramètres",
},
"accessRules": [
- # Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
- # Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
- # Admin resources
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
- ]
+ ],
},
{
"roleLabel": "realestate-manager",
"description": {
"en": "Real Estate Manager - Manage properties and tenants",
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
- "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
+ "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires",
},
"accessRules": [
- # UI access to map view
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
- # Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
- # Resource: create projects
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
- ]
- },
- {
- "roleLabel": "realestate-viewer",
- "description": {
- "en": "Real Estate Viewer - View property information",
- "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen",
- "fr": "Visualiseur immobilier - Consulter les informations immobilières"
- },
- "accessRules": [
- # UI access to map view (read-only)
- {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
- # Read-only DATA access (my records)
- {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
- ]
+ ],
},
]
diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py
index 9be96393..4d6519d8 100644
--- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py
+++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py
@@ -10,7 +10,6 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
-from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG
from .datamodelTeamsbot import (
@@ -104,13 +103,10 @@ class TeamsbotObjects:
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new session."""
- sessionData["creationDate"] = getIsoTimestamp()
- sessionData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSession, sessionData)
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update session fields."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSession, sessionId, updates)
def deleteSession(self, sessionId: str) -> bool:
@@ -149,7 +145,6 @@ class TeamsbotObjects:
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new transcript segment."""
- transcriptData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotTranscript, transcriptData)
def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@@ -180,7 +175,6 @@ class TeamsbotObjects:
def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new bot response record."""
- responseData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotBotResponse, responseData)
def _deleteResponsesBySession(self, sessionId: str) -> int:
@@ -216,13 +210,10 @@ class TeamsbotObjects:
def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new system bot account."""
- botData["creationDate"] = getIsoTimestamp()
- botData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSystemBot, botData)
def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update a system bot account."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSystemBot, botId, updates)
def deleteSystemBot(self, botId: str) -> bool:
@@ -243,13 +234,10 @@ class TeamsbotObjects:
def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
"""Create user settings."""
- settingsData["creationDate"] = getIsoTimestamp()
- settingsData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserSettings, settingsData)
def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update user settings."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserSettings, settingsId, updates)
def deleteUserSettings(self, settingsId: str) -> bool:
@@ -270,13 +258,10 @@ class TeamsbotObjects:
def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create saved MS credentials."""
- data["creationDate"] = getIsoTimestamp()
- data["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserAccount, data)
def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update saved MS credentials."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserAccount, accountId, updates)
def deleteUserAccount(self, accountId: str) -> bool:
diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py
index 97cc107e..afdce822 100644
--- a/modules/features/teamsbot/mainTeamsbot.py
+++ b/modules/features/teamsbot/mainTeamsbot.py
@@ -103,25 +103,35 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
]
},
+ {
+ "roleLabel": "teamsbot-viewer",
+ "description": {
+ "en": "Teams Bot Viewer - View sessions and transcripts (read-only)",
+ "de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
+ "fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
{
"roleLabel": "teamsbot-user",
"description": {
"en": "Teams Bot User - Can start/stop sessions and view transcripts",
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
- "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions"
+ "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions",
},
"accessRules": [
- # UI access to dashboard and sessions (not settings)
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
- # Own records only
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
- # Start and stop sessions
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
- ]
+ ],
},
]
@@ -132,7 +142,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True,
+ "autoCreateInstance": False,
}
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 606da308..45824b1b 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -170,60 +170,81 @@ RESOURCE_OBJECTS = [
# Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "trustee-viewer",
+ "description": {
+ "en": "Trustee Viewer - View trustee data (read-only)",
+ "de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
+ "fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
+ {
+ "roleLabel": "trustee-user",
+ "description": {
+ "en": "Trustee User - Create and manage own trustee records",
+ "de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
+ "fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
+ ],
+ },
{
"roleLabel": "trustee-admin",
"description": {
"en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
- "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires"
+ "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires",
},
"accessRules": [
- # Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
- # Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
- # Admin resource: manage instance roles
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
- ]
+ ],
},
{
"roleLabel": "trustee-accountant",
"description": {
"en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
- "fr": "Comptable fiduciaire - Gérer les données comptables et financières"
+ "fr": "Comptable fiduciaire - Gérer les données comptables et financières",
},
"accessRules": [
- # UI access to main views (not admin views, not expense-import) - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
- # Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
- # Accounting sync permission
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
- ]
+ ],
},
{
"roleLabel": "trustee-client",
"description": {
"en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
- "fr": "Client fiduciaire - Consulter ses propres données comptables et documents"
+ "fr": "Client fiduciaire - Consulter ses propres données comptables et documents",
},
"accessRules": [
- # UI access to main views + expense-import - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True},
- # Own records only (MY level)
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
- ]
+ ],
},
]
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index ffde890f..d980eb56 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1446,7 +1446,7 @@ class AppObjects:
if not adminRoleId:
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
- self.createUserMandate(userId, mandateId, roleIds=[adminRoleId])
+ self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
subscription = MandateSubscription(
mandateId=mandateId,
@@ -1454,8 +1454,10 @@ class AppObjects:
status=SubscriptionStatusEnum.PENDING,
)
if plan.trialDays:
- pass # trialEndsAt set on ACTIVE transition
- self.db.recordCreate(MandateSubscription, subscription.model_dump())
+ pass # trialEndsAt set on ACTIVE/TRIALING transition
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ subInterface = _getSubRoot()
+ subInterface.createSubscription(subscription)
featureInterface = getFeatureInterface(self.db)
mainModules = loadFeatureMainModules()
@@ -1513,50 +1515,58 @@ class AppObjects:
"""
Activate PENDING subscriptions for all mandates where this user is a member.
Called on login — trial period begins NOW, not at registration.
+ Uses the subscription interface (poweron_billing) for all subscription operations.
Returns number of activated subscriptions.
"""
from modules.datamodels.datamodelSubscription import (
- MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS,
+ SubscriptionStatusEnum, BUILTIN_PLANS,
)
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
from datetime import datetime, timezone, timedelta
activated = 0
+ subInterface = _getSubRoot()
+
userMandates = self.db.getRecordset(
UserMandate, recordFilter={"userId": userId, "enabled": True}
)
for um in userMandates:
mandateId = um.get("mandateId")
- subs = self.db.getRecordset(
- MandateSubscription,
- recordFilter={"mandateId": mandateId, "status": SubscriptionStatusEnum.PENDING.value}
- )
- for sub in subs:
+ allSubs = subInterface.listForMandate(mandateId)
+ pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value]
+
+ for sub in pendingSubs:
subId = sub.get("id")
planKey = sub.get("planKey")
plan = BUILTIN_PLANS.get(planKey)
now = datetime.now(timezone.utc)
- updateData = {
- "status": SubscriptionStatusEnum.TRIALING.value if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE.value,
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE
+ additionalData = {
"currentPeriodStart": now.isoformat(),
}
if plan and plan.trialDays:
trialEnd = now + timedelta(days=plan.trialDays)
- updateData["trialEndsAt"] = trialEnd.isoformat()
- updateData["currentPeriodEnd"] = trialEnd.isoformat()
+ additionalData["trialEndsAt"] = trialEnd.isoformat()
+ additionalData["currentPeriodEnd"] = trialEnd.isoformat()
elif plan and plan.billingPeriod:
from modules.datamodels.datamodelSubscription import BillingPeriodEnum
if plan.billingPeriod == BillingPeriodEnum.MONTHLY:
- updateData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
+ additionalData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
elif plan.billingPeriod == BillingPeriodEnum.YEARLY:
- updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
+ additionalData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
try:
- self.db.recordModify(MandateSubscription, subId, updateData)
+ subInterface.transitionStatus(
+ subId,
+ expectedFromStatus=SubscriptionStatusEnum.PENDING,
+ toStatus=targetStatus,
+ additionalData=additionalData,
+ )
activated += 1
- logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {updateData.get('status')}")
+ logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {targetStatus.value}")
except Exception as e:
logger.error(f"Failed to activate subscription {subId}: {e}")
@@ -1848,7 +1858,7 @@ class AppObjects:
logger.error(f"Error getting UserMandates: {e}")
return []
- def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
+ def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None, *, skipCapacityCheck: bool = False) -> UserMandate:
"""
Create a UserMandate record (add user to mandate).
Also creates a billing audit account for the user if billing is configured.
@@ -1859,6 +1869,8 @@ class AppObjects:
userId: User ID
mandateId: Mandate ID
roleIds: List of role IDs to assign (at least one required)
+ skipCapacityCheck: If True, skip subscription capacity check (used during initial provisioning
+ when the subscription hasn't been created yet)
Returns:
Created UserMandate object
@@ -1871,7 +1883,8 @@ class AppObjects:
if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
- self._checkSubscriptionCapacity(mandateId, "users", delta=1)
+ if not skipCapacityCheck:
+ self._checkSubscriptionCapacity(mandateId, "users", delta=1)
userMandate = UserMandate(
userId=userId,
@@ -2551,6 +2564,18 @@ class AppObjects:
logger.error(f"Error getting invitations for target username {targetUsername}: {e}")
return []
+ def getInvitationsByEmail(self, email: str) -> List[Invitation]:
+ """Get all invitations for a target email address (email-only invitations)."""
+ try:
+ records = self.db.getRecordset(Invitation, recordFilter={"email": email})
+ result = []
+ for record in records:
+ result.append(Invitation(**dict(record)))
+ return result
+ except Exception as e:
+ logger.error(f"Error getting invitations for email {email}: {e}")
+ return []
+
# ============================================
# Additional Helper Methods
# ============================================
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index bb2dc5c9..948f8918 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -586,17 +586,6 @@ class BillingObjects:
# Create transaction record (always on transaction.accountId for audit)
transactionDict = transaction.model_dump(exclude_none=True)
- ts = getUtcTimestamp()
- uid = str(self.userId) if self.userId else None
- if transactionDict.get("sysCreatedAt") is None:
- transactionDict["sysCreatedAt"] = ts
- if transactionDict.get("sysModifiedAt") is None:
- transactionDict["sysModifiedAt"] = ts
- if uid:
- if transactionDict.get("sysCreatedBy") is None:
- transactionDict["sysCreatedBy"] = uid
- if transactionDict.get("sysModifiedBy") is None:
- transactionDict["sysModifiedBy"] = uid
created = self.db.recordCreate(BillingTransaction, transactionDict)
# Update balance on the target account
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 0a16b734..2df85164 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -823,7 +823,7 @@ class ComponentObjects:
mimeType=file["mimeType"],
fileHash=file["fileHash"],
fileSize=file["fileSize"],
- creationDate=file["creationDate"]
+ sysCreatedAt=file.get("sysCreatedAt") or file.get("creationDate"),
)
def getMimeType(self, fileName: str) -> str:
@@ -928,9 +928,11 @@ class ComponentObjects:
fileItems = []
for file in files:
try:
- creationDate = file.get("creationDate")
- if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0:
- file["creationDate"] = getUtcTimestamp()
+ sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
+ if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
+ file["sysCreatedAt"] = getUtcTimestamp()
+ else:
+ file["sysCreatedAt"] = sysCreatedAt
fileName = file.get("fileName")
if not fileName or fileName == "None":
@@ -977,20 +979,19 @@ class ComponentObjects:
file = filteredFiles[0]
try:
- # Get creation date from record or use current time
- creationDate = file.get("creationDate")
- if not creationDate:
- creationDate = getUtcTimestamp()
-
+ sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
+ if not sysCreatedAt:
+ sysCreatedAt = getUtcTimestamp()
+
return FileItem(
id=file.get("id"),
mandateId=file.get("mandateId"),
+ featureInstanceId=file.get("featureInstanceId", ""),
fileName=file.get("fileName"),
mimeType=file.get("mimeType"),
- workflowId=file.get("workflowId"),
fileHash=file.get("fileHash"),
fileSize=file.get("fileSize"),
- creationDate=creationDate
+ sysCreatedAt=sysCreatedAt,
)
except Exception as e:
logger.error(f"Error converting file record: {str(e)}")
diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py
index a1da658b..7cce66ca 100644
--- a/modules/routes/routeDataUsers.py
+++ b/modules/routes/routeDataUsers.py
@@ -920,30 +920,29 @@ def send_password_link(
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try:
- from modules.serviceHub import Services
- services = Services(targetUser)
-
+ from modules.routes.routeSecurityLocal import _buildAuthEmailHtml, _sendAuthEmail
+
emailSubject = "PowerOn - Passwort setzen"
- emailBody = f"""
-Hallo {targetUser.fullName or targetUser.username},
+ 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.",
+ )
-Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.
-
-Ihr Benutzername: {targetUser.username}
-
-Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
-{magicLink}
-
-Dieser Link ist {expiryHours} Stunden gültig.
-
-Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.
-"""
-
- emailSent = services.messaging.sendEmailDirect(
+ emailSent = _sendAuthEmail(
recipient=targetUser.email,
subject=emailSubject,
- message=emailBody,
- userId=str(targetUser.id)
+ message="",
+ userId=str(targetUser.id),
+ htmlOverride=emailHtml,
)
if not emailSent:
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index cb913137..ccefcc87 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -292,37 +292,24 @@ def create_invitation(
emailConnector = ConnectorMessagingEmail()
if instance_label:
emailSubject = f"Einladung zur Feature-Instanz {instance_label}"
- invite_text = f"der Feature-Instanz {instance_label} (Mandant: {mandateName}) beizutreten"
+ invite_desc = 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 {display_name},
- Sie wurden eingeladen, {invite_text}.
- Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:
-
-
- Einladung annehmen
-
-
-
- Oder kopieren Sie diesen Link in Ihren Browser:
- {inviteUrl}
-
-
- Diese Einladung ist {data.expiresInHours} Stunden gültig.
-
-
-
- Diese E-Mail wurde automatisch von PowerOn gesendet.
-
-
-
- """
-
+ invite_desc = f"dem Mandanten «{mandateName}» beizutreten"
+
+ from modules.routes.routeSecurityLocal import _buildAuthEmailHtml
+ emailBody = _buildAuthEmailHtml(
+ greeting=f"Hallo {display_name}",
+ bodyLines=[
+ f"Sie wurden eingeladen, {invite_desc}.",
+ "",
+ "Klicken Sie auf die Schaltfläche, um die Einladung anzunehmen:",
+ ],
+ buttonText="Einladung annehmen",
+ buttonUrl=inviteUrl,
+ footerText=f"Diese Einladung ist {data.expiresInHours} Stunden gültig.",
+ )
+
emailConnector.send(
recipient=email_val,
subject=emailSubject,
@@ -376,6 +363,8 @@ def create_invitation(
f"to {target_desc}, expires in {data.expiresInHours}h"
)
+ # Invitation extends PowerOnModel: recordCreate/_saveRecord set sysCreatedAt and sysCreatedBy automatically.
+ # API response uses createdAt/createdBy; map from the system fields (no separate createdAt column on model).
return InvitationResponse(
id=str(createdRecord.get("id")),
token=str(createdRecord.get("token")),
@@ -384,8 +373,8 @@ def create_invitation(
roleIds=createdRecord.get("roleIds", []),
targetUsername=createdRecord.get("targetUsername"),
email=createdRecord.get("email"),
- createdBy=str(createdRecord.get("createdBy")),
- createdAt=createdRecord.get("createdAt"),
+ createdBy=str(createdRecord["sysCreatedBy"]),
+ createdAt=float(createdRecord["sysCreatedAt"]),
expiresAt=createdRecord.get("expiresAt"),
usedBy=createdRecord.get("usedBy"),
usedAt=createdRecord.get("usedAt"),
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index 11b6cb0f..9ec4fc38 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -26,36 +26,122 @@ from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
-def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
+def _buildAuthEmailHtml(
+ greeting: str,
+ bodyLines: list,
+ buttonText: str = None,
+ buttonUrl: str = None,
+ footerText: str = None,
+) -> str:
+ """Build a branded HTML email for authentication flows.
+
+ Uses the same visual design as notifyMandateAdmins._renderHtmlEmail
+ (dark header, clean body, operator footer).
+ """
+ import html as _html
+
+ paragraphsHtml = ""
+ for line in bodyLines:
+ if line == "":
+ paragraphsHtml += '
\n'
+ else:
+ escaped = _html.escape(str(line))
+ paragraphsHtml += f'{escaped}
\n'
+
+ buttonBlock = ""
+ if buttonText and buttonUrl:
+ buttonBlock = f'''
+
+ {_html.escape(buttonUrl)}
+
'''
+
+ footerNote = ""
+ if footerText:
+ footerNote = f'{_html.escape(footerText)}
\n'
+
+ operatorLine = ""
+ try:
+ from modules.shared.configuration import APP_CONFIG
+ parts = [p for p in [
+ APP_CONFIG.get("Operator_CompanyName", ""),
+ APP_CONFIG.get("Operator_Address", ""),
+ APP_CONFIG.get("Operator_VatNumber", ""),
+ ] if p]
+ if parts:
+ operatorLine = (
+ f''
+ f'{_html.escape(" | ".join(parts))}
\n'
+ )
+ except Exception:
+ pass
+
+ return f'''
+
+
+
+
+
+
+
+
+ PowerOn
+ |
+
+
+ {_html.escape(greeting)}
+
+ {paragraphsHtml}
+ {buttonBlock}
+
+ {footerNote}
+ |
+
+ |
+
+ Diese E-Mail wurde automatisch von PowerOn versendet.
+
+ {operatorLine}
+ |
+
+ |
+
+
+'''
+
+
+def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None, htmlOverride: str = None) -> bool:
"""
Send authentication-related email directly without requiring full Services initialization.
Used for registration, password reset, and other auth flows.
-
+
Args:
recipient: Email address
subject: Email subject
- message: Plain text message (will be converted to HTML)
+ message: Plain text fallback (ignored when htmlOverride is given)
userId: Optional user ID for logging
-
+ htmlOverride: Pre-built branded HTML (from _buildAuthEmailHtml)
+
Returns:
bool: True if email was sent successfully
"""
try:
- import html
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.datamodels.datamodelMessaging import MessagingChannel
-
- # Convert plain text to simple HTML
- escaped = html.escape(message)
- escaped = escaped.replace('\n', '
\n')
- htmlMessage = f"""
-
-
-
-{escaped}
-
-"""
-
+
+ htmlMessage = htmlOverride
+ if not htmlMessage:
+ import html
+ escaped = html.escape(message)
+ escaped = escaped.replace('\n', '
\n')
+ htmlMessage = f'{escaped}'
+
messagingInterface = getMessagingInterface()
success = messagingInterface.send(
channel=MessagingChannel.EMAIL,
@@ -63,12 +149,12 @@ def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = Non
subject=subject,
message=htmlMessage
)
-
+
if success:
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
else:
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
-
+
return success
except Exception as e:
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
@@ -88,15 +174,43 @@ router = APIRouter(
)
def _ensureHomeMandate(rootInterface, user) -> None:
- """Ensure user has a Home mandate. Creates 'Home {username}' if none exists."""
- userMandates = rootInterface.getUserMandates(str(user.id))
+ """Ensure user has a Home mandate, but only if they have no mandate memberships
+ AND no pending invitations.
+
+ Invited users should NOT get a Home mandate — they join existing mandates via
+ invitation acceptance and can create their own later via onboarding.
+ """
+ userId = str(user.id)
+ userMandates = rootInterface.getUserMandates(userId)
+
+ if userMandates:
+ for um in userMandates:
+ mandate = rootInterface.getMandate(um.mandateId)
+ if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
+ return
+ logger.debug(f"User {user.username} has {len(userMandates)} mandate(s) but no Home — skipping auto-creation")
+ return
+
+ try:
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf
+ appIf = _getRootIf()
+ normalizedEmail = (user.email or "").strip().lower() if user.email else None
+ pendingByUsername = appIf.getInvitationsByTargetUsername(user.username)
+ pendingByEmail = appIf.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
+ seenIds = set()
+ for inv in pendingByUsername + pendingByEmail:
+ if inv.id in seenIds:
+ continue
+ seenIds.add(inv.id)
+ if not inv.revokedAt and (inv.currentUses or 0) < (inv.maxUses or 1):
+ logger.info(f"User {user.username} has pending invitation(s) — skipping Home mandate creation")
+ return
+ except Exception as e:
+ logger.warning(f"Could not check pending invitations for {user.username}: {e}")
+
homeMandateName = f"Home {user.username}"
- for um in userMandates:
- mandate = rootInterface.getMandate(um.mandateId)
- if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
- return
rootInterface._provisionMandateForUser(
- userId=str(user.id),
+ userId=userId,
mandateName=homeMandateName,
planKey="TRIAL_7D",
)
@@ -191,7 +305,14 @@ def login(
# Save access token
userInterface.saveAccessToken(token)
- # Activate PENDING subscriptions on first login
+ # Ensure user has a Home mandate (created on first login if missing)
+ try:
+ _ensureHomeMandate(rootInterface, user)
+ except Exception as homeErr:
+ logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
+
+ # Activate PENDING subscriptions on first login (runs AFTER _ensureHomeMandate
+ # so that a freshly provisioned Home mandate subscription is also activated)
try:
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
if activatedCount > 0:
@@ -199,12 +320,6 @@ def login(
except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {subErr}")
- # Ensure user has a Home mandate (created on first login if missing)
- try:
- _ensureHomeMandate(rootInterface, user)
- except Exception as homeErr:
- logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
-
# Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try:
@@ -282,35 +397,28 @@ def register_user(
) -> Dict[str, Any]:
"""Register a new local user (magic link based - no password required).
+ Unified registration path: invited users skip Home mandate provisioning
+ (they join the inviting mandate instead). Non-invited users get a Home
+ mandate with TRIAL_7D. Company mandate creation is deferred to onboarding.
+
Args:
userData: User data (username, email, fullName, language)
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
+ registrationType: Kept for backward compat but ignored (company mandates via onboarding)
+ companyName: Kept for backward compat but ignored
"""
try:
- # Get gateway interface with root privileges since this is a public endpoint
appInterface = getRootInterface()
-
- # Note: User registration does NOT require mandateId context
- # Users are mandate-independent (Multi-Tenant Design)
- # Mandate assignment happens via createUserMandate() after registration
-
- # Frontend URL is required - no fallback
baseUrl = frontendUrl.rstrip("/")
-
- # Normalize email
normalizedEmail = userData.email.lower().strip() if userData.email else None
- # Note: Email can be shared across multiple users (different mandates)
- # Username uniqueness is enforced in createUser() - that's the primary constraint
-
- # Create user with local authentication (no password - magic link based)
user = appInterface.createUser(
username=userData.username,
- password=None, # No password - will be set via magic link
+ password=None,
email=normalizedEmail,
fullName=userData.fullName,
language=userData.language,
- enabled=True, # Users are enabled by default (can login after setting password)
+ enabled=True,
authenticationAuthority=AuthAuthority.LOCAL
)
@@ -320,35 +428,51 @@ def register_user(
detail="Failed to register user"
)
- # Provision Home mandate for every new user ("Home {username}")
- provisionResult = None
+ # Check for pending invitations BEFORE provisioning.
+ # Search by both username AND email (email-only invitations have targetUsername=None).
+ hasPendingInvitations = False
+ validInvitations = []
try:
- homeMandateName = f"Home {user.username}"
- provisionResult = appInterface._provisionMandateForUser(
- userId=str(user.id),
- mandateName=homeMandateName,
- planKey="TRIAL_7D",
- )
- logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
- except Exception as provErr:
- logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
+ from modules.datamodels.datamodelInvitation import Invitation
- # If company registration, also create a company mandate with the paid plan
- if registrationType == "company":
- if not companyName:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="companyName is required for company registration"
- )
+ currentTime = getUtcTimestamp()
+ pendingByUsername = appInterface.getInvitationsByTargetUsername(userData.username)
+ pendingByEmail = appInterface.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
+
+ seenIds = set()
+ allPending = pendingByUsername + pendingByEmail
+ for invitation in allPending:
+ if invitation.id in seenIds:
+ continue
+ seenIds.add(invitation.id)
+ if (invitation.expiresAt or 0) < currentTime:
+ continue
+ if invitation.revokedAt:
+ continue
+ if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
+ continue
+ validInvitations.append(invitation)
+
+ hasPendingInvitations = len(validInvitations) > 0
+ except Exception as invErr:
+ logger.warning(f"Failed to check pending invitations: {invErr}")
+
+ # Only provision Home mandate if user has NO pending invitations.
+ # Invited users join existing mandates; they can create their own later via onboarding.
+ provisionResult = None
+ if not hasPendingInvitations:
try:
- companyResult = appInterface._provisionMandateForUser(
+ homeMandateName = f"Home {user.username}"
+ provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
- mandateName=companyName,
- planKey="STANDARD_MONTHLY",
+ mandateName=homeMandateName,
+ planKey="TRIAL_7D",
)
- logger.info(f"Provisioned company mandate for user {user.id}: {companyResult}")
- except Exception as compErr:
- logger.error(f"Error provisioning company mandate for user {user.id}: {compErr}")
+ logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
+ except Exception as provErr:
+ logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
+ else:
+ logger.info(f"Skipping Home mandate for user {user.id} — has {len(validInvitations)} pending invitation(s)")
# Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry()
@@ -360,57 +484,43 @@ def register_user(
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen"
- emailBody = f"""Hallo {user.fullName or user.username},
+ emailHtml = _buildAuthEmailHtml(
+ greeting=f"Hallo {user.fullName or user.username}",
+ bodyLines=[
+ "Vielen Dank für Ihre Registrierung bei PowerOn.",
+ "",
+ f"Ihr Benutzername: {user.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 sich nicht registriert haben, können Sie diese E-Mail ignorieren.",
+ )
-Vielen Dank für Ihre Registrierung bei PowerOn.
-
-Ihr Benutzername: {user.username}
-
-Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
-{magicLink}
-
-Dieser Link ist {expiryHours} Stunden gültig.
-
-Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
-
emailSent = _sendAuthEmail(
recipient=user.email,
subject=emailSubject,
- message=emailBody,
- userId=str(user.id)
+ message="",
+ userId=str(user.id),
+ htmlOverride=emailHtml,
)
if not emailSent:
logger.warning(f"Failed to send registration email to {user.email}")
except Exception as emailErr:
logger.error(f"Error sending registration email: {str(emailErr)}")
- # Don't fail registration if email fails - user can request reset later
- # Check for pending invitations and create notifications
- try:
- from modules.datamodels.datamodelInvitation import Invitation
- from modules.routes.routeNotifications import createInvitationNotification
- from modules.datamodels.datamodelUam import Mandate
-
- currentTime = getUtcTimestamp()
- pendingInvitations = appInterface.getInvitationsByTargetUsername(userData.username)
-
- for invitation in pendingInvitations:
- # Skip expired, revoked, or fully used invitations
- if (invitation.expiresAt or 0) < currentTime:
- continue
- if invitation.revokedAt:
- continue
- if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
- continue
+ # Create notifications for pending invitations
+ for invitation in validInvitations:
+ try:
+ from modules.routes.routeNotifications import createInvitationNotification
- # Get mandate name for notification using interface method
mandateId = invitation.mandateId
mandate = appInterface.getMandate(mandateId)
mandateName = (mandate.label or mandate.name) if mandate else "PowerOn"
- # Get inviter name
- inviterId = invitation.createdBy
+ inviterId = invitation.sysCreatedBy
inviter = appInterface.getUser(inviterId) if inviterId else None
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
@@ -421,16 +531,15 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
inviterName=inviterName
)
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}")
-
- except Exception as notifErr:
- logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
- # Don't fail registration if notification creation fails
+ except Exception as notifErr:
+ logger.warning(f"Failed to create notification for invitation {invitation.id}: {notifErr}")
responseData = {
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
}
if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId")
+ responseData["hasInvitations"] = hasPendingInvitations
return responseData
except ValueError as e:
@@ -676,24 +785,26 @@ def password_reset_request(
# Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen"
- emailBody = f"""Hallo {user.fullName or user.username},
+ emailHtml = _buildAuthEmailHtml(
+ greeting=f"Hallo {user.fullName or user.username}",
+ bodyLines=[
+ "Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.",
+ "",
+ f"Benutzername: {user.username}",
+ "",
+ "Klicken Sie auf die Schaltfläche, um Ihr Passwort zurückzusetzen:",
+ ],
+ buttonText="Passwort zurücksetzen",
+ buttonUrl=magicLink,
+ footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.",
+ )
-Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
-
-Benutzername: {user.username}
-
-Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
-{magicLink}
-
-Dieser Link ist {expiryHours} Stunden gültig.
-
-Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
-
emailSent = _sendAuthEmail(
recipient=user.email,
subject=emailSubject,
- message=emailBody,
- userId=str(user.id)
+ message="",
+ userId=str(user.id),
+ htmlOverride=emailHtml,
)
if emailSent:
@@ -725,24 +836,63 @@ def onboarding_provision(
companyName: str = Body(None, embed=True),
planKey: str = Body("TRIAL_7D", embed=True),
) -> Dict[str, Any]:
- """Post-login onboarding: ensure Home mandate exists and optionally create a company mandate."""
+ """Post-login onboarding: create a mandate for the user.
+
+ Guard: user can only create a mandate if they are NOT already admin in any
+ non-system mandate. This prevents duplicate provisioning.
+ """
try:
+ from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
+ from modules.datamodels.datamodelRbac import Role
+
appInterface = getRootInterface()
+ db = appInterface.db
+ userId = str(currentUser.id)
- _ensureHomeMandate(appInterface, currentUser)
+ # Check if user already has admin role in a non-system mandate
+ userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
+ hasAdminMandate = False
+ for um in userMandates:
+ mandateId = um.get("mandateId")
+ mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
+ if mandate and mandate[0].get("isSystem"):
+ continue
+ umId = um.get("id")
+ umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
+ for umRole in umRoles:
+ roleId = umRole.get("roleId")
+ roles = db.getRecordset(Role, recordFilter={"id": roleId})
+ for role in roles:
+ if "admin" in (role.get("roleLabel") or "").lower():
+ hasAdminMandate = True
+ break
+ if hasAdminMandate:
+ break
+ if hasAdminMandate:
+ break
- result = None
- if companyName and companyName.strip():
- if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"):
- planKey = "STANDARD_MONTHLY"
- result = appInterface._provisionMandateForUser(
- userId=str(currentUser.id),
- mandateName=companyName.strip(),
- planKey=planKey,
- )
+ if hasAdminMandate:
+ logger.info(f"Onboarding: user {currentUser.username} already has admin mandate — skipping provisioning")
+ return {
+ "message": "User already has an admin mandate",
+ "mandateId": None,
+ "alreadyProvisioned": True,
+ }
+
+ mandateName = (companyName.strip() if companyName and companyName.strip()
+ else f"Home {currentUser.username}")
+
+ if planKey not in ("TRIAL_7D", "STANDARD_MONTHLY", "STANDARD_YEARLY"):
+ planKey = "TRIAL_7D"
+
+ result = appInterface._provisionMandateForUser(
+ userId=userId,
+ mandateName=mandateName,
+ planKey=planKey,
+ )
try:
- activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id))
+ activatedCount = appInterface._activatePendingSubscriptions(userId)
if activatedCount > 0:
logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding")
except Exception as subErr:
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 5c6f782a..0d6e68ec 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -136,8 +136,8 @@ def listUserMandates(
) -> List[Dict[str, Any]]:
"""
List mandates where the user can activate features (admin mandates).
- If user has 0 admin mandates, auto-provisions a personal mandate so the
- Store always has a clear mandate context.
+ Returns empty list if user has no admin mandates — the frontend handles
+ this via OnboardingAssistant/OnboardingWizard to create a mandate.
"""
try:
rootInterface = getRootInterface()
@@ -145,16 +145,6 @@ def listUserMandates(
userId = str(context.user.id)
adminMandateIds = _getUserAdminMandateIds(db, userId)
- if not adminMandateIds:
- homeMandateName = f"Home {context.user.username}"
- provisionResult = rootInterface._provisionMandateForUser(
- userId=userId,
- mandateName=homeMandateName,
- planKey="TRIAL_7D",
- )
- adminMandateIds = [provisionResult["mandateId"]]
- logger.info(f"Auto-provisioned personal mandate {adminMandateIds[0]} for user {userId} on Store access")
-
result = []
for mid in adminMandateIds:
records = db.getRecordset(Mandate, recordFilter={"id": mid})
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 40769fae..f3a74b1e 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -422,7 +422,7 @@ class ChatService:
"size": fileItem.fileSize,
"mimeType": fileItem.mimeType,
"fileHash": fileItem.fileHash,
- "creationDate": fileItem.creationDate,
+ "creationDate": fileItem.sysCreatedAt,
"tags": getattr(fileItem, "tags", None),
"folderId": getattr(fileItem, "folderId", None),
"description": getattr(fileItem, "description", None),
@@ -482,7 +482,7 @@ class ChatService:
"fileName": fileItem.fileName,
"mimeType": fileItem.mimeType,
"fileSize": fileItem.fileSize,
- "creationDate": fileItem.creationDate,
+ "creationDate": fileItem.sysCreatedAt,
"tags": getattr(fileItem, "tags", None),
"folderId": getattr(fileItem, "folderId", None),
"description": getattr(fileItem, "description", None),
@@ -524,7 +524,7 @@ class ChatService:
mandateId=self._context.mandate_id or "",
userId=self.user.id if self.user else "",
)
- return self.interfaceDbComponent.db.recordCreate(DataSource, ds)
+ return self.interfaceDbApp.db.recordCreate(DataSource, ds)
def listDataSources(self, featureInstanceId: str = None) -> List[Dict[str, Any]]:
"""List data sources, optionally filtered by feature instance."""
@@ -532,19 +532,19 @@ class ChatService:
recordFilter = {}
if featureInstanceId:
recordFilter["featureInstanceId"] = featureInstanceId
- return self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter=recordFilter)
+ return self.interfaceDbApp.db.getRecordset(DataSource, recordFilter=recordFilter)
def getDataSource(self, dataSourceId: str) -> Optional[Dict[str, Any]]:
"""Get a single data source by ID."""
from modules.datamodels.datamodelDataSource import DataSource
- results = self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
+ results = self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
return results[0] if results else None
def deleteDataSource(self, dataSourceId: str) -> bool:
"""Delete a data source."""
from modules.datamodels.datamodelDataSource import DataSource
try:
- self.interfaceDbComponent.db.recordDelete(DataSource, dataSourceId)
+ self.interfaceDbApp.db.recordDelete(DataSource, dataSourceId)
return True
except Exception as e:
logger.error(f"Failed to delete DataSource {dataSourceId}: {e}")
diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
index 8fccd4e4..99da173e 100644
--- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
+++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
@@ -346,7 +346,7 @@ class GenerationService:
"size": file_item.fileSize,
"mimeType": file_item.mimeType,
"fileHash": getattr(file_item, 'fileHash', None),
- "creationDate": getattr(file_item, 'creationDate', None)
+ "creationDate": getattr(file_item, 'sysCreatedAt', None)
}
return None
except Exception as e: