From 8fe1c75288dddaaa1ab2f88a69a478c11b1635d8 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 7 May 2025 13:06:42 +0200 Subject: [PATCH] prod azure 1.0.12 --- app.py | 9 +- env_dev.env | 2 +- modules/agentEmail.py | 257 +++----- modules/lucydomInterface.py | 59 ++ modules/lucydomModel.py | 25 + routes/routeMsft.py | 617 +++++++++++------- static/31_email_preview.html | 42 ++ static/32_email_template.json | 6 + static/33_email_preview.html | 42 ++ static/34_email_template.json | 6 + static/35_email_preview.html | 42 ++ static/36_email_template.json | 6 + .../7d08aab9-a170-4975-8898-bc7e0a95488e.json | 2 +- 13 files changed, 722 insertions(+), 393 deletions(-) create mode 100644 static/31_email_preview.html create mode 100644 static/32_email_template.json create mode 100644 static/33_email_preview.html create mode 100644 static/34_email_template.json create mode 100644 static/35_email_preview.html create mode 100644 static/36_email_template.json diff --git a/app.py b/app.py index 038e2af5..a9a4c40c 100644 --- a/app.py +++ b/app.py @@ -114,7 +114,14 @@ app.add_middleware( baseDir = pathlib.Path(__file__).parent staticFolder = baseDir / "static" os.makedirs(staticFolder, exist_ok=True) -app.mount("/static", StaticFiles(directory=str(staticFolder)), name="static") + +# Mount static files with proper configuration +app.mount("/static", StaticFiles(directory=str(staticFolder), html=True), name="static") + +# Add favicon route +@app.get("/favicon.ico") +async def favicon(): + return FileResponse(str(staticFolder / "favicon.ico"), media_type="image/x-icon") # General Elements @app.get("/", tags=["General"]) diff --git a/env_dev.env b/env_dev.env index 8ecb6aed..54828aa6 100644 --- a/env_dev.env +++ b/env_dev.env @@ -3,7 +3,7 @@ # System Configuration APP_ENV_TYPE = dev APP_ENV_LABEL = Development Instance Patrick -APP_API_URL = http://localhost:8080 +APP_API_URL = http://localhost:8000 # Database Configuration System DB_SYSTEM_HOST=D:/Temp/_powerondb diff --git a/modules/agentEmail.py b/modules/agentEmail.py index effd0591..0e64dd70 100644 --- a/modules/agentEmail.py +++ b/modules/agentEmail.py @@ -7,8 +7,8 @@ import logging import json import base64 import os -import msal import requests +import msal from typing import Dict, Any, List, Optional from modules.configuration import APP_CONFIG @@ -41,11 +41,8 @@ class AgentEmail(AgentBase): self.authority = None self.scopes = ["Mail.ReadWrite", "User.Read"] - # Token storage directory - self.token_dir = './token_storage' - if not os.path.exists(self.token_dir): - os.makedirs(self.token_dir) - logger.info(f"Created token storage directory: {self.token_dir}") + # API base URL for Microsoft authentication + self.api_base_url = APP_CONFIG.get("APP_API_URL", "(no-url)") def setDependencies(self, mydom=None): """Set external dependencies for the agent.""" @@ -377,125 +374,86 @@ class AgentEmail(AgentBase): """ return html - def _getCurrentUserToken(self): + def _getCurrentUserToken(self) -> tuple: """ - Get the current user's token from the token store. - Does not attempt to initiate authentication flow. - - Returns: - Tuple of (user info, access token) or (None, None) if no valid token + Get the current user's Microsoft token using the current user context. + Returns tuple of (user_info, access_token) or (None, None) if not authenticated. """ try: - # Check if we have any token files - if not os.path.exists(self.token_dir) or not os.listdir(self.token_dir): - logger.warning("No token files found. User needs to authenticate with Microsoft.") + if not self.mydom: + logger.error("No mydom interface available") return None, None - # Find the most recently modified token file - token_files = [os.path.join(self.token_dir, f) for f in os.listdir(self.token_dir) if f.endswith('.json')] - if not token_files: + # Get token data from database + token_data = self.mydom.getMsftToken() + if not token_data: + logger.info("No Microsoft token found for user") return None, None - most_recent = max(token_files, key=os.path.getmtime) - user_id = os.path.basename(most_recent).split('.')[0] + # Verify token is still valid + if not self._verifyToken(token_data.get("access_token")): + logger.info("Token invalid, attempting refresh") + if not self._refreshToken(token_data): + logger.info("Token refresh failed") + return None, None + # Get updated token data after refresh + token_data = self.mydom.getMsftToken() + + return token_data.get("user_info"), token_data.get("access_token") - # Load the token - token_data = self._loadTokenFromFile(user_id) - if not token_data or not token_data.get("access_token"): - logger.warning(f"No valid token data for user {user_id}") - return None, None - - # Get user info from token - user_info = self._getUserInfoFromToken(token_data["access_token"]) - if not user_info: - # Try to refresh the token - if self._refreshToken(user_id): - # Load the refreshed token - token_data = self._loadTokenFromFile(user_id) - if token_data and token_data.get("access_token"): - user_info = self._getUserInfoFromToken(token_data["access_token"]) - if user_info: - return user_info, token_data["access_token"] - - logger.warning(f"Could not get user info for user {user_id}") - return None, None - - return user_info, token_data["access_token"] except Exception as e: logger.error(f"Error getting current user token: {str(e)}") return None, None - - def _loadTokenFromFile(self, user_id): - """Load token data from a file""" - filename = os.path.join(self.token_dir, f"{user_id}.json") - if os.path.exists(filename): - try: - with open(filename, 'r') as f: - return json.load(f) - except Exception as e: - logger.error(f"Error loading token file: {str(e)}") - return None - return None - - def _getUserInfoFromToken(self, access_token): - """Get user information using the access token""" - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - + + def _verifyToken(self, token: str) -> bool: + """Verify the access token is valid""" try: + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) - if response.status_code == 200: - user_data = response.json() - return { - "name": user_data.get("displayName", ""), - "email": user_data.get("userPrincipalName", ""), - "id": user_data.get("id", "") - } - else: - logger.error(f"Error getting user info: {response.status_code} - {response.text}") - return None + return response.status_code == 200 + except Exception as e: - logger.error(f"Exception getting user info: {str(e)}") - return None - - def _refreshToken(self, user_id): + logger.error(f"Error verifying token: {str(e)}") + return False + + def _refreshToken(self, token_data: Dict[str, Any]) -> bool: """Refresh the access token using the stored refresh token""" - token_data = self._loadTokenFromFile(user_id) - if not token_data or not token_data.get("refresh_token"): - logger.warning("No refresh token available") - return False - - msal_app = msal.ConfidentialClientApplication( - self.client_id, - authority=self.authority, - client_credential=self.client_secret - ) - - result = msal_app.acquire_token_by_refresh_token( - token_data["refresh_token"], - scopes=self.scopes - ) - - if "error" in result: - logger.error(f"Error refreshing token: {result.get('error')}") - return False - - # Update tokens in storage - token_data["access_token"] = result["access_token"] - if "refresh_token" in result: - token_data["refresh_token"] = result["refresh_token"] - - # Save the updated token - filename = os.path.join(self.token_dir, f"{user_id}.json") try: - with open(filename, 'w') as f: - json.dump(token_data, f) - logger.info(f"Token saved for user: {user_id}") + if not token_data or not token_data.get("refresh_token"): + logger.warning("No refresh token available") + return False + + msal_app = msal.ConfidentialClientApplication( + self.client_id, + authority=self.authority, + client_credential=self.client_secret + ) + + result = msal_app.acquire_token_by_refresh_token( + token_data["refresh_token"], + scopes=self.scopes + ) + + if "error" in result: + logger.error(f"Error refreshing token: {result.get('error')}") + return False + + # Update token data + token_data["access_token"] = result["access_token"] + if "refresh_token" in result: + token_data["refresh_token"] = result["refresh_token"] + + # Save updated token + self.mydom.saveMsftToken(token_data) + logger.info("Access token refreshed successfully") return True + except Exception as e: - logger.error(f"Error saving token file: {str(e)}") + logger.error(f"Error refreshing token: {str(e)}") return False def _createDraftEmail(self, recipient, subject, body, attachments=None): @@ -523,7 +481,7 @@ class AgentEmail(AgentBase): def _createGraphDraftEmail(self, access_token, recipient, subject, body, attachments=None): """ Create a draft email using Microsoft Graph API with fixed attachment handling. - Directly uses the document's data attribute for attachments. + Uses the complete document data for attachments. Args: access_token: Microsoft Graph access token @@ -561,56 +519,47 @@ class AgentEmail(AgentBase): email_data['attachments'] = [] for attachment in attachments: - # Get the document object doc = attachment.get('document', {}) file_name = attachment.get('name', 'attachment.file') logger.info(f"Processing attachment: {file_name}") - # Directly access the data attribute from the document - if 'data' in doc: - file_content = doc['data'] - is_base64 = doc.get('base64Encoded', False) - - # Determine content type - content_type = "application/octet-stream" - if 'mimeType' in doc: - content_type = doc['mimeType'] - elif 'contentType' in doc: - content_type = doc['contentType'] - - # Check if we need to encode the content - if not is_base64: - logger.info(f"Base64 encoding content for {file_name}") - if isinstance(file_content, str): - try: - # Check if already valid base64 - base64.b64decode(file_content) - logger.info("Content appears to be valid base64 already") - except: - # Not valid base64, encode it - logger.info("Encoding string content to base64") - file_content = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') - elif isinstance(file_content, bytes): - logger.info("Encoding bytes content to base64") - file_content = base64.b64encode(file_content).decode('utf-8') - - # Add attachment to email data - logger.info(f"Adding attachment: {file_name} ({content_type})") - attachment_data = { - '@odata.type': '#microsoft.graph.fileAttachment', - 'name': file_name, - 'contentType': content_type, - 'contentBytes': file_content - } - email_data['attachments'].append(attachment_data) - logger.info(f"Successfully added attachment: {file_name}") - else: - logger.warning(f"Document does not contain 'data' attribute: {file_name}") - # Try to find data in the fileId - if 'fileId' in doc: - logger.info(f"Found fileId: {doc['fileId']} - could implement fileId-based attachment lookup here") - # Future enhancement: implement file lookup by fileId + # Get the document data + file_content = doc.get('data') + if not file_content: + logger.warning(f"No data found for attachment: {file_name}") + continue + + # Get content type from document + content_type = doc.get('contentType', 'application/octet-stream') + is_base64 = doc.get('base64Encoded', False) + + # Handle base64 encoding if needed + if not is_base64: + logger.info(f"Base64 encoding content for {file_name}") + if isinstance(file_content, str): + try: + # Check if already valid base64 + base64.b64decode(file_content) + logger.info("Content appears to be valid base64 already") + except: + # Not valid base64, encode it + logger.info("Encoding string content to base64") + file_content = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') + elif isinstance(file_content, bytes): + logger.info("Encoding bytes content to base64") + file_content = base64.b64encode(file_content).decode('utf-8') + + # Add attachment to email data + logger.info(f"Adding attachment: {file_name} ({content_type})") + attachment_data = { + '@odata.type': '#microsoft.graph.fileAttachment', + 'name': file_name, + 'contentType': content_type, + 'contentBytes': file_content + } + email_data['attachments'].append(attachment_data) + logger.info(f"Successfully added attachment: {file_name}") # Try to create draft using drafts folder endpoint (Option 1) try: @@ -648,7 +597,7 @@ class AgentEmail(AgentBase): except Exception as e: logger.error(f"Exception creating draft email: {str(e)}", exc_info=True) - return None + return None # Factory function for the Email agent def getAgentEmail(): diff --git a/modules/lucydomInterface.py b/modules/lucydomInterface.py index b1fc5dcb..a2106d6d 100644 --- a/modules/lucydomInterface.py +++ b/modules/lucydomInterface.py @@ -11,6 +11,7 @@ from typing import Dict, Any, List, Optional, Union import importlib import hashlib +import json from modules.mimeUtils import isTextMimeType, determineContentEncoding @@ -1286,7 +1287,65 @@ class LucyDOMInterface: except Exception as e: logger.error(f"Error loading workflow state: {str(e)}") return None + + # Microsoft Login + + def getMsftToken(self) -> Optional[Dict[str, Any]]: + """Get Microsoft token data for the current user from database""" + try: + # Get token from database using current user's mandateId and userId + tokens = self.db.getRecordset("msftTokens", recordFilter={ + "mandateId": self.mandateId, + "userId": self.userId + }) + if tokens and len(tokens) > 0: + token_data = json.loads(tokens[0]["token_data"]) + logger.info(f"Retrieved Microsoft token for user {self.userId}") + return token_data + else: + logger.info(f"No Microsoft token found for user {self.userId}") + return None + + except Exception as e: + logger.error(f"Error retrieving Microsoft token: {str(e)}") + return None + + def saveMsftToken(self, token_data: Dict[str, Any]) -> bool: + """Save Microsoft token data for the current user to database""" + try: + # Check if token already exists + tokens = self.db.getRecordset("msftTokens", recordFilter={ + "mandateId": self.mandateId, + "userId": self.userId + }) + + if tokens and len(tokens) > 0: + # Update existing token + token_id = tokens[0]["id"] + updated_data = { + "token_data": json.dumps(token_data), + "updated_at": datetime.now().isoformat() + } + self.db.recordModify("msftTokens", token_id, updated_data) + logger.info(f"Updated Microsoft token for user {self.userId}") + else: + # Create new token + new_token = { + "mandateId": self.mandateId, + "userId": self.userId, + "token_data": json.dumps(token_data), + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat() + } + self.db.recordCreate("msftTokens", new_token) + logger.info(f"Saved new Microsoft token for user {self.userId}") + + return True + + except Exception as e: + logger.error(f"Error saving Microsoft token: {str(e)}") + return False # Singleton factory for LucyDOMInterface instances per context _lucydomInterfaces = {} diff --git a/modules/lucydomModel.py b/modules/lucydomModel.py index 68939580..c14ab9f3 100644 --- a/modules/lucydomModel.py +++ b/modules/lucydomModel.py @@ -78,6 +78,31 @@ class FileData(BaseModel): base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") +class MsftToken(BaseModel): + """Data model for Microsoft authentication tokens""" + id: int = Field(description="Unique ID of the token") + mandateId: int = Field(description="ID of the associated mandate") + userId: int = Field(description="ID of the user") + token_data: str = Field(description="JSON string containing the token data") + created_at: str = Field(description="Timestamp when the token was created") + updated_at: str = Field(description="Timestamp when the token was last updated") + + label: Label = Field( + default=Label(default="Microsoft Token", translations={"en": "Microsoft Token", "fr": "Jeton Microsoft"}), + description="Label for the class" + ) + + # Labels for attributes + fieldLabels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "token_data": Label(default="Token Data", translations={"en": "Token Data", "fr": "Données du jeton"}), + "created_at": Label(default="Created At", translations={"en": "Created At", "fr": "Créé le"}), + "updated_at": Label(default="Updated At", translations={"en": "Updated At", "fr": "Mis à jour le"}) + } + + # Workflow model classes class DocumentContent(BaseModel): diff --git a/routes/routeMsft.py b/routes/routeMsft.py index 1f81793d..ddbdc343 100644 --- a/routes/routeMsft.py +++ b/routes/routeMsft.py @@ -1,12 +1,11 @@ from fastapi import APIRouter, HTTPException, Depends, Request, Response, status, Cookie from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import msal -import os import logging -import sys import json -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List from datetime import datetime, timedelta +import secrets from modules.auth import getCurrentActiveUser, getUserContext from modules.configuration import APP_CONFIG @@ -44,26 +43,67 @@ app_config = { "redirect_uri": REDIRECT_URI } -# Create a simple file-based token storage -TOKEN_DIR = './token_storage' -if not os.path.exists(TOKEN_DIR): - os.makedirs(TOKEN_DIR) - logger.info(f"Created token storage directory: {TOKEN_DIR}") +async def save_token_to_file(token_data, currentUser: Dict[str, Any]): + """Save token data to database using LucyDOMInterface""" + try: + # Get current user context + mandateId, userId = await getUserContext(currentUser) + if not mandateId or not userId: + logger.error("No user context available for token storage") + return False + + # Get LucyDOM interface for current user + mydom = getLucydomInterface( + mandateId=mandateId, + userId=userId + ) + if not mydom: + logger.error("No LucyDOM interface available for token storage") + return False + + # Save token to database + success = mydom.saveMsftToken(token_data) + if success: + logger.info("Token saved successfully to database") + return True + else: + logger.error("Failed to save token to database") + return False + + except Exception as e: + logger.error(f"Error saving token: {str(e)}") + return False -def save_token_to_file(user_id: str, token_data: Dict[str, Any]): - """Save token data to a file""" - filename = os.path.join(TOKEN_DIR, f"{user_id}.json") - with open(filename, 'w') as f: - json.dump(token_data, f) - logger.info(f"Token saved for user: {user_id}") - -def load_token_from_file(user_id: str) -> Optional[Dict[str, Any]]: - """Load token data from a file""" - filename = os.path.join(TOKEN_DIR, f"{user_id}.json") - if os.path.exists(filename): - with open(filename, 'r') as f: - return json.load(f) - return None +async def load_token_from_file(currentUser: Dict[str, Any]): + """Load token data from database using LucyDOMInterface""" + try: + # Get current user context + mandateId, userId = await getUserContext(currentUser) + if not mandateId or not userId: + logger.error("No user context available for token retrieval") + return None + + # Get LucyDOM interface for current user + mydom = getLucydomInterface( + mandateId=mandateId, + userId=userId + ) + if not mydom: + logger.error("No LucyDOM interface available for token retrieval") + return None + + # Get token from database + token_data = mydom.getMsftToken() + if token_data: + logger.info("Token loaded successfully from database") + return token_data + else: + logger.info("No token found in database") + return None + + except Exception as e: + logger.error(f"Error loading token: {str(e)}") + return None def get_user_info_from_token(access_token: str) -> Optional[Dict[str, Any]]: """Get user information using the access token""" @@ -111,9 +151,9 @@ def verify_token(token: str) -> bool: logger.error(f"Exception verifying token: {str(e)}") return False -def refresh_token(user_id: str) -> bool: +async def refresh_token(user_id: str, currentUser: Dict[str, Any]) -> bool: """Refresh the access token using the stored refresh token""" - token_data = load_token_from_file(user_id) + token_data = await load_token_from_file(currentUser) if not token_data or not token_data.get("refresh_token"): logger.warning("No refresh token available") return False @@ -138,45 +178,13 @@ def refresh_token(user_id: str) -> bool: if "refresh_token" in result: token_data["refresh_token"] = result["refresh_token"] - save_token_to_file(user_id, token_data) + await save_token_to_file(token_data, currentUser) logger.info("Access token refreshed successfully") return True -def silent_login(user_id: str) -> bool: - """Try to silently log in a user using their refresh token""" - token_data = load_token_from_file(user_id) - if not token_data or not token_data.get("refresh_token"): - logger.info(f"No refresh token found for user: {user_id}") - return False - - # Try to refresh the token - msal_app = msal.ConfidentialClientApplication( - app_config["client_id"], - authority=app_config["authority"], - client_credential=app_config["client_credential"] - ) - - result = msal_app.acquire_token_by_refresh_token( - token_data["refresh_token"], - scopes=SCOPES - ) - - if "error" in result: - logger.error(f"Error refreshing token: {result.get('error')}") - return False - - # Update tokens in storage - token_data["access_token"] = result["access_token"] - if "refresh_token" in result: - token_data["refresh_token"] = result["refresh_token"] - - save_token_to_file(user_id, token_data) - - return True - @router.get("/login") async def login(): - # Modified implementation without requiring current user + """Initiate Microsoft login for the current user""" try: # Create a confidential client application msal_app = msal.ConfidentialClientApplication( @@ -185,221 +193,358 @@ async def login(): client_credential=app_config["client_credential"] ) - # Build the auth URL + # Build the auth URL with a random state + state = secrets.token_urlsafe(32) + auth_url = msal_app.get_authorization_request_url( SCOPES, - state="anonymous-user", # Use a general state since we don't have user context + state=state, # Use random state redirect_uri=app_config["redirect_uri"] ) - logger.info(f"Redirecting to Microsoft login: {auth_url[:60]}...") + logger.info(f"Redirecting to Microsoft login") return RedirectResponse(auth_url) except Exception as e: logger.error(f"Error initiating Microsoft login: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error initiating Microsoft login: {str(e)}" + detail=f"Failed to initiate Microsoft login: {str(e)}" ) - + @router.get("/auth/callback") -async def auth_callback(request: Request, code: str = None, state: str = None): - """Handle callback from Microsoft login""" +async def auth_callback(code: str, state: str, request: Request): + """Handle Microsoft OAuth callback""" try: - # Log callback for debugging - logger.info("Received callback from Microsoft login") - - if not code: - logger.error("No authorization code received in callback") - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"message": "No authorization code received"} - ) - - # Extract user and mandate info from state if available - user_id = None - mandate_id = None - - if state and state != "anonymous-user": - try: - mandate_id, user_id = state.split(":") - logger.info(f"State contains mandate_id: {mandate_id}, user_id: {user_id}") - except ValueError: - logger.warning(f"Invalid state format: {state}") - # Generate a generic user ID if state is invalid - user_id = f"user_{datetime.now().strftime('%Y%m%d%H%M%S')}" - else: - # For anonymous authentication, create a generic user ID - logger.info("Anonymous authentication (no user context)") - user_id = f"user_{datetime.now().strftime('%Y%m%d%H%M%S')}" - - # Create a confidential client application - msal_app = msal.ConfidentialClientApplication( - app_config["client_id"], - authority=app_config["authority"], - client_credential=app_config["client_credential"] + # Create MSAL app instance + app = msal.ConfidentialClientApplication( + client_id=CLIENT_ID, + client_credential=CLIENT_SECRET, + authority=AUTHORITY ) - # Get tokens using the authorization code - result = msal_app.acquire_token_by_authorization_code( - code, + # Exchange code for token + token_response = app.acquire_token_by_authorization_code( + code=code, scopes=SCOPES, - redirect_uri=app_config["redirect_uri"] + redirect_uri=REDIRECT_URI ) - if "error" in result: - logger.error(f"Error acquiring token: {result.get('error')}") - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"message": f"Error acquiring token: {result.get('error_description', result.get('error'))}"} + if "error" in token_response: + logger.error(f"Token acquisition failed: {token_response['error']}") + return HTMLResponse( + content=""" + + + Authentication Failed + + + +

Authentication Failed

+

Please try again.

+ + + + """, + status_code=400 ) - # Store user information - user_info = {} - if "id_token_claims" in result: - user_info = { - "name": result["id_token_claims"].get("name", ""), - "email": result["id_token_claims"].get("preferred_username", ""), - } - - # If we have user info from the token, use that for user_id - token_user_id = result["id_token_claims"].get("oid") or result["id_token_claims"].get("sub") - if token_user_id: - user_id = token_user_id - elif not user_id and user_info.get("email"): - # Fall back to email-based ID if no other ID is available - user_id = user_info.get("email", "user").replace("@", "_").replace(".", "_") - - # Save tokens to file - token_data = { - "access_token": result["access_token"], - "refresh_token": result.get("refresh_token", ""), - "user_info": user_info, - "timestamp": datetime.now().isoformat() - } - - # Ensure token directory exists - if not os.path.exists(TOKEN_DIR): - os.makedirs(TOKEN_DIR) - - # Save token to file - token_file = os.path.join(TOKEN_DIR, f"{user_id}.json") - with open(token_file, 'w') as f: - json.dump(token_data, f) - - logger.info(f"User authenticated: {user_info.get('email', 'unknown')}") - - # Create a success page - html_content = """ - + # Get user info from token + user_info = get_user_info_from_token(token_response["access_token"]) + if not user_info: + logger.error("Failed to get user info from token") + return HTMLResponse( + content=""" + + + Authentication Failed + + + +

Authentication Failed

+

Could not retrieve user information.

+ + + + """, + status_code=400 + ) + + # Add user info to token data + token_response["user_info"] = user_info + + # Store tokens in session storage for the frontend to pick up + response = HTMLResponse( + content=f""" - - - Authentication Successful - - - -
-

Authentication Successful

-
-

You have successfully authenticated with Microsoft.

-

You can now close this tab and return to the application.

-

Your email templates will now be able to create drafts in your mailbox.

- Close Window -
- - + + Authentication Successful + + + +

Authentication Successful

+

Welcome, {user_info.get('name', 'User')}!

+

This window will close automatically.

+ + """ - - return HTMLResponse(content=html_content) + ) - else: - logger.warning("No id_token_claims found in result") - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"message": "Failed to retrieve user information"} - ) + return response except Exception as e: - logger.error(f"Error in auth callback: {str(e)}", exc_info=True) - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"message": f"Error in auth callback: {str(e)}"} + logger.error(f"Authentication failed: {str(e)}") + return HTMLResponse( + content=""" + + + Authentication Failed + + + +

Authentication Failed

+

An error occurred during authentication.

+ + + + """, + status_code=500 ) - + @router.get("/status") -async def auth_status( - msft_user_id: Optional[str] = Cookie(None), - currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) -): +async def auth_status(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Check Microsoft authentication status""" try: - # Get user ID - if not msft_user_id: - mandateId, userId = await getUserContext(currentUser) - user_id = str(userId) - else: - user_id = msft_user_id + # Get current user context + mandateId, userId = await getUserContext(currentUser) + if not mandateId or not userId: + logger.info("No user context found") + return JSONResponse({ + "authenticated": False, + "message": "Not authenticated with Microsoft" + }) + + # Check if we have a token for the current user + token_data = await load_token_from_file(currentUser) - # Check if user has a token - token_data = load_token_from_file(user_id) if not token_data: - return JSONResponse( - content={"authenticated": False, "message": "Not authenticated with Microsoft"} + logger.info(f"No token data found for user {userId}") + return JSONResponse({ + "authenticated": False, + "message": "Not authenticated with Microsoft" + }) + + # Verify token is still valid + if not verify_token(token_data["access_token"]): + logger.info("Token invalid, attempting refresh") + # Try to refresh the token + if not await refresh_token(userId, currentUser): + logger.info("Token refresh failed") + return JSONResponse({ + "authenticated": False, + "message": "Token expired and refresh failed" + }) + # Reload token data after refresh + token_data = await load_token_from_file(currentUser) + + # Get user info from token data + user_info = token_data.get("user_info") + if not user_info: + logger.info("No user info found in token data") + return JSONResponse({ + "authenticated": False, + "message": "No user information available" + }) + + logger.info(f"User {user_info.get('name')} is authenticated") + return JSONResponse({ + "authenticated": True, + "user": user_info + }) + + except Exception as e: + logger.error(f"Error checking authentication status: {str(e)}") + return JSONResponse({ + "authenticated": False, + "message": f"Error checking authentication status: {str(e)}" + }) + +@router.post("/logout") +async def logout(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): + """Logout from Microsoft""" + try: + # Get current user context + mandateId, userId = await getUserContext(currentUser) + if not mandateId or not userId: + return JSONResponse({ + "message": "Not authenticated with Microsoft" + }) + + # Get LucyDOM interface for current user + mydom = getLucydomInterface( + mandateId=mandateId, + userId=userId + ) + if not mydom: + return JSONResponse({ + "message": "Not authenticated with Microsoft" + }) + + # Remove token from database + tokens = mydom.db.getRecordset("msftTokens", recordFilter={ + "mandateId": mandateId, + "userId": userId + }) + + if tokens and len(tokens) > 0: + mydom.db.recordDelete("msftTokens", tokens[0]["id"]) + logger.info(f"Removed Microsoft token for user {userId}") + + return JSONResponse({ + "message": "Successfully logged out from Microsoft" + }) + + except Exception as e: + logger.error(f"Error during logout: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Logout failed: {str(e)}" + ) + +@router.get("/token") +async def get_access_token(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): + """Get the current user's access token for Microsoft Graph API""" + try: + # Check if we have a token for the current user + token_data = await load_token_from_file(currentUser) + + if not token_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated with Microsoft" ) - # Check if token is valid - if not verify_token(token_data.get("access_token", "")): - # Try to refresh token - if refresh_token(user_id): - token_data = load_token_from_file(user_id) - user_info = token_data.get("user_info", {}) - return JSONResponse( - content={ - "authenticated": True, - "message": "Token refreshed successfully", - "user": user_info - } - ) - else: - return JSONResponse( - content={ - "authenticated": False, - "message": "Token expired and couldn't be refreshed" - } + # Verify token is still valid + if not verify_token(token_data["access_token"]): + # Try to refresh the token + if not await refresh_token(currentUser["id"], currentUser): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired and refresh failed" ) + # Reload token data after refresh + token_data = await load_token_from_file(currentUser) + + return JSONResponse({ + "access_token": token_data["access_token"] + }) - # Token is valid, return user info - user_info = token_data.get("user_info", {}) - return JSONResponse( - content={ - "authenticated": True, - "message": "Authenticated with Microsoft", - "user": user_info - } - ) - except Exception as e: - logger.error(f"Error checking auth status: {str(e)}") - return JSONResponse( + logger.error(f"Error getting access token: {str(e)}") + raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"message": f"Error checking auth status: {str(e)}"} + detail=f"Error getting access token: {str(e)}" ) + +@router.post("/save-token") +async def save_token(token_data: Dict[str, Any], currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): + """Save Microsoft token data from frontend""" + try: + # Save token to database + success = await save_token_to_file(token_data, currentUser) + + if success: + return JSONResponse({ + "success": True, + "message": "Token saved successfully" + }) + else: + return JSONResponse({ + "success": False, + "message": "Failed to save token" + }) + + except Exception as e: + logger.error(f"Error saving token: {str(e)}") + return JSONResponse({ + "success": False, + "message": f"Error saving token: {str(e)}" + }) + +async def generateFinalMessage(self, objUserResponse: str, objFinalDocuments: List[str], objResults: List[Dict[str, Any]]) -> Dict[str, Any]: + """Generate the final message for the workflow""" + try: + # Get list of delivered documents + matchingDocuments = [] + for result in objResults: + if "documents" in result: + for doc in result["documents"]: + if doc.get("label") in objFinalDocuments: + matchingDocuments.append(doc.get("label")) + + # Use the mydom for language-aware AI calls + finalPrompt = await self.mydom.callAi([ + {"role": "system", "content": "You are a project manager, who delivers results to a user."}, + {"role": "user", "content": f""" +Give a brief summary of what has been accomplished, referencing the initial request (objUserResponse). List only the files that have been successfully delivered (filesDelivered). Keep the message concise and professional. + +Here the data: +objUserResponse = {self.parseJson2text(objUserResponse)} +filesDelivered = {self.parseJson2text(matchingDocuments)} +""" + } + ], produceUserAnswer=True) + + # Create basic message structure with proper fields + logger.debug(f"FINAL PROMPT = {self.parseJson2text(finalPrompt)}.") + finalMessage = { + "role": "assistant", + "agentName": "Project Manager", + "content": finalPrompt, + "documents": [] # DO NOT include the results documents, already with agents + } + + logger.debug(f"FINAL MESSAGE = {self.parseJson2text(finalMessage)}.") + return finalMessage + + except Exception as e: + logger.error(f"Error generating final message: {str(e)}") + return { + "role": "assistant", + "agentName": "Project Manager", + "content": "I apologize, but there was an error generating the final message. Please check the logs for more details.", + "documents": [] + } diff --git a/static/31_email_preview.html b/static/31_email_preview.html new file mode 100644 index 00000000..2e367670 --- /dev/null +++ b/static/31_email_preview.html @@ -0,0 +1,42 @@ + + + + + + Email Preview: Verschiebung des Meetings auf Freitag + + + +
+ + + +
+ + + \ No newline at end of file diff --git a/static/32_email_template.json b/static/32_email_template.json new file mode 100644 index 00000000..852b45ec --- /dev/null +++ b/static/32_email_template.json @@ -0,0 +1,6 @@ +{ + "recipient": "peter.muster@domain.com", + "subject": "Verschiebung des Meetings auf Freitag", + "plainBody": "Sehr geehrter Herr Muster,\n\nich hoffe, es geht Ihnen gut. Ich schreibe Ihnen, um unser geplantes Meeting von 10 Uhr auf Freitag zu verschieben. Bitte lassen Sie mich wissen, ob Ihnen dieser neue Termin passt.\n\nVielen Dank f\u00fcr Ihr Verst\u00e4ndnis.\n\nMit freundlichen Gr\u00fc\u00dfen,\n\n[Ihr Name]", + "htmlBody": "

Sehr geehrter Herr Muster,

ich hoffe, es geht Ihnen gut. Ich schreibe Ihnen, um unser geplantes Meeting von 10 Uhr auf Freitag zu verschieben. Bitte lassen Sie mich wissen, ob Ihnen dieser neue Termin passt.

Vielen Dank f\u00fcr Ihr Verst\u00e4ndnis.

Mit freundlichen Gr\u00fc\u00dfen,
[Ihr Name]

" +} \ No newline at end of file diff --git a/static/33_email_preview.html b/static/33_email_preview.html new file mode 100644 index 00000000..17c0c832 --- /dev/null +++ b/static/33_email_preview.html @@ -0,0 +1,42 @@ + + + + + + Email Preview: Erneuter Versand: Verschiebung des Meetings auf Freitag + + + +
+ + + +
+ + + \ No newline at end of file diff --git a/static/34_email_template.json b/static/34_email_template.json new file mode 100644 index 00000000..5b95930f --- /dev/null +++ b/static/34_email_template.json @@ -0,0 +1,6 @@ +{ + "recipient": "patrick@motsch.ch", + "subject": "Erneuter Versand: Verschiebung des Meetings auf Freitag", + "plainBody": "Sehr geehrter Herr Motsch,\n\nich hoffe, es geht Ihnen gut. Ich schreibe Ihnen, um unser geplantes Meeting von 10 Uhr auf Freitag zu verschieben. Bitte lassen Sie mich wissen, ob Ihnen dieser neue Termin passt.\n\nVielen Dank f\u00fcr Ihr Verst\u00e4ndnis.\n\nMit freundlichen Gr\u00fc\u00dfen,\n\n[Ihr Name]", + "htmlBody": "

Sehr geehrter Herr Motsch,

ich hoffe, es geht Ihnen gut. Ich schreibe Ihnen, um unser geplantes Meeting von 10 Uhr auf Freitag zu verschieben. Bitte lassen Sie mich wissen, ob Ihnen dieser neue Termin passt.

Vielen Dank f\u00fcr Ihr Verst\u00e4ndnis.

Mit freundlichen Gr\u00fc\u00dfen,
[Ihr Name]

" +} \ No newline at end of file diff --git a/static/35_email_preview.html b/static/35_email_preview.html new file mode 100644 index 00000000..8173b7b5 --- /dev/null +++ b/static/35_email_preview.html @@ -0,0 +1,42 @@ + + + + + + Email Preview: Python Scripts Attached for Your Review + + + +
+ + + +
+ + + \ No newline at end of file diff --git a/static/36_email_template.json b/static/36_email_template.json new file mode 100644 index 00000000..cf80603f --- /dev/null +++ b/static/36_email_template.json @@ -0,0 +1,6 @@ +{ + "recipient": "example@domain.com", + "subject": "Python Scripts Attached for Your Review", + "plainBody": "Sehr geehrter Empf\u00e4nger,\n\nim Anhang finden Sie die beiden Python-Skripte, die Sie angefordert haben. Bitte z\u00f6gern Sie nicht, sich bei Fragen oder weiteren Anliegen an mich zu wenden.\n\nMit freundlichen Gr\u00fc\u00dfen,\nIhr Name", + "htmlBody": "

Sehr geehrter Empf\u00e4nger,

im Anhang finden Sie die beiden Python-Skripte, die Sie angefordert haben. Bitte z\u00f6gern Sie nicht, sich bei Fragen oder weiteren Anliegen an mich zu wenden.

Mit freundlichen Gr\u00fc\u00dfen,
Ihr Name

" +} \ No newline at end of file diff --git a/token_storage/7d08aab9-a170-4975-8898-bc7e0a95488e.json b/token_storage/7d08aab9-a170-4975-8898-bc7e0a95488e.json index 8a65bff5..723a30f3 100644 --- a/token_storage/7d08aab9-a170-4975-8898-bc7e0a95488e.json +++ b/token_storage/7d08aab9-a170-4975-8898-bc7e0a95488e.json @@ -1 +1 @@ -{"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6IlZXdlYzdDhtMTIyR19WNXF0ZU9sRGc0WjlBUkNBZkNCMHZER0hucmJWYlEiLCJhbGciOiJSUzI1NiIsIng1dCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSIsImtpZCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC82YTUxYWFlYi0yNDY3LTQxODYtOTUwNC0yYTA1YWVkYzU5MWYvIiwiaWF0IjoxNzQ2NTk5NTA5LCJuYmYiOjE3NDY1OTk1MDksImV4cCI6MTc0NjYwNTE1NywiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQWFRQVcvOFpBQUFBa2NUUldzUVFTZVV3eVY0cS91Z0w0NDhndEQwb1prZ3paKzgxVDdaN1k0VWhDV1RwREF6OUwrdlYvN2V5SW9sMG5zVXQvY2N1U3NuMjhXenlMd2szWWpvZGM3ajZrb2dGeW5hU0owcE0vTTl1VXM1NDMrQ3k4cDBZRExhTzF4djFCcDRmRVdkMUd3bDRsZ0VROUtFNndjazFMY25raWRTQUM0djA3V3k1RUw3SDV4MUNKY3cyOXYrcU9Dc1VDdkNIYnN0a2JGbzdoZ3NvY0w3b0ZuTVh3Zz09IiwiYW1yIjpbInB3ZCIsInJzYSIsIm1mYSJdLCJhcHBfZGlzcGxheW5hbWUiOiJQTSBUZXN0IC0gRW1haWwgRHJhZnQiLCJhcHBpZCI6ImM3ZTcxMTJkLTYxZGMtNGYzYS04Y2QzLTA4Y2M0Y2Q3NTA0YyIsImFwcGlkYWNyIjoiMSIsImRldmljZWlkIjoiOWE0YTM2OWEtNjBhOS00NjdlLWFjNTktODdkZGQyMDUxZGU5IiwiZmFtaWx5X25hbWUiOiJNb3RzY2giLCJnaXZlbl9uYW1lIjoiUGF0cmljayIsImlkdHlwIjoidXNlciIsImlwYWRkciI6IjE3OC4xOTcuMjIyLjE0OCIsIm5hbWUiOiJQYXRyaWNrIE1vdHNjaCIsIm9pZCI6IjdkMDhhYWI5LWExNzAtNDk3NS04ODk4LWJjN2UwYTk1NDg4ZSIsInBsYXRmIjoiMyIsInB1aWQiOiIxMDAzN0ZGRThDREQ2QTgyIiwicmgiOiIxLkFRc0E2NnBSYW1ja2hrR1ZCQ29GcnR4Wkh3TUFBQUFBQUFBQXdBQUFBQUFBQUFDRUFEQUxBQS4iLCJzY3AiOiJNYWlsLlJlYWRXcml0ZSBvcGVuaWQgcHJvZmlsZSBVc2VyLlJlYWQgZW1haWwiLCJzaWQiOiIyOTI0ZTgxMS0xMTM1LTQ0ZTItOGUxYi1kMmU2YmVhZmI3ZTUiLCJzaWduaW5fc3RhdGUiOlsia21zaSJdLCJzdWIiOiJJZzBpcDN4YWRiTGl1S3piRmd3VmhOSU1fRHpHMHdweGlFRmIySll1Y240IiwidGVuYW50X3JlZ2lvbl9zY29wZSI6IkVVIiwidGlkIjoiNmE1MWFhZWItMjQ2Ny00MTg2LTk1MDQtMmEwNWFlZGM1OTFmIiwidW5pcXVlX25hbWUiOiJwLm1vdHNjaEB2YWx1ZW9uLmNoIiwidXBuIjoicC5tb3RzY2hAdmFsdWVvbi5jaCIsInV0aSI6ImYzXy1ha2NKblVlQXhuM3o3NmdOQUEiLCJ2ZXIiOiIxLjAiLCJ3aWRzIjpbIjE1OGMwNDdhLWM5MDctNDU1Ni1iN2VmLTQ0NjU1MWE2YjVmNyIsIjliODk1ZDkyLTJjZDMtNDRjNy05ZDAyLWE2YWMyZDVlYTVjMyIsImNmMWMzOGU1LTM2MjEtNDAwNC1hN2NiLTg3OTYyNGRjZWQ3YyIsIjlmMDYyMDRkLTczYzEtNGQ0Yy04ODBhLTZlZGI5MDYwNmZkOCIsIjg5MmM1ODQyLWE5YTYtNDYzYS04MDQxLTcyYWEwOGNhM2NmNiIsImI3OWZiZjRkLTNlZjktNDY4OS04MTQzLTc2YjE5NGU4NTUwOSJdLCJ4bXNfZnRkIjoiMlN2YmlORzVSbGVucGhwdmM2SDdEU1R5WFF5UnpPTmJYOUtOQzFzZmRKSUJaWFZ5YjNCbGQyVnpkQzFrYzIxeiIsInhtc19pZHJlbCI6IjEgMTIiLCJ4bXNfc3QiOnsic3ViIjoiUjJ2RDBHMW1tYVlSQzdKWVdjSVNaVzJLRFBnTkJqQkxGbDZlTEFCX1BVTSJ9LCJ4bXNfdGNkdCI6MTQxODIxNDUwMSwieG1zX3RkYnIiOiJFVSJ9.dg3yuHyt--1GJh5mhnLy1mPkopsVhUTlPv3GpbRT9QcUcMgFnHqGqsU3Ht_hCG5XATy0fe1-cojzBTpYBuyIOBjEZtYpJb5fGcfd-lfuBxCKcYT-ApV5sfQgOEv-r5ki7OTI13MktZKrC4d63uTXmEAOOdoRsIG0UN-ZpM0iGwbWeRZdJV_2F-skZCCLpeOK63jItZkQ7spa8KH9VaU5070vSwDQEXVBuMmvDq70ql6Sw-oqlSzh-ea-pQAn0SoKVg23xMXWTvHgCFxjeveq6Q3vCsmThIXmWdQcQtWUIFYbRajW7ZMM_c1xsTOWyFMQEbEcOwmAqf93bAL_gF_DQw", "refresh_token": "1.AQsA66pRamckhkGVBCoFrtxZHy0R58fcYTpPjNMIzEzXUEyEADALAA.AgABAwEAAABVrSpeuWamRam2jAF1XRQEAwDs_wUA9P950c99CUJxojzQN3haIYdKnZObsofQW2RZsTO0E9apCt7LtcrCIp0xFEJkIYipSHIN1bAG6Jhhm4QiYb3XxIH7VtgmZSnZrZdf3QgZILRwjUKyFnFdjjmq0S0BO7InylLaZIJ2ZzOOPE8cY4xSXedyKEe3DC06Ejh7Zp9EhC6BlWdgGUCyyFNloDKv3xhUfqJ8GjQ91bo1OErWAFkH8N7CiD_f7XJQ55EV4dx7w7qHemN4aDDeG4uNjioMMuPDspIHcFZFZwzcgphHZO9uelRrlQMNEwQ8zNDNQk0f1Q_m09xGifHoMCszwoX2Z-ffaYtkcQjwGnEp4DsEQbCv_-03wHc5KlmJnTmOGgtCLSpfxl06qcyBqRVeA2cGCwhzmmF_Q81AkkAqM0MCFalT4z3b6dK3uxjs1Umu_wUa_lEKtEePKzQaeTDf1wCDWXo1UZ1oTeZESV6yGrnBnCiG6z4wRifqCdJpf6WWItYk_EyKV5Reh6kPMIret11Cacha2elopHxLTFFmEExvb2Mfu1z9NHZ_qBnXA-F05fDmvKickYspWu4CuQr2DhwJ74CD4IZ1dKFRiwHuYlw9HuTuBQjOdMy-FhAyGdhjTKHhkf4rh8GVjeza0DvCl5NJr04ubacnd2-_vGVoVNbsqUeqDWF9gKdT5Qnz5Aba9vFs4VYKjtnfrVEBEWZHZJsX5JzWVIzqfSVKmcE3ij2v9KGLw8kdcD16hfTN6wCCHYRdtMx5CVRhyBuj8KMBRRUtdEBFgG_jraeQTGoj6BnsdKPclM_TkPHhR8p-0KjfErJud-MFavGT9Y1cvsdr9TdsX_8o9y2LTcW8nXl3Vljnzq3RlZ6N4PoeQSzZNmri8MRpLuUFwJfAwxwuGemN_OIph7Npo3IQ1Tw9WeENGczplZWgbf2FdPITisdPylACrsWflH8mlfy1fEatstQb_2E2k-1vqCFYX8hYiSRbOS0kYWzYUBJ_yvRF2EUsu8yTbzw4SlJYjeNdAdmLcxjbdWXS0OS-aKv2QUvTi_htK0UKKm7V0Yj1I_bE", "user_info": {"name": "Patrick Motsch", "email": "p.motsch@valueon.ch"}, "timestamp": "2025-05-07T01:45:00.286453"} \ No newline at end of file +{"token_type": "Bearer", "scope": "Mail.ReadWrite openid profile User.Read email", "expires_in": 5258, "ext_expires_in": 5258, "access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6Il9nWEtUM1JCWWt0Nzk1VThwU0tKbThSQ3BvRkxGcHFpZ3dJb1AzekhGN0kiLCJhbGciOiJSUzI1NiIsIng1dCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSIsImtpZCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC82YTUxYWFlYi0yNDY3LTQxODYtOTUwNC0yYTA1YWVkYzU5MWYvIiwiaWF0IjoxNzQ2NjEyOTMwLCJuYmYiOjE3NDY2MTI5MzAsImV4cCI6MTc0NjYxODQ4OSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQVpRQWEvOFpBQUFBcUFIWWsrL1ZjVG5lbXJZSkZhOEZvaHhPSVZRa1hPM2FqNzRJd1F5amlxWE9KNC9HOXVFREdhLzZGS3Bjdm9aeEJYMDVXeTFFYzIyQklVTDVUWUt1MW5OR2dTUGtZU3cvOFRpSzNJbklJVlVTMlgyS25jbnFJbGhGZytWV2FEdWdicktWMTNNNXF6Q2o5MmVvU3lGejdMNXpHcXNUK3hGcS9GMEpFT2F2MG9CU0ZQT2xpbmZpRUpDVzJpMEtZbWN2IiwiYW1yIjpbInB3ZCIsIm1mYSJdLCJhcHBfZGlzcGxheW5hbWUiOiJQTSBUZXN0IC0gRW1haWwgRHJhZnQiLCJhcHBpZCI6ImM3ZTcxMTJkLTYxZGMtNGYzYS04Y2QzLTA4Y2M0Y2Q3NTA0YyIsImFwcGlkYWNyIjoiMSIsImZhbWlseV9uYW1lIjoiTW90c2NoIiwiZ2l2ZW5fbmFtZSI6IlBhdHJpY2siLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxMDkuMTY0LjI1NC4yNDkiLCJuYW1lIjoiUGF0cmljayBNb3RzY2giLCJvaWQiOiI3ZDA4YWFiOS1hMTcwLTQ5NzUtODg5OC1iYzdlMGE5NTQ4OGUiLCJwbGF0ZiI6IjMiLCJwdWlkIjoiMTAwMzdGRkU4Q0RENkE4MiIsInJoIjoiMS5BUXNBNjZwUmFtY2toa0dWQkNvRnJ0eFpId01BQUFBQUFBQUF3QUFBQUFBQUFBQ0VBREFMQUEuIiwic2NwIjoiTWFpbC5SZWFkV3JpdGUgb3BlbmlkIHByb2ZpbGUgVXNlci5SZWFkIGVtYWlsIiwic2lkIjoiMDAyMDg4MzktZjE5NS1jYTJiLTk1ODYtMTdhN2RlMzk1NTFmIiwic2lnbmluX3N0YXRlIjpbImttc2kiXSwic3ViIjoiSWcwaXAzeGFkYkxpdUt6YkZnd1ZoTklNX0R6RzB3cHhpRUZiMkpZdWNuNCIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJFVSIsInRpZCI6IjZhNTFhYWViLTI0NjctNDE4Ni05NTA0LTJhMDVhZWRjNTkxZiIsInVuaXF1ZV9uYW1lIjoicC5tb3RzY2hAdmFsdWVvbi5jaCIsInVwbiI6InAubW90c2NoQHZhbHVlb24uY2giLCJ1dGkiOiJxNldYODRvSzZrZTR0NUhRUk9LRUFBIiwidmVyIjoiMS4wIiwid2lkcyI6WyIxNThjMDQ3YS1jOTA3LTQ1NTYtYjdlZi00NDY1NTFhNmI1ZjciLCI5Yjg5NWQ5Mi0yY2QzLTQ0YzctOWQwMi1hNmFjMmQ1ZWE1YzMiLCJjZjFjMzhlNS0zNjIxLTQwMDQtYTdjYi04Nzk2MjRkY2VkN2MiLCI5ZjA2MjA0ZC03M2MxLTRkNGMtODgwYS02ZWRiOTA2MDZmZDgiLCI4OTJjNTg0Mi1hOWE2LTQ2M2EtODA0MS03MmFhMDhjYTNjZjYiLCJiNzlmYmY0ZC0zZWY5LTQ2ODktODE0My03NmIxOTRlODU1MDkiXSwieG1zX2Z0ZCI6ImFaS1didjU0QW92WXVvelQxUlpfY2lOczRoLWhueE1hV2M3SUtXLVA2Q1lCWm5KaGJtTmxZeTFrYzIxeiIsInhtc19pZHJlbCI6IjEgMTgiLCJ4bXNfc3QiOnsic3ViIjoiUjJ2RDBHMW1tYVlSQzdKWVdjSVNaVzJLRFBnTkJqQkxGbDZlTEFCX1BVTSJ9LCJ4bXNfdGNkdCI6MTQxODIxNDUwMSwieG1zX3RkYnIiOiJFVSJ9.CrvSaMBnkX0orusNqR90PHxu2TTGWTh11EGpxyyrQPsAw8JWVHiCjxrdLPHzXi1OuBia-hCnWcm-VSVnfbPDfnI_TQmslrZVXXjxOO5zOeFUnLpoiSRbN1X81r9e6lFTl6Y7G6in21g4XdH0UbP7WMq8-3xkKVY_a1bZXrtRFBY3BzOAMOOSz2UchYTG_2w2kDuSoILzh2QXX4DMIxhaFiYDz7XBeftYjse6mJU_yPJBu_2mvw4XGhpzmhT88CNIflUyE9SzDslb099etouO66J7_6TOe4YHZ_JYG52BRK6M4ZNfnM7glrgN9tVDMnxvrT-0hvMDGNK4rZao-x2ILw", "refresh_token": "1.AQsA66pRamckhkGVBCoFrtxZHy0R58fcYTpPjNMIzEzXUEyEADALAA.AgABAwEAAABVrSpeuWamRam2jAF1XRQEAwDs_wUA9P_Q_lUJCXV8u5CZh3ubTScjIL6w0fIKC_Hgr2rqK7vyFSgxIyQ8U0jA5a_nZzRV2ZDIC5RQWt9a9NJM0LOyL55xOr3K0JE3V2u4DOFih2mF7G3o_HWU530BtJO9oBSQ9Wa4b9MOs1K30t7xHGHgt3pld_l7HTzvO62xFPscwGq4s5RvkDH-uEgbDOI0zIB_Ormo_CJN1zR9C8XCnQFAwqU_5MzEIXzVwSPN9DGU4gSrYLxKfbmU8WSluHdiCgUeNkM0D9C645zofB7FV4w8lndTiKI7ne-ZF1CPnv9lsOZiQv6OPZXPRFELxahAfMpvqWDgc9aCZUMs4pdtat_TCY593okrXCbG7y5zqXkbi6WZZgnsgqC-KcETMvQK4onL98Bndyp_Xq6-GIFEoKBwrz7fnlJ4b-14WHbEAlYV2ldcnD4EX3m5at4BjevoVUuvyz4N2d5yIaozdU2Y6Hb2wRbIWvqGAc-w_6lsix98mDhuwQQ3H0klD4czL1UcORDwoy_HhRLJcHG1iuoRBjqGjrilWvDPBORMSkRzwOv5E_uONm7co6s8Tv1zdCN1sLsiREBOeGSHNk09mEg6xddA_AOL3t45g1h0xn7vL_GNtyxq3_pW5mr-7GVtrUAd5wqfI8fE44GxSTfBv4emTuygCT1E-pYid1IvRdEgUoZp3WOeUFSd80CMyfCu6hPgoByzOwbafgPEUSZaJREoyCHp_6Zs51QerlwOZ05KHLL71WAIC6u08cyRAENC3Or9XW1YrwThbVzH65sPanDeieb7G32LfO1qYlac3mQWvxWeg0rdlKLVAc7zb7JamIzJFh1SyQ255BEGFtXbaL9R8LZ4cw-okeOTDTQgMQznBWeygwhF6I7k0y6kRw", "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSJ9.eyJhdWQiOiJjN2U3MTEyZC02MWRjLTRmM2EtOGNkMy0wOGNjNGNkNzUwNGMiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vNmE1MWFhZWItMjQ2Ny00MTg2LTk1MDQtMmEwNWFlZGM1OTFmL3YyLjAiLCJpYXQiOjE3NDY2MTI5MzAsIm5iZiI6MTc0NjYxMjkzMCwiZXhwIjoxNzQ2NjE2ODMwLCJhaW8iOiJBYVFBVy84WkFBQUFoazc3ZEgvVENNeGdZVTdxMGFnaUhXSzkxY3d2alpTaWU1SURkLzB5dTF2eEI1b2hBeTRrbDR6RnRwL3FlQmRLenBNcFBzUXVCajcySFl3QVZaN1lLeXpXUFkwSWJjQ0dxNTZsS25kdDFwd3RQTHJRY2xqQnhqTGZWUVhNdXRSS1JLN1FKNzNSUjJoUGxGVWJKQ1gvU3lmWTRnZ2tKaXplVVRoQVVHOHplRUFkTG1IYjRxL09rMzVuelFTVWY1aktSQ3d4Sk85cm1nci9uY2N2SVZ2RTR3PT0iLCJuYW1lIjoiUGF0cmljayBNb3RzY2giLCJvaWQiOiI3ZDA4YWFiOS1hMTcwLTQ5NzUtODg5OC1iYzdlMGE5NTQ4OGUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJwLm1vdHNjaEB2YWx1ZW9uLmNoIiwicmgiOiIxLkFRc0E2NnBSYW1ja2hrR1ZCQ29GcnR4Wkh5MFI1OGZjWVRwUGpOTUl6RXpYVUV5RUFEQUxBQS4iLCJzaWQiOiIwMDIwODgzOS1mMTk1LWNhMmItOTU4Ni0xN2E3ZGUzOTU1MWYiLCJzdWIiOiJSMnZEMEcxbW1hWVJDN0pZV2NJU1pXMktEUGdOQmpCTEZsNmVMQUJfUFVNIiwidGlkIjoiNmE1MWFhZWItMjQ2Ny00MTg2LTk1MDQtMmEwNWFlZGM1OTFmIiwidXRpIjoicTZXWDg0b0s2a2U0dDVIUVJPS0VBQSIsInZlciI6IjIuMCJ9.Nudb1JfecVFeW_kcRfbVZsfvoCOXm6c_qdNmXsL1zC1CyWU9PnLFwNtS4Cu1c8cX4NaFB2LnImmyu9vmB_zWEoYDjv_XS-2jZp0fOumzqPpWgQ3dfCicD58aa9mD46vT439YVAN8rpEC2hkK-bZw3JWauf25U-L8zgF6g39EWXIr_Y7QHEVA20hf4ND-TBJlfJ_JZXE-TqxRYDOybayQcGKF0kSJ4Kmuu6_HTKrjRemSqLnh0Q-nRoAUReskgFdx3KEuWlAjTQGaMI_wMIv_8Z5taSECM8MQNal66503Ifpk4lvqTtZM_6yQusv7q49Glg7FVk37nIx82yWKfiIqVw", "client_info": "eyJ1aWQiOiI3ZDA4YWFiOS1hMTcwLTQ5NzUtODg5OC1iYzdlMGE5NTQ4OGUiLCJ1dGlkIjoiNmE1MWFhZWItMjQ2Ny00MTg2LTk1MDQtMmEwNWFlZGM1OTFmIn0", "id_token_claims": {"aud": "c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c", "iss": "https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f/v2.0", "iat": 1746612930, "nbf": 1746612930, "exp": 1746616830, "aio": "AaQAW/8ZAAAAhk77dH/TCMxgYU7q0agiHWK91cwvjZSie5IDd/0yu1vxB5ohAy4kl4zFtp/qeBdKzpMpPsQuBj72HYwAVZ7YKyzWPY0IbcCGq56lKndt1pwtPLrQcljBxjLfVQXMutRKRK7QJ73RR2hPlFUbJCX/SyfY4ggkJizeUThAUG8zeEAdLmHb4q/Ok35nzQSUf5jKRCwxJO9rmgr/nccvIVvE4w==", "name": "Patrick Motsch", "oid": "7d08aab9-a170-4975-8898-bc7e0a95488e", "preferred_username": "p.motsch@valueon.ch", "rh": "1.AQsA66pRamckhkGVBCoFrtxZHy0R58fcYTpPjNMIzEzXUEyEADALAA.", "sid": "00208839-f195-ca2b-9586-17a7de39551f", "sub": "R2vD0G1mmaYRC7JYWcISZW2KDPgNBjBLFl6eLAB_PUM", "tid": "6a51aaeb-2467-4186-9504-2a05aedc591f", "uti": "q6WX84oK6ke4t5HQROKEAA", "ver": "2.0"}, "user_info": {"name": "Patrick Motsch", "email": "p.motsch@valueon.ch", "id": "7d08aab9-a170-4975-8898-bc7e0a95488e"}} \ No newline at end of file