fixed onboarding flow

This commit is contained in:
ValueOn AG 2026-03-30 23:03:36 +02:00
parent e0a09ae6b1
commit a787cdf6bf
19 changed files with 550 additions and 323 deletions

View file

@ -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),

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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:

View file

@ -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"},
]
],
},
]

View file

@ -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"},
]
],
},
]

View file

@ -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:

View file

@ -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,
}

View file

@ -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"},
]
],
},
]

View file

@ -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,6 +1883,7 @@ class AppObjects:
if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
if not skipCapacityCheck:
self._checkSubscriptionCapacity(mandateId, "users", delta=1)
userMandate = UserMandate(
@ -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
# ============================================

View file

@ -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

View file

@ -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)}")

View file

@ -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:

View file

@ -292,36 +292,23 @@ def create_invitation(
emailConnector = ConnectorMessagingEmail()
if instance_label:
emailSubject = f"Einladung zur Feature-Instanz {instance_label}"
invite_text = f"der Feature-Instanz <strong>{instance_label}</strong> (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 <strong>{mandateName}</strong> beizutreten"
emailBody = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2>Sie wurden eingeladen!</h2>
<p>Hallo <strong>{display_name}</strong>,</p>
<p>Sie wurden eingeladen, {invite_text}.</p>
<p>Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:</p>
<p style="margin: 20px 0;">
<a href="{inviteUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
Einladung annehmen
</a>
</p>
<p style="color: #666; font-size: 0.9em;">
Oder kopieren Sie diesen Link in Ihren Browser:<br>
<code>{inviteUrl}</code>
</p>
<p style="color: #666; font-size: 0.9em;">
Diese Einladung ist {data.expiresInHours} Stunden gültig.
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="color: #999; font-size: 0.8em;">
Diese E-Mail wurde automatisch von PowerOn gesendet.
</p>
</body>
</html>
"""
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,
@ -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"),

View file

@ -26,7 +26,97 @@ 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 += '<p style="margin: 0 0 14px 0;">&nbsp;</p>\n'
else:
escaped = _html.escape(str(line))
paragraphsHtml += f'<p style="margin: 0 0 14px 0; color: #333333;">{escaped}</p>\n'
buttonBlock = ""
if buttonText and buttonUrl:
buttonBlock = f'''<div style="text-align: center; margin: 24px 0 8px 0;">
<a href="{_html.escape(buttonUrl)}"
style="display: inline-block; background-color: #2563eb; color: #ffffff;
font-size: 15px; font-weight: 600; text-decoration: none;
padding: 12px 32px; border-radius: 6px; mso-padding-alt: 0;">
{_html.escape(buttonText)}
</a>
</div>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #9ca3af; word-break: break-all; text-align: center;">
{_html.escape(buttonUrl)}
</p>'''
footerNote = ""
if footerText:
footerNote = f'<p style="margin: 16px 0 0 0; font-size: 13px; color: #888888;">{_html.escape(footerText)}</p>\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'<p style="margin: 4px 0 0 0; font-size: 11px; color: #b0b0b0; text-align: center;">'
f'{_html.escape(" | ".join(parts))}</p>\n'
)
except Exception:
pass
return f'''<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f7; padding: 32px 16px;">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
<!-- Header -->
<tr><td style="background-color: #1a1a2e; padding: 24px 32px;">
<h1 style="margin: 0; font-size: 18px; font-weight: 600; color: #ffffff;">PowerOn</h1>
</td></tr>
<!-- Body -->
<tr><td style="padding: 32px;">
<h2 style="margin: 0 0 20px 0; font-size: 20px; font-weight: 600; color: #1a1a2e;">{_html.escape(greeting)}</h2>
<div style="font-size: 15px; line-height: 1.6;">
{paragraphsHtml}
{buttonBlock}
</div>
{footerNote}
</td></tr>
<!-- Footer -->
<tr><td style="padding: 16px 32px; background-color: #f9fafb; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
Diese E-Mail wurde automatisch von PowerOn versendet.
</p>
{operatorLine}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
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.
@ -34,27 +124,23 @@ def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = Non
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
htmlMessage = htmlOverride
if not htmlMessage:
import html
escaped = html.escape(message)
escaped = escaped.replace('\n', '<br>\n')
htmlMessage = f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped}
</body>
</html>"""
htmlMessage = f'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family: Arial, sans-serif; line-height: 1.6;">{escaped}</body></html>'
messagingInterface = getMessagingInterface()
success = messagingInterface.send(
@ -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))
homeMandateName = f"Home {user.username}"
"""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}"
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,8 +428,39 @@ def register_user(
detail="Failed to register user"
)
# Provision Home mandate for every new user ("Home {username}")
# Check for pending invitations BEFORE provisioning.
# Search by both username AND email (email-only invitations have targetUsername=None).
hasPendingInvitations = False
validInvitations = []
try:
from modules.datamodels.datamodelInvitation import Invitation
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:
homeMandateName = f"Home {user.username}"
provisionResult = appInterface._provisionMandateForUser(
@ -332,23 +471,8 @@ def register_user(
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}")
# 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"
)
try:
companyResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateName=companyName,
planKey="STANDARD_MONTHLY",
)
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}")
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},
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."""
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.",
)
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
# Create notifications for pending invitations
for invitation in validInvitations:
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
# 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
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},
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."""
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.",
)
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
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 = 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(),
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:

View file

@ -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})

View file

@ -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}")

View file

@ -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: