diff --git a/.env.example b/.env.example index e6d2fa2..c96b844 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,31 @@ -# Application +# ============================================================================= +# CMDB Insight - Environment Configuration +# ============================================================================= +# Copy this file to .env and update the values according to your environment +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Application Configuration +# ----------------------------------------------------------------------------- PORT=3001 NODE_ENV=development +FRONTEND_URL=http://localhost:5173 +# Application Branding +APP_NAME=CMDB Insight +APP_TAGLINE=Management console for Jira Assets +APP_COPYRIGHT=© {year} Zuyderland Medisch Centrum + +# ----------------------------------------------------------------------------- # Database Configuration +# ----------------------------------------------------------------------------- # Use 'postgres' for PostgreSQL or 'sqlite' for SQLite (default) DATABASE_TYPE=postgres + +# Option 1: Use DATABASE_URL (recommended for PostgreSQL) DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb -# Or use individual components: + +# Option 2: Use individual components (alternative to DATABASE_URL) # DATABASE_HOST=localhost # DATABASE_PORT=5432 # DATABASE_NAME=cmdb @@ -14,17 +33,71 @@ DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb # DATABASE_PASSWORD=cmdb-dev # DATABASE_SSL=false +# ----------------------------------------------------------------------------- # Jira Assets Configuration +# ----------------------------------------------------------------------------- JIRA_HOST=https://jira.zuyderland.nl -JIRA_PAT=your_personal_access_token_here JIRA_SCHEMA_ID=your_schema_id -JIRA_API_BATCH_SIZE=20 -# Claude API -ANTHROPIC_API_KEY=your_anthropic_api_key_here +# Jira Service Account Token (for read operations: sync, fetching data) +# This token is used for all read operations from Jira Assets. +# Write operations (saving changes) require users to configure their own PAT in profile settings. +JIRA_SERVICE_ACCOUNT_TOKEN=your_service_account_personal_access_token +JIRA_API_BATCH_SIZE=15 -# Tavily API Key (verkrijgbaar via https://tavily.com) -TAVILY_API_KEY=your_tavily_api_key_here +# Jira Authentication Method +# Note: User Personal Access Tokens (PAT) are NOT configured here - users configure them in their profile settings +# The service account token above is used for read operations, user PATs are used for write operations. -# OpenAI API -OPENAI_API_KEY=your_openai_api_key_here +# Options: 'pat' (Personal Access Token) or 'oauth' (OAuth 2.0) +JIRA_AUTH_METHOD=pat + + +# Option 2: OAuth 2.0 Authentication +# Required when JIRA_AUTH_METHOD=oauth +# JIRA_OAUTH_CLIENT_ID=your_oauth_client_id +# JIRA_OAUTH_CLIENT_SECRET=your_oauth_client_secret +# JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback +# JIRA_OAUTH_SCOPES=READ WRITE + +# Legacy: JIRA_OAUTH_ENABLED (for backward compatibility) +# JIRA_OAUTH_ENABLED=false + +# ----------------------------------------------------------------------------- +# Local Authentication System +# ----------------------------------------------------------------------------- +# Enable local authentication (email/password login) +LOCAL_AUTH_ENABLED=true + +# Allow public registration (optional, default: false) +REGISTRATION_ENABLED=false + +# Session Configuration +SESSION_SECRET=change-this-secret-in-production +SESSION_DURATION_HOURS=24 + +# Password Requirements +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_NUMBER=true +PASSWORD_REQUIRE_SPECIAL=false + +# Email Configuration (for invitations, password resets, etc.) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your-email-password +SMTP_FROM=noreply@example.com + +# Encryption Key (for encrypting sensitive user data like API keys) +# Generate with: openssl rand -base64 32 +ENCRYPTION_KEY=your-32-byte-encryption-key-base64 + +# Initial Administrator User (optional - created on first migration) +# If not set, you'll need to create an admin user manually +ADMIN_USERNAME=administrator +ADMIN_PASSWORD=SecurePassword123! +ADMIN_EMAIL=admin@example.com +ADMIN_DISPLAY_NAME=Administrator \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a2ad8a6..d089d41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,9 +193,9 @@ JIRA_ATTR_APPLICATION_CLUSTER= JIRA_ATTR_APPLICATION_TYPE= # AI Classification -ANTHROPIC_API_KEY= -OPENAI_API_KEY= # Optional: alternative to Claude -DEFAULT_AI_PROVIDER=claude # 'claude' or 'openai' +# Note: AI API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY), +# default AI provider, and web search are configured per-user in profile settings, +# not in environment variables # Server PORT=3001 diff --git a/backend/data/effort-calculation-config.json b/backend/data/effort-calculation-config.json index 859c0d4..74f1624 100644 --- a/backend/data/effort-calculation-config.json +++ b/backend/data/effort-calculation-config.json @@ -1,902 +1,1551 @@ { - "governanceModelRules": [ - { - "governanceModel": "Regiemodel A", - "applicationTypeRules": { + "metadata": { + "version": "25", + "description": "FTE-configuratie Dienstencatalogus Applicatiebeheer v25", + "date": "2025-12-23", + "formula": "Werkelijke FTE = basis_fte_avg * schaalfactor * complexiteit * dynamiek" + }, + "regiemodellen": { + "A": { + "name": "Centraal Beheer ICMT", + "description": "ICMT voert volledig beheer uit (TAB + FAB + IM)", + "allowedBia": [ + "D", + "E", + "F" + ], + "defaultFte": { + "min": 0.15, + "max": 0.3 + }, + "applicationTypes": { "Applicatie": { - "applicationTypes": [ - "Applicatie", - "Connected Device" - ], - "businessImpactRules": { - "F": [ - { - "result": 0.3, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.5, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.3 - } - ], - "E": [ - { - "result": 0.2, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.3, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.2 - } - ], - "D": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ], - "C": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ], - "B": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ], - "A": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ] + "defaultFte": { + "min": 0.15, + "max": 0.3 }, - "default": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ] - }, - "Platform": { - "applicationTypes": "Platform", - "businessImpactRules": { - "F": [ - { - "result": 0.6, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 1, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 1 - } - ], - "E": [ - { - "result": 0.25, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.4, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.25 - } - ], - "D": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.25, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ], - "C": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.25, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ], - "B": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.25, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ], - "A": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.25, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ] - }, - "default": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.25, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ] - }, - "Workload": { - "applicationTypes": "Workload", - "businessImpactRules": { + "biaLevels": { "F": { - "result": 0.2 + "description": "Zeer kritiek - Levensbedreigende impact", + "defaultFte": { + "min": 0.3, + "max": 0.5 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.5, + "max": 1 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.3, + "max": 0.5 + } + } + } }, "E": { - "result": 0.12 + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.2, + "max": 0.3 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.3, + "max": 0.5 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.2, + "max": 0.3 + } + } + } }, "D": { - "result": 0.08 + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.1, + "max": 0.2 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.15, + "max": 0.3 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.1, + "max": 0.2 + } + } + } + } + } + }, + "Platform": { + "defaultFte": { + "min": 0.2, + "max": 0.4 + }, + "biaLevels": { + "F": { + "description": "Zeer kritiek - Levensbedreigende impact", + "defaultFte": { + "min": 0.4, + "max": 0.6 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.6, + "max": 1 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.4, + "max": 0.6 + } + } + } + }, + "E": { + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.25, + "max": 0.4 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.4, + "max": 0.6 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.25, + "max": 0.4 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.15, + "max": 0.25 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.25, + "max": 0.4 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.15, + "max": 0.25 + } + } + } + } + } + }, + "Workload": { + "defaultFte": { + "min": 0.08, + "max": 0.15 + }, + "biaLevels": { + "F": { + "description": "Zeer kritiek - Levensbedreigende impact", + "defaultFte": { + "min": 0.2, + "max": 0.35 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.2, + "max": 0.35 + } + } + } + }, + "E": { + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.12, + "max": 0.2 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.12, + "max": 0.2 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.08, + "max": 0.15 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + } + } }, "C": { - "result": 0.04 - }, - "B": { - "result": 0.04 - }, - "A": { - "result": 0.04 + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.04, + "max": 0.08 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.04, + "max": 0.08 + } + } + } } + } + }, + "Connected Device": { + "note": "Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J", + "requiresManualAssessment": true, + "defaultFte": { + "min": 0.05, + "max": 0.15 }, - "default": { - "result": 0.04 + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.05, + "max": 0.15 + } + } + } + } } } } }, - { - "governanceModel": "Regiemodel B", - "applicationTypeRules": { + "B": { + "name": "Federatief Beheer", + "description": "ICMT doet TAB + IM, business doet FAB met ICMT-coaching", + "allowedBia": [ + "C", + "D", + "E" + ], + "defaultFte": { + "min": 0.05, + "max": 0.15 + }, + "applicationTypes": { "Applicatie": { - "applicationTypes": [ - "Applicatie", - "Connected Device" - ], - "businessImpactRules": { - "F": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ], - "E": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ], - "D": [ - { - "result": 0.08, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.1, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.08 - } - ], - "C": { - "result": 0.04, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - "B": { - "result": 0.04, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - "A": { - "result": 0.04, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - } + "defaultFte": { + "min": 0.05, + "max": 0.15 }, - "default": [ - { - "result": 0.04, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.04, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.04 - } - ] - }, - "Platform": { - "applicationTypes": "Platform", - "businessImpactRules": { - "F": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.2, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ], - "E": [ - { - "result": 0.15, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.2, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.15 - } - ], - "D": [ - { - "result": 0.1, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.15, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.1 - } - ], - "C": [ - { - "result": 0.06, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.08, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.06 - } - ], - "B": [ - { - "result": 0.06, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.08, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.06 - } - ], - "A": [ - { - "result": 0.06, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.08, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.06 - } - ] - }, - "default": [ - { - "result": 0.06, - "conditions": { - "hostingType": [ - "Azure (IaaS)", - "Azure (PaaS)", - "PoC (Saas)", - "SaaS" - ] - } - }, - { - "result": 0.08, - "conditions": { - "hostingType": [ - "On-premises", - "PoC (On-premises)" - ] - } - }, - { - "result": 0.06 - } - ] - }, - "Workload": { - "applicationTypes": "Workload", - "businessImpactRules": { - "F": { - "result": 0.08 - }, + "biaLevels": { "E": { - "result": 0.08 + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.1, + "max": 0.2 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.15, + "max": 0.3 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.1, + "max": 0.2 + } + } + } }, "D": { - "result": 0.05 + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.08, + "max": 0.15 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.1, + "max": 0.2 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + } + } }, "C": { - "result": 0.02 - }, - "B": { - "result": 0.02 - }, - "A": { - "result": 0.02 + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.04, + "max": 0.08 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.05, + "max": 0.1 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.04, + "max": 0.08 + } + } + } } + } + }, + "Platform": { + "defaultFte": { + "min": 0.08, + "max": 0.18 }, - "default": { - "result": 0.02 + "biaLevels": { + "E": { + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.15, + "max": 0.25 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.2, + "max": 0.35 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.15, + "max": 0.25 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.1, + "max": 0.18 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.15, + "max": 0.25 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.1, + "max": 0.18 + } + } + } + }, + "C": { + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.06, + "max": 0.12 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.06, + "max": 0.12 + } + } + } + } + } + }, + "Workload": { + "note": "ICMT-aandeel; business levert aanvullend eigen FTE voor FAB", + "defaultFte": { + "min": 0.03, + "max": 0.08 + }, + "biaLevels": { + "E": { + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.08, + "max": 0.12 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.12 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.05, + "max": 0.08 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.05, + "max": 0.08 + } + } + } + }, + "C": { + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.02, + "max": 0.05 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.02, + "max": 0.05 + } + } + } + } + } + }, + "Connected Device": { + "note": "Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J", + "requiresManualAssessment": true, + "defaultFte": { + "min": 0.03, + "max": 0.1 + }, + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.03, + "max": 0.1 + } + } + } + } } } } }, - { - "governanceModel": "Regiemodel C", - "applicationTypeRules": { + "B+": { + "name": "Gescheiden Beheer", + "description": "ICMT doet TAB + IM, business doet FAB zelfstandig (zonder coaching)", + "allowedBia": [ + "C", + "D", + "E" + ], + "defaultFte": { + "min": 0.04, + "max": 0.12 + }, + "note": "FTE-waarden zijn circa 20-30% lager dan Model B (geen coaching)", + "applicationTypes": { "Applicatie": { - "applicationTypes": [ - "Applicatie", - "Connected Device" - ], - "businessImpactRules": { - "F": { - "result": 0.25 - }, + "defaultFte": { + "min": 0.04, + "max": 0.12 + }, + "biaLevels": { "E": { - "result": 0.15 + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.08, + "max": 0.15 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.12, + "max": 0.22 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + } + } }, "D": { - "result": 0.08 + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.06, + "max": 0.11 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.06, + "max": 0.11 + } + } + } }, "C": { - "result": 0.04 - }, - "B": { - "result": 0.04 - }, - "A": { - "result": 0.04 + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.03, + "max": 0.06 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.04, + "max": 0.08 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.03, + "max": 0.06 + } + } + } } - }, - "default": { - "result": 0.04 } }, "Platform": { - "applicationTypes": "Platform", - "businessImpactRules": { - "F": { - "result": 0.35 - }, + "defaultFte": { + "min": 0.06, + "max": 0.14 + }, + "biaLevels": { "E": { - "result": 0.2 + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.12, + "max": 0.19 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.15, + "max": 0.26 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.12, + "max": 0.19 + } + } + } }, "D": { - "result": 0.12 + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.08, + "max": 0.14 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.12, + "max": 0.19 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.14 + } + } + } }, "C": { - "result": 0.06 - }, - "B": { - "result": 0.06 - }, - "A": { - "result": 0.06 + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.05, + "max": 0.09 + }, + "hosting": { + "OnPrem": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer" + ], + "fte": { + "min": 0.06, + "max": 0.11 + } + }, + "SaaS": { + "hostingValues": [ + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.05, + "max": 0.09 + } + } + } } - }, - "default": { - "result": 0.6 } }, "Workload": { - "applicationTypes": "Workload", - "businessImpactRules": { - "F": { - "result": 0.15 - }, + "note": "ICMT-aandeel; business levert volledig eigen FTE voor FAB (geen coaching)", + "defaultFte": { + "min": 0.02, + "max": 0.05 + }, + "biaLevels": { "E": { - "result": 0.1 + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.05, + "max": 0.08 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.05, + "max": 0.08 + } + } + } }, "D": { - "result": 0.06 + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.03, + "max": 0.05 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.03, + "max": 0.05 + } + } + } }, "C": { - "result": 0.03 - }, - "B": { - "result": 0.03 - }, - "A": { - "result": 0.03 + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.01, + "max": 0.03 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.03 + } + } + } } + } + }, + "Connected Device": { + "note": "Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J", + "requiresManualAssessment": true, + "defaultFte": { + "min": 0.02, + "max": 0.08 }, - "default": { - "result": 0.03 + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.02, + "max": 0.08 + } + } + } + } } } } }, - { - "governanceModel": "Regiemodel D", - "applicationTypeRules": { + "C": { + "name": "Uitbesteed met ICMT-Regie", + "description": "Leverancier doet TAB, ICMT doet IM/regie + FAB (BIA-afhankelijk)", + "allowedBia": [ + "C", + "D", + "E", + "F" + ], + "defaultFte": { + "min": 0.06, + "max": 0.15 + }, + "note": "FAB-niveau: Volledig (E-F), Uitgebreid (D), Basis (C)", + "applicationTypes": { "Applicatie": { - "applicationTypes": [ - "Applicatie", - "Connected Device" - ], - "businessImpactRules": {}, - "default": { - "result": 0.01 + "defaultFte": { + "min": 0.06, + "max": 0.15 + }, + "biaLevels": { + "F": { + "description": "Zeer kritiek - Levensbedreigende impact (FAB-niveau: Volledig)", + "defaultFte": { + "min": 0.25, + "max": 0.5 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.25, + "max": 0.5 + } + } + } + }, + "E": { + "description": "Kritiek - Grote impact op zorgprocessen (FAB-niveau: Volledig)", + "defaultFte": { + "min": 0.15, + "max": 0.25 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.15, + "max": 0.25 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact (FAB-niveau: Uitgebreid)", + "defaultFte": { + "min": 0.08, + "max": 0.15 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + } + } + }, + "C": { + "description": "Standaard - Gemiddelde impact (FAB-niveau: Basis)", + "defaultFte": { + "min": 0.04, + "max": 0.08 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.04, + "max": 0.08 + } + } + } + } } }, "Platform": { - "applicationTypes": "Platform", - "businessImpactRules": {}, - "default": { - "result": 0.02 + "defaultFte": { + "min": 0.1, + "max": 0.25 + }, + "biaLevels": { + "F": { + "description": "Zeer kritiek - Levensbedreigende impact (IM/Regie focus: Intensief)", + "defaultFte": { + "min": 0.35, + "max": 0.5 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.35, + "max": 0.5 + } + } + } + }, + "E": { + "description": "Kritiek - Grote impact op zorgprocessen (IM/Regie focus: Hoog)", + "defaultFte": { + "min": 0.2, + "max": 0.35 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.2, + "max": 0.35 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact (IM/Regie focus: Standaard)", + "defaultFte": { + "min": 0.12, + "max": 0.2 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.12, + "max": 0.2 + } + } + } + }, + "C": { + "description": "Standaard - Gemiddelde impact (IM/Regie focus: Basis)", + "defaultFte": { + "min": 0.06, + "max": 0.12 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.06, + "max": 0.12 + } + } + } + } } }, "Workload": { - "applicationTypes": "Workload", - "businessImpactRules": {}, - "default": { - "result": 0.01 + "defaultFte": { + "min": 0.04, + "max": 0.1 + }, + "biaLevels": { + "F": { + "description": "Zeer kritiek - Levensbedreigende impact", + "defaultFte": { + "min": 0.15, + "max": 0.25 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.15, + "max": 0.25 + } + } + } + }, + "E": { + "description": "Kritiek - Grote impact op zorgprocessen", + "defaultFte": { + "min": 0.08, + "max": 0.15 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.08, + "max": 0.15 + } + } + } + }, + "D": { + "description": "Belangrijk - Significante impact", + "defaultFte": { + "min": 0.05, + "max": 0.1 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.05, + "max": 0.1 + } + } + } + }, + "C": { + "description": "Standaard - Gemiddelde impact", + "defaultFte": { + "min": 0.03, + "max": 0.06 + }, + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.03, + "max": 0.06 + } + } + } + } + } + }, + "Connected Device": { + "note": "Handmatige beoordeling vereist conform Beheer Readiness Checklist sectie J", + "requiresManualAssessment": true, + "defaultFte": { + "min": 0.03, + "max": 0.1 + }, + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.03, + "max": 0.1 + } + } + } + } } } } }, - { - "governanceModel": "Regiemodel E", - "applicationTypeRules": {}, - "default": { - "result": 0.01 + "D": { + "name": "Decentraal met Business-Regie", + "description": "Business of decentrale IT regisseert, leverancier doet TAB, ICMT alleen CMDB + advies", + "allowedBia": [ + "A", + "B", + "C" + ], + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "note": "Vaste FTE ongeacht BIA en Hosting - alleen CMDB-registratie en review", + "applicationTypes": { + "Applicatie": { + "fixedFte": true, + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "description": "Alle BIA-niveaus (A, B, C)", + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } + }, + "Platform": { + "fixedFte": true, + "notRecommended": true, + "note": "Niet aanbevolen voor Platforms vanwege governance-risico", + "defaultFte": { + "min": 0.02, + "max": 0.04 + }, + "biaLevels": { + "_all": { + "description": "Alle BIA-niveaus (A, B, C)", + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.02, + "max": 0.04 + } + } + } + } + } + }, + "Workload": { + "fixedFte": true, + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "description": "Alle BIA-niveaus (A, B, C)", + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } + }, + "Connected Device": { + "fixedFte": true, + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } + } } }, - { - "governanceModel": "Regiemodel B+", - "applicationTypeRules": { - "New Application Type 1": { - "applicationTypes": [ - "New Type", - "Connected Device", - "Applicatie" - ], - "businessImpactRules": {} + "E": { + "name": "Volledig Decentraal Beheer", + "description": "Business voert volledig beheer uit, ICMT alleen CMDB + jaarlijkse review", + "allowedBia": [ + "A", + "B" + ], + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "note": "Vaste FTE ongeacht BIA en Hosting - alleen CMDB-registratie en jaarlijkse review", + "applicationTypes": { + "Applicatie": { + "fixedFte": true, + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "description": "Alle BIA-niveaus (A, B)", + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } }, - "New Application Type 2": { - "applicationTypes": [ - "New Type", - "Platform" - ], - "businessImpactRules": {} + "Platform": { + "fixedFte": true, + "notRecommended": true, + "note": "Model E is niet geschikt voor Platforms", + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } }, - "New Application Type 3": { - "applicationTypes": [ - "New Type", - "Workload" - ], - "businessImpactRules": {} + "Workload": { + "fixedFte": true, + "notRecommended": true, + "note": "Model E is niet geschikt voor Workloads (vereist Platform)", + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } + }, + "Connected Device": { + "fixedFte": true, + "defaultFte": { + "min": 0.01, + "max": 0.02 + }, + "biaLevels": { + "_all": { + "hosting": { + "_all": { + "hostingValues": [ + "On-Premises", + "Azure - Eigen beheer", + "Azure - Delegated Management", + "Extern (SaaS)" + ], + "fte": { + "min": 0.01, + "max": 0.02 + } + } + } + } + } } } } - ], - "default": { - "result": 0.05 + }, + "validationRules": { + "biaRegieModelConstraints": { + "A": [ + "D", + "E", + "F" + ], + "B": [ + "C", + "D", + "E" + ], + "B+": [ + "C", + "D", + "E" + ], + "C": [ + "C", + "D", + "E", + "F" + ], + "D": [ + "A", + "B", + "C" + ], + "E": [ + "A", + "B" + ] + }, + "platformRestrictions": [ + { + "regiemodel": "D", + "applicationType": "Platform", + "warning": "Niet aanbevolen vanwege governance-risico" + }, + { + "regiemodel": "E", + "applicationType": "Platform", + "warning": "Niet geschikt voor Platforms" + }, + { + "regiemodel": "E", + "applicationType": "Workload", + "warning": "Niet geschikt voor Workloads" + } + ] } } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index d5b7acf..702ecad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,10 +9,15 @@ "build": "tsc", "start": "node dist/index.js", "generate-schema": "tsx scripts/generate-schema.ts", + "migrate": "tsx scripts/run-migrations.ts", + "check-admin": "tsx scripts/check-admin-user.ts", "migrate:sqlite-to-postgres": "tsx scripts/migrate-sqlite-to-postgres.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", + "@types/bcrypt": "^6.0.0", + "@types/nodemailer": "^7.0.5", + "bcrypt": "^6.0.0", "better-sqlite3": "^11.6.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -20,6 +25,7 @@ "express": "^4.21.1", "express-rate-limit": "^7.4.1", "helmet": "^8.0.0", + "nodemailer": "^7.0.12", "openai": "^6.15.0", "pg": "^8.13.1", "winston": "^3.17.0", diff --git a/backend/scripts/check-admin-user.ts b/backend/scripts/check-admin-user.ts new file mode 100644 index 0000000..6027ac2 --- /dev/null +++ b/backend/scripts/check-admin-user.ts @@ -0,0 +1,109 @@ +/** + * Check Admin User + * + * Script to check if the admin user exists and verify credentials. + * + * Usage: + * tsx scripts/check-admin-user.ts + */ + +import { getAuthDatabase } from '../src/services/database/migrations.js'; +import { userService } from '../src/services/userService.js'; +import { roleService } from '../src/services/roleService.js'; + +async function main() { + try { + const db = getAuthDatabase(); + + console.log('\n=== Checking Admin User ===\n'); + + // Check environment variables + const adminEmail = process.env.ADMIN_EMAIL; + const adminUsername = process.env.ADMIN_USERNAME || 'admin'; + const adminPassword = process.env.ADMIN_PASSWORD; + + console.log('Environment Variables:'); + console.log(` ADMIN_EMAIL: ${adminEmail || 'NOT SET'}`); + console.log(` ADMIN_USERNAME: ${adminUsername}`); + console.log(` ADMIN_PASSWORD: ${adminPassword ? '***SET***' : 'NOT SET'}`); + console.log(''); + + // Check if users table exists + try { + const userCount = await db.queryOne<{ count: number }>( + 'SELECT COUNT(*) as count FROM users' + ); + console.log(`Total users in database: ${userCount?.count || 0}`); + } catch (error) { + console.error('❌ Users table does not exist. Run migrations first: npm run migrate'); + await db.close(); + process.exit(1); + } + + // Try to find user by email + if (adminEmail) { + const userByEmail = await userService.getUserByEmail(adminEmail); + if (userByEmail) { + console.log(`✓ User found by email: ${adminEmail}`); + console.log(` - ID: ${userByEmail.id}`); + console.log(` - Username: ${userByEmail.username}`); + console.log(` - Display Name: ${userByEmail.display_name}`); + console.log(` - Active: ${userByEmail.is_active}`); + console.log(` - Email Verified: ${userByEmail.email_verified}`); + + // Check roles + const roles = await roleService.getUserRoles(userByEmail.id); + console.log(` - Roles: ${roles.map(r => r.name).join(', ') || 'None'}`); + + // Test password if provided + if (adminPassword) { + const isValid = await userService.verifyPassword(adminPassword, userByEmail.password_hash); + console.log(` - Password verification: ${isValid ? '✓ VALID' : '✗ INVALID'}`); + } + } else { + console.log(`✗ User NOT found by email: ${adminEmail}`); + } + } + + // Try to find user by username + const userByUsername = await userService.getUserByUsername(adminUsername); + if (userByUsername) { + console.log(`✓ User found by username: ${adminUsername}`); + console.log(` - ID: ${userByUsername.id}`); + console.log(` - Email: ${userByUsername.email}`); + console.log(` - Display Name: ${userByUsername.display_name}`); + console.log(` - Active: ${userByUsername.is_active}`); + console.log(` - Email Verified: ${userByUsername.email_verified}`); + + // Check roles + const roles = await roleService.getUserRoles(userByUsername.id); + console.log(` - Roles: ${roles.map(r => r.name).join(', ') || 'None'}`); + + // Test password if provided + if (adminPassword) { + const isValid = await userService.verifyPassword(adminPassword, userByUsername.password_hash); + console.log(` - Password verification: ${isValid ? '✓ VALID' : '✗ INVALID'}`); + } + } else { + console.log(`✗ User NOT found by username: ${adminUsername}`); + } + + // List all users + const allUsers = await db.query('SELECT id, email, username, display_name, is_active, email_verified FROM users'); + if (allUsers && allUsers.length > 0) { + console.log(`\n=== All Users (${allUsers.length}) ===`); + for (const user of allUsers) { + const roles = await roleService.getUserRoles(user.id); + console.log(` - ${user.email} (${user.username}) - Active: ${user.is_active}, Verified: ${user.email_verified}, Roles: ${roles.map(r => r.name).join(', ') || 'None'}`); + } + } + + await db.close(); + console.log('\n✓ Check completed\n'); + } catch (error) { + console.error('✗ Error:', error); + process.exit(1); + } +} + +main(); diff --git a/backend/scripts/run-migrations.ts b/backend/scripts/run-migrations.ts new file mode 100644 index 0000000..0c1caea --- /dev/null +++ b/backend/scripts/run-migrations.ts @@ -0,0 +1,27 @@ +/** + * Run Database Migrations + * + * Standalone script to run database migrations manually. + * + * Usage: + * npm run migrate + * or + * tsx scripts/run-migrations.ts + */ + +import { runMigrations } from '../src/services/database/migrations.js'; +import { logger } from '../src/services/logger.js'; + +async function main() { + try { + console.log('Starting database migrations...'); + await runMigrations(); + console.log('✓ Database migrations completed successfully'); + process.exit(0); + } catch (error) { + console.error('✗ Migration failed:', error); + process.exit(1); + } +} + +main(); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index e5be63f..599567c 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -30,12 +30,12 @@ interface Config { jiraHost: string; jiraSchemaId: string; + // Jira Service Account Token (for read operations: sync, fetching data) + jiraServiceAccountToken: string; + // Jira Authentication Method ('pat' or 'oauth') jiraAuthMethod: JiraAuthMethod; - // Jira Personal Access Token (used when jiraAuthMethod = 'pat') - jiraPat: string; - // Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth') jiraOAuthClientId: string; jiraOAuthClientSecret: string; @@ -45,14 +45,9 @@ interface Config { // Session Configuration sessionSecret: string; - // AI API Keys - anthropicApiKey: string; - openaiApiKey: string; - defaultAIProvider: 'claude' | 'openai'; - - // Web Search API (Tavily) - tavilyApiKey: string; - enableWebSearch: boolean; + // AI Configuration + // Note: API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY), default AI provider, + // and web search are now configured per-user in their profile settings, not in environment variables // Application port: number; @@ -60,6 +55,9 @@ interface Config { isDevelopment: boolean; isProduction: boolean; frontendUrl: string; + appName: string; + appTagline: string; + appCopyright: string; // API Configuration jiraApiBatchSize: number; @@ -69,9 +67,9 @@ function getOptionalEnvVar(name: string, defaultValue: string = ''): string { return process.env[name] || defaultValue; } -// Helper to determine auth method with backward compatibility + // Helper to determine auth method function getJiraAuthMethod(): JiraAuthMethod { - // Check new JIRA_AUTH_METHOD first + // Check JIRA_AUTH_METHOD first const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase(); if (authMethod === 'oauth') return 'oauth'; if (authMethod === 'pat') return 'pat'; @@ -80,14 +78,12 @@ function getJiraAuthMethod(): JiraAuthMethod { const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true'; if (oauthEnabled) return 'oauth'; - // Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist - const hasPat = !!getOptionalEnvVar('JIRA_PAT'); + // Default to 'oauth' if OAuth credentials exist, otherwise 'pat' const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'); - if (hasPat) return 'pat'; if (hasOAuthCredentials) return 'oauth'; - // Default to 'pat' (will show warning during validation) + // Default to 'pat' (users configure PAT in their profile) return 'pat'; } @@ -96,12 +92,12 @@ export const config: Config = { jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'), jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'), + // Jira Service Account Token (for read operations: sync, fetching data) + jiraServiceAccountToken: getOptionalEnvVar('JIRA_SERVICE_ACCOUNT_TOKEN'), + // Jira Authentication Method jiraAuthMethod: getJiraAuthMethod(), - // Jira Personal Access Token (for PAT authentication) - jiraPat: getOptionalEnvVar('JIRA_PAT'), - // Jira OAuth 2.0 Configuration (for OAuth authentication) jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'), jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'), @@ -111,21 +107,15 @@ export const config: Config = { // Session Configuration sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'), - // AI API Keys - anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'), - openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'), - defaultAIProvider: (getOptionalEnvVar('DEFAULT_AI_PROVIDER', 'claude') as 'claude' | 'openai'), - - // Web Search API (Tavily) - tavilyApiKey: getOptionalEnvVar('TAVILY_API_KEY'), - enableWebSearch: getOptionalEnvVar('ENABLE_WEB_SEARCH', 'false').toLowerCase() === 'true', - // Application port: parseInt(getOptionalEnvVar('PORT', '3001'), 10), nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'), isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development', isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production', frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'), + appName: getOptionalEnvVar('APP_NAME', 'CMDB Insight'), + appTagline: getOptionalEnvVar('APP_TAGLINE', 'Management console for Jira Assets'), + appCopyright: getOptionalEnvVar('APP_COPYRIGHT', '© {year} Zuyderland Medisch Centrum'), // API Configuration jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10), @@ -139,9 +129,8 @@ export function validateConfig(): void { console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`); if (config.jiraAuthMethod === 'pat') { - if (!config.jiraPat) { - missingVars.push('JIRA_PAT (required for PAT authentication)'); - } + // JIRA_PAT is configured in user profiles, not in ENV + warnings.push('JIRA_AUTH_METHOD=pat - users must configure PAT in their profile settings'); } else if (config.jiraAuthMethod === 'oauth') { if (!config.jiraOAuthClientId) { missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)'); @@ -156,7 +145,14 @@ export function validateConfig(): void { // General required config if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID'); - if (!config.anthropicApiKey) warnings.push('ANTHROPIC_API_KEY not set - AI classification disabled'); + + // Service account token warning (not required, but recommended for sync operations) + if (!config.jiraServiceAccountToken) { + warnings.push('JIRA_SERVICE_ACCOUNT_TOKEN not configured - sync and read operations may not work. Users can still use their personal PAT for reads as fallback.'); + } + + // AI API keys are configured in user profiles, not in ENV + warnings.push('AI API keys must be configured in user profile settings'); if (warnings.length > 0) { warnings.forEach(w => console.warn(`Warning: ${w}`)); diff --git a/backend/src/generated/jira-types.ts b/backend/src/generated/jira-types.ts index 06ffcc5..16a527f 100644 --- a/backend/src/generated/jira-types.ts +++ b/backend/src/generated/jira-types.ts @@ -44,7 +44,7 @@ export interface ApplicationComponent extends BaseCMDBObject { updated: string | null; description: string | null; // * Application description status: string | null; // Application Lifecycle Management - confluenceSpace: number | null; + confluenceSpace: string | number | null; // Can be URL string (from Confluence link) or number (legacy) zenyaID: number | null; zenyaURL: string | null; customDevelopment: boolean | null; // Is er sprake van eigen programmatuur? diff --git a/backend/src/index.ts b/backend/src/index.ts index aedd157..a960401 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,10 +14,15 @@ import referenceDataRouter from './routes/referenceData.js'; import dashboardRouter from './routes/dashboard.js'; import configurationRouter from './routes/configuration.js'; import authRouter, { authMiddleware } from './routes/auth.js'; +import usersRouter from './routes/users.js'; +import rolesRouter from './routes/roles.js'; +import userSettingsRouter from './routes/userSettings.js'; +import profileRouter from './routes/profile.js'; import searchRouter from './routes/search.js'; import cacheRouter from './routes/cache.js'; import objectsRouter from './routes/objects.js'; import schemaRouter from './routes/schema.js'; +import { runMigrations } from './services/database/migrations.js'; // Validate configuration validateConfig(); @@ -55,13 +60,49 @@ app.use((req, res, next) => { // Auth middleware - extract session info for all requests app.use(authMiddleware); -// Set user token on CMDBService for each request (for user-specific OAuth) -app.use((req, res, next) => { - // Set user's OAuth token if available +// Set user token and settings on services for each request +app.use(async (req, res, next) => { + // Set user's OAuth token if available (for OAuth sessions) if (req.accessToken) { cmdbService.setUserToken(req.accessToken); } + // Set user's Jira PAT and AI keys if user is authenticated and has local account + if (req.user && 'id' in req.user) { + try { + const { userSettingsService } = await import('./services/userSettingsService.js'); + const settings = await userSettingsService.getUserSettings(req.user.id); + + if (settings?.jira_pat) { + // Use user's Jira PAT from profile settings (preferred for writes) + cmdbService.setUserToken(settings.jira_pat); + } else if (config.jiraServiceAccountToken) { + // Fallback to service account token if user doesn't have PAT configured + // This allows writes to work when JIRA_SERVICE_ACCOUNT_TOKEN is set in .env + cmdbService.setUserToken(config.jiraServiceAccountToken); + logger.debug('Using service account token as fallback (user PAT not configured)'); + } else { + // No token available - clear token + cmdbService.setUserToken(null); + } + + // Store user settings in request for services to access + (req as any).userSettings = settings; + } catch (error) { + // If user settings can't be loaded, try service account token as fallback + logger.debug('Failed to load user settings:', error); + if (config.jiraServiceAccountToken) { + cmdbService.setUserToken(config.jiraServiceAccountToken); + logger.debug('Using service account token as fallback (user settings load failed)'); + } else { + cmdbService.setUserToken(null); + } + } + } else { + // No user authenticated - clear token + cmdbService.setUserToken(null); + } + // Clear token after response is sent res.on('finish', () => { cmdbService.clearUserToken(); @@ -80,7 +121,7 @@ app.get('/health', async (req, res) => { timestamp: new Date().toISOString(), dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data', jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null, - aiConfigured: !!config.anthropicApiKey, + aiConfigured: true, // AI is configured per-user in profile settings cache: { isWarm: cacheStatus.isWarm, objectCount: cacheStatus.totalObjects, @@ -98,6 +139,10 @@ app.get('/api/config', (req, res) => { // API routes app.use('/api/auth', authRouter); +app.use('/api/users', usersRouter); +app.use('/api/roles', rolesRouter); +app.use('/api/user-settings', userSettingsRouter); +app.use('/api/profile', profileRouter); app.use('/api/applications', applicationsRouter); app.use('/api/classifications', classificationsRouter); app.use('/api/reference-data', referenceDataRouter); @@ -127,14 +172,24 @@ const PORT = config.port; app.listen(PORT, async () => { logger.info(`Server running on http://localhost:${PORT}`); logger.info(`Environment: ${config.nodeEnv}`); - logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`); - logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`); + logger.info(`AI Classification: Configured per-user in profile settings`); + logger.info(`Jira Assets: ${config.jiraSchemaId ? 'Schema configured - users configure PAT in profile' : 'Schema not configured'}`); - // Initialize sync engine if using Jira Assets - if (config.jiraPat && config.jiraSchemaId) { + // Run database migrations + try { + await runMigrations(); + logger.info('Database migrations completed'); + } catch (error) { + logger.error('Failed to run database migrations', error); + } + + // Initialize sync engine if Jira schema is configured + // Note: Sync engine will only sync when users with configured Jira PATs make requests + // This prevents unauthorized Jira API calls + if (config.jiraSchemaId) { try { await syncEngine.initialize(); - logger.info('Sync Engine: Initialized and running'); + logger.info('Sync Engine: Initialized (sync on-demand per user request)'); } catch (error) { logger.error('Failed to initialize sync engine', error); } diff --git a/backend/src/middleware/authorization.ts b/backend/src/middleware/authorization.ts new file mode 100644 index 0000000..33804b9 --- /dev/null +++ b/backend/src/middleware/authorization.ts @@ -0,0 +1,115 @@ +/** + * Authorization Middleware + * + * Middleware functions for route protection based on authentication and permissions. + */ + +import { Request, Response, NextFunction } from 'express'; +import { authService, type SessionUser } from '../services/authService.js'; +import { roleService } from '../services/roleService.js'; +import { logger } from '../services/logger.js'; + +// Extend Express Request to include user info +declare global { + namespace Express { + interface Request { + sessionId?: string; + user?: SessionUser; + accessToken?: string; + } + } +} + +/** + * Middleware to require authentication + */ +export function requireAuth(req: Request, res: Response, next: NextFunction) { + const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId; + + if (!sessionId) { + return res.status(401).json({ error: 'Authentication required' }); + } + + // Get session user + authService.getSession(sessionId) + .then(session => { + if (!session) { + return res.status(401).json({ error: 'Invalid or expired session' }); + } + + // Check if it's a local user session + if ('id' in session.user) { + req.sessionId = sessionId; + req.user = session.user as SessionUser; + req.accessToken = session.accessToken; + next(); + } else { + // OAuth-only session (Jira user without local account) + // For now, allow through but user won't have permissions + req.sessionId = sessionId; + req.accessToken = session.accessToken; + next(); + } + }) + .catch(error => { + logger.error('Auth middleware error:', error); + res.status(500).json({ error: 'Authentication check failed' }); + }); +} + +/** + * Middleware to require a specific role + */ +export function requireRole(roleName: string) { + return async (req: Request, res: Response, next: NextFunction) => { + if (!req.user || !('id' in req.user)) { + return res.status(403).json({ error: 'Permission denied' }); + } + + const hasRole = await roleService.userHasRole(req.user.id, roleName); + if (!hasRole) { + return res.status(403).json({ error: `Role '${roleName}' required` }); + } + + next(); + }; +} + +/** + * Middleware to require a specific permission + */ +export function requirePermission(permissionName: string) { + return async (req: Request, res: Response, next: NextFunction) => { + if (!req.user || !('id' in req.user)) { + return res.status(403).json({ error: 'Permission denied' }); + } + + const hasPermission = await roleService.userHasPermission(req.user.id, permissionName); + if (!hasPermission) { + return res.status(403).json({ error: `Permission '${permissionName}' required` }); + } + + next(); + }; +} + +/** + * Middleware to check permission (optional, doesn't fail if missing) + * Sets req.hasPermission flag + */ +export function checkPermission(permissionName: string) { + return async (req: Request, res: Response, next: NextFunction) => { + if (req.user && 'id' in req.user) { + const hasPermission = await roleService.userHasPermission(req.user.id, permissionName); + (req as any).hasPermission = hasPermission; + } else { + (req as any).hasPermission = false; + } + next(); + }; +} + +/** + * Middleware to require admin role + */ +export const requireAdmin = requireRole('administrator'); diff --git a/backend/src/routes/applications.ts b/backend/src/routes/applications.ts index 971654a..c4e8b10 100644 --- a/backend/src/routes/applications.ts +++ b/backend/src/routes/applications.ts @@ -7,13 +7,17 @@ import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../se import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js'; import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js'; import { getQueryString, getParamString } from '../utils/queryHelpers.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import type { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js'; import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); -// Search applications with filters -router.post('/search', async (req: Request, res: Response) => { +// All routes require authentication +router.use(requireAuth); + +// Search applications with filters (requires search permission) +router.post('/search', requirePermission('search'), async (req: Request, res: Response) => { try { const { filters, page = 1, pageSize = 25 } = req.body as { filters: SearchFilters; @@ -356,9 +360,22 @@ router.get('/:id', async (req: Request, res: Response) => { } }); -// Update application with conflict detection -router.put('/:id', async (req: Request, res: Response) => { +// Update application with conflict detection (requires edit permission) +router.put('/:id', requirePermission('edit_applications'), async (req: Request, res: Response) => { try { + // Check if user has Jira PAT configured OR service account token is available (required for write operations) + const userSettings = (req as any).userSettings; + const { config } = await import('../config/env.js'); + + // Allow writes if user has PAT OR service account token is configured + if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) { + res.status(403).json({ + error: 'Jira PAT not configured', + message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.' + }); + return; + } + const id = getParamString(req, 'id'); const { updates, _jiraUpdatedAt } = req.body as { updates?: { @@ -468,9 +485,22 @@ router.put('/:id', async (req: Request, res: Response) => { } }); -// Force update (ignore conflicts) -router.put('/:id/force', async (req: Request, res: Response) => { +// Force update (ignore conflicts) (requires edit permission) +router.put('/:id/force', requirePermission('edit_applications'), async (req: Request, res: Response) => { try { + // Check if user has Jira PAT configured OR service account token is available (required for write operations) + const userSettings = (req as any).userSettings; + const { config } = await import('../config/env.js'); + + // Allow writes if user has PAT OR service account token is configured + if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) { + res.status(403).json({ + error: 'Jira PAT not configured', + message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.' + }); + return; + } + const id = getParamString(req, 'id'); const updates = req.body; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 313670b..9f05de5 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,7 +1,10 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { authService, JiraUser } from '../services/authService.js'; +import { authService, type SessionUser, type JiraUser } from '../services/authService.js'; +import { userService } from '../services/userService.js'; +import { roleService } from '../services/roleService.js'; import { config } from '../config/env.js'; import { logger } from '../services/logger.js'; +import { getAuthDatabase } from '../services/database/migrations.js'; const router = Router(); @@ -10,55 +13,179 @@ declare global { namespace Express { interface Request { sessionId?: string; - user?: JiraUser; + user?: SessionUser | JiraUser; accessToken?: string; } } } // Get auth configuration -router.get('/config', (req: Request, res: Response) => { - const authMethod = authService.getAuthMethod(); +router.get('/config', async (req: Request, res: Response) => { + // JIRA_AUTH_METHOD is only for backend Jira API configuration, NOT for application authentication + // Application authentication is ALWAYS via local auth or OAuth + // Users authenticate to the application, then their PAT/OAuth token is used for Jira API writes + // JIRA_SERVICE_ACCOUNT_TOKEN is used for Jira API reads + + // Check if users exist in database (if migrations have run) + let hasUsers = false; + try { + const db = getAuthDatabase(); + const userCount = await db.queryOne<{ count: number }>( + 'SELECT COUNT(*) as count FROM users' + ); + hasUsers = (userCount?.count || 0) > 0; + await db.close(); + } catch (error) { + // If table doesn't exist yet, hasUsers stays false + } + + // Local auth is ALWAYS enabled for application authentication + // (unless explicitly disabled via LOCAL_AUTH_ENABLED=false) + // This allows users to create accounts and log in + const localAuthEnabled = process.env.LOCAL_AUTH_ENABLED !== 'false'; + + // OAuth is enabled if configured + const oauthEnabled = authService.isOAuthEnabled(); + + // Service accounts are NOT used for application authentication + // They are only for Jira API read access (JIRA_SERVICE_ACCOUNT_TOKEN in .env) + // serviceAccountEnabled should always be false for authentication purposes + + // authMethod is 'local' if local auth is enabled, 'oauth' if only OAuth, or 'none' if both disabled + let authMethod: 'local' | 'oauth' | 'none' = 'none'; + if (localAuthEnabled && oauthEnabled) { + authMethod = 'local'; // Default to local, user can choose + } else if (localAuthEnabled) { + authMethod = 'local'; + } else if (oauthEnabled) { + authMethod = 'oauth'; + } + res.json({ - // Configured authentication method ('pat', 'oauth', or 'none') + // Application branding + appName: config.appName, + appTagline: config.appTagline, + appCopyright: config.appCopyright, + // Application authentication method (always 'local' or 'oauth', never 'pat') + // 'pat' is only for backend Jira API configuration, not user authentication authMethod, - // Legacy fields for backward compatibility - oauthEnabled: authService.isOAuthEnabled(), - serviceAccountEnabled: authService.isUsingServiceAccount(), + // Authentication options + oauthEnabled, + serviceAccountEnabled: false, // Service accounts are NOT for app authentication + localAuthEnabled, // Jira host for display purposes jiraHost: config.jiraHost, }); }); // Get current user (check if logged in) -router.get('/me', (req: Request, res: Response) => { - const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId; +router.get('/me', async (req: Request, res: Response) => { + // The sessionId should already be set by authMiddleware from cookies + const sessionId = req.sessionId || req.headers['x-session-id'] as string || req.cookies?.sessionId; + + logger.debug(`[GET /me] SessionId: ${sessionId ? sessionId.substring(0, 8) + '...' : 'none'}, Cookies: ${JSON.stringify(req.cookies)}`); + + // Service accounts are NOT used for application authentication + // They are only used for Jira API access (configured in .env as JIRA_SERVICE_ACCOUNT_TOKEN) + // Application authentication requires a real user session (local or OAuth) if (!sessionId) { - // If OAuth not enabled, allow anonymous access with service account - if (authService.isUsingServiceAccount() && !authService.isOAuthEnabled()) { - return res.json({ - authenticated: true, - authMethod: 'service-account', - user: { - accountId: 'service-account', - displayName: 'Service Account', - }, - }); + // No session = not authenticated + // Service account mode is NOT a valid authentication method for the application + return res.json({ authenticated: false }); + } + + try { + const session = await authService.getSession(sessionId); + if (!session) { + return res.json({ authenticated: false }); } + + // Determine auth method from session + let authMethod = 'local'; + if ('accountId' in session.user) { + authMethod = 'oauth'; + } else if ('id' in session.user) { + authMethod = 'local'; + } + + // For local users, ensure we have all required fields + let userData = session.user; + if ('id' in session.user) { + // Local user - ensure proper format + userData = { + id: session.user.id, + email: session.user.email || session.user.emailAddress, + username: session.user.username, + displayName: session.user.displayName, + emailAddress: session.user.email || session.user.emailAddress, + roles: session.user.roles || [], + permissions: session.user.permissions || [], + }; + } + + res.json({ + authenticated: true, + authMethod, + user: userData, + }); + } catch (error) { + logger.error('Error getting session:', error); return res.json({ authenticated: false }); } +}); - const user = authService.getUser(sessionId); - if (!user) { - return res.json({ authenticated: false }); +// Local login (email/password) +router.post('/login', async (req: Request, res: Response) => { + if (!authService.isLocalAuthEnabled()) { + return res.status(400).json({ error: 'Local authentication is not enabled' }); } - res.json({ - authenticated: true, - authMethod: 'oauth', - user, - }); + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + try { + const ipAddress = req.ip || req.socket.remoteAddress || undefined; + const userAgent = req.get('user-agent') || undefined; + + const { sessionId, user } = await authService.localLogin(email, password, ipAddress, userAgent); + + // Set session cookie + // Note: When using Vite proxy, cookies work correctly as the proxy forwards them + // In development, use 'lax' for same-site requests (localhost:5173 -> localhost:3001 via proxy) + // In production, use 'lax' for security + const cookieOptions: any = { + httpOnly: true, + secure: config.isProduction, + sameSite: 'lax' as const, + path: '/', // Make cookie available for all paths + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }; + + // In development, don't set domain (defaults to current host) + // This allows the cookie to work with the Vite proxy + if (!config.isDevelopment) { + // In production, you might want to set domain explicitly if needed + // cookieOptions.domain = '.yourdomain.com'; + } + + res.cookie('sessionId', sessionId, cookieOptions); + + logger.debug(`[Local Login] Session cookie set: sessionId=${sessionId.substring(0, 8)}..., path=/, maxAge=24h, sameSite=lax`); + + res.json({ + success: true, + sessionId, + user, + }); + } catch (error) { + logger.error('Local login error:', error); + const message = error instanceof Error ? error.message : 'Login failed'; + res.status(401).json({ error: message }); + } }); // Initiate OAuth login @@ -102,21 +229,41 @@ router.get('/callback', async (req: Request, res: Response) => { } try { + const ipAddress = req.ip || req.socket.remoteAddress || undefined; + const userAgent = req.get('user-agent') || undefined; + // Exchange code for tokens const { sessionId, user } = await authService.exchangeCodeForTokens( String(code), - String(state) + String(state), + ipAddress, + userAgent ); logger.info(`OAuth login successful for: ${user.displayName}`); // Set session cookie - res.cookie('sessionId', sessionId, { + // Note: When using Vite proxy, cookies work correctly as the proxy forwards them + // In development, use 'lax' for same-site requests (localhost:5173 -> localhost:3001 via proxy) + // In production, use 'lax' for security + const cookieOptions: any = { httpOnly: true, secure: config.isProduction, - sameSite: 'lax', + sameSite: 'lax' as const, + path: '/', // Make cookie available for all paths maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + }; + + // In development, don't set domain (defaults to current host) + // This allows the cookie to work with the Vite proxy + if (!config.isDevelopment) { + // In production, you might want to set domain explicitly if needed + // cookieOptions.domain = '.yourdomain.com'; + } + + res.cookie('sessionId', sessionId, cookieOptions); + + logger.debug(`[OAuth Login] Session cookie set: sessionId=${sessionId.substring(0, 8)}..., path=/, maxAge=24h, sameSite=lax`); // Redirect to frontend with session info res.redirect(`${config.frontendUrl}?login=success`); @@ -128,16 +275,16 @@ router.get('/callback', async (req: Request, res: Response) => { }); // Logout -router.post('/logout', (req: Request, res: Response) => { +router.post('/logout', async (req: Request, res: Response) => { const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId; if (sessionId) { - authService.logout(sessionId); + await authService.logout(sessionId); } - // Clear cookies - res.clearCookie('sessionId'); - res.clearCookie('oauth_state'); + // Clear cookies (must use same path as when setting) + res.clearCookie('sessionId', { path: '/' }); + res.clearCookie('oauth_state', { path: '/' }); res.json({ success: true }); }); @@ -159,37 +306,183 @@ router.post('/refresh', async (req: Request, res: Response) => { } }); +// Forgot password +router.post('/forgot-password', async (req: Request, res: Response) => { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + try { + await userService.generatePasswordResetToken(email); + // Always return success to prevent email enumeration + res.json({ success: true, message: 'If an account exists with this email, a password reset link has been sent.' }); + } catch (error) { + logger.error('Forgot password error:', error); + // Still return success to prevent email enumeration + res.json({ success: true, message: 'If an account exists with this email, a password reset link has been sent.' }); + } +}); + +// Reset password +router.post('/reset-password', async (req: Request, res: Response) => { + const { token, password } = req.body; + + if (!token || !password) { + return res.status(400).json({ error: 'Token and password are required' }); + } + + // Validate password requirements + const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10); + const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true'; + const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true'; + const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true'; + + if (password.length < minLength) { + return res.status(400).json({ error: `Password must be at least ${minLength} characters long` }); + } + if (requireUppercase && !/[A-Z]/.test(password)) { + return res.status(400).json({ error: 'Password must contain at least one uppercase letter' }); + } + if (requireLowercase && !/[a-z]/.test(password)) { + return res.status(400).json({ error: 'Password must contain at least one lowercase letter' }); + } + if (requireNumber && !/[0-9]/.test(password)) { + return res.status(400).json({ error: 'Password must contain at least one number' }); + } + + try { + const success = await userService.resetPasswordWithToken(token, password); + if (success) { + res.json({ success: true, message: 'Password reset successfully' }); + } else { + res.status(400).json({ error: 'Invalid or expired token' }); + } + } catch (error) { + logger.error('Reset password error:', error); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + +// Verify email +router.post('/verify-email', async (req: Request, res: Response) => { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ error: 'Token is required' }); + } + + try { + const success = await userService.verifyEmail(token); + if (success) { + res.json({ success: true, message: 'Email verified successfully' }); + } else { + res.status(400).json({ error: 'Invalid or expired token' }); + } + } catch (error) { + logger.error('Verify email error:', error); + res.status(500).json({ error: 'Failed to verify email' }); + } +}); + +// Get invitation token info +router.get('/invitation/:token', async (req: Request, res: Response) => { + const { token } = req.params; + + try { + const user = await userService.validateInvitationToken(token); + if (!user) { + return res.status(400).json({ error: 'Invalid or expired invitation token' }); + } + + res.json({ + valid: true, + user: { + email: user.email, + username: user.username, + display_name: user.display_name, + }, + }); + } catch (error) { + logger.error('Validate invitation error:', error); + res.status(500).json({ error: 'Failed to validate invitation' }); + } +}); + +// Accept invitation +router.post('/accept-invitation', async (req: Request, res: Response) => { + const { token, password } = req.body; + + if (!token || !password) { + return res.status(400).json({ error: 'Token and password are required' }); + } + + // Validate password requirements + const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10); + const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true'; + const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true'; + const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true'; + + if (password.length < minLength) { + return res.status(400).json({ error: `Password must be at least ${minLength} characters long` }); + } + if (requireUppercase && !/[A-Z]/.test(password)) { + return res.status(400).json({ error: 'Password must contain at least one uppercase letter' }); + } + if (requireLowercase && !/[a-z]/.test(password)) { + return res.status(400).json({ error: 'Password must contain at least one lowercase letter' }); + } + if (requireNumber && !/[0-9]/.test(password)) { + return res.status(400).json({ error: 'Password must contain at least one number' }); + } + + try { + const user = await userService.acceptInvitation(token, password); + if (user) { + res.json({ success: true, message: 'Invitation accepted successfully', user }); + } else { + res.status(400).json({ error: 'Invalid or expired invitation token' }); + } + } catch (error) { + logger.error('Accept invitation error:', error); + res.status(500).json({ error: 'Failed to accept invitation' }); + } +}); + // Middleware to extract session and attach user to request -export function authMiddleware(req: Request, res: Response, next: NextFunction) { +export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId; + // Debug logging for cookie issues + if (req.path === '/api/auth/me') { + logger.debug(`[authMiddleware] Path: ${req.path}, Cookies: ${JSON.stringify(req.cookies)}, SessionId from cookie: ${req.cookies?.sessionId}, SessionId from header: ${req.headers['x-session-id']}`); + } + if (sessionId) { - const session = authService.getSession(sessionId); - if (session) { - req.sessionId = sessionId; - req.user = session.user; - req.accessToken = session.accessToken; + try { + const session = await authService.getSession(sessionId); + if (session) { + req.sessionId = sessionId; + req.user = session.user; + req.accessToken = session.accessToken; + } else { + logger.debug(`[authMiddleware] Session not found for sessionId: ${sessionId.substring(0, 8)}...`); + } + } catch (error) { + logger.error('Auth middleware error:', error); + } + } else { + if (req.path === '/api/auth/me') { + logger.debug(`[authMiddleware] No sessionId found in cookies or headers for ${req.path}`); } } next(); } -// Middleware to require authentication -export function requireAuth(req: Request, res: Response, next: NextFunction) { - // If OAuth is enabled, require a valid session - if (authService.isOAuthEnabled()) { - if (!req.user) { - return res.status(401).json({ error: 'Authentication required' }); - } - } - // If only service account is configured, allow through - else if (!authService.isUsingServiceAccount()) { - return res.status(503).json({ error: 'No authentication method configured' }); - } - - next(); -} +// Re-export authorization middleware for convenience +export { requireAuth, requireRole, requirePermission, requireAdmin } from '../middleware/authorization.js'; export default router; diff --git a/backend/src/routes/cache.ts b/backend/src/routes/cache.ts index 4a4b206..adb3a87 100644 --- a/backend/src/routes/cache.ts +++ b/backend/src/routes/cache.ts @@ -8,12 +8,17 @@ import { Router, Request, Response } from 'express'; import { cacheStore } from '../services/cacheStore.js'; import { syncEngine } from '../services/syncEngine.js'; import { logger } from '../services/logger.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import { getQueryString, getParamString } from '../utils/queryHelpers.js'; import { OBJECT_TYPES } from '../generated/jira-schema.js'; import type { CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); +// All routes require authentication and manage_settings permission +router.use(requireAuth); +router.use(requirePermission('manage_settings')); + // Get cache status router.get('/status', async (req: Request, res: Response) => { try { diff --git a/backend/src/routes/classifications.ts b/backend/src/routes/classifications.ts index 97187e2..4b06361 100644 --- a/backend/src/routes/classifications.ts +++ b/backend/src/routes/classifications.ts @@ -4,21 +4,50 @@ import { dataService } from '../services/dataService.js'; import { databaseService } from '../services/database.js'; import { logger } from '../services/logger.js'; import { config } from '../config/env.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js'; const router = Router(); -// Get AI classification for an application -router.post('/suggest/:id', async (req: Request, res: Response) => { +// All routes require authentication +router.use(requireAuth); + +// Get AI classification for an application (requires search permission) +router.post('/suggest/:id', requirePermission('search'), async (req: Request, res: Response) => { try { const id = getParamString(req, 'id'); - // Get provider from query parameter or request body, default to config - const provider = (getQueryString(req, 'provider') as AIProvider) || (req.body.provider as AIProvider) || config.defaultAIProvider; + // Get provider from query parameter, request body, or user settings (default to 'claude') + const userSettings = (req as any).userSettings; + + // Check if AI is enabled for this user + if (!userSettings?.ai_enabled) { + res.status(403).json({ + error: 'AI functionality is disabled', + message: 'AI functionality is not enabled in your profile settings. Please enable it in your user settings.' + }); + return; + } + + // Check if user has selected an AI provider + if (!userSettings?.ai_provider) { + res.status(403).json({ + error: 'AI provider not configured', + message: 'Please select an AI provider (Claude or OpenAI) in your user settings.' + }); + return; + } + + const userDefaultProvider = userSettings.ai_provider === 'anthropic' ? 'claude' : userSettings.ai_provider === 'openai' ? 'openai' : 'claude'; + const provider = (getQueryString(req, 'provider') as AIProvider) || (req.body.provider as AIProvider) || (userDefaultProvider as AIProvider); - if (!aiService.isConfigured(provider)) { + // Check if user has API key for the selected provider + const hasApiKey = (provider === 'claude' && userSettings.ai_provider === 'anthropic' && !!userSettings.ai_api_key) || + (provider === 'openai' && userSettings.ai_provider === 'openai' && !!userSettings.ai_api_key); + + if (!hasApiKey) { res.status(503).json({ error: 'AI classification not available', - message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured. Please set ${provider === 'claude' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'}.` + message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API key is not configured. Please configure the API key in your user settings.` }); return; } @@ -29,8 +58,15 @@ router.post('/suggest/:id', async (req: Request, res: Response) => { return; } + // Get user API keys from user settings (already loaded above) + const userApiKeys = userSettings ? { + anthropic: userSettings.ai_provider === 'anthropic' ? userSettings.ai_api_key : undefined, + openai: userSettings.ai_provider === 'openai' ? userSettings.ai_api_key : undefined, + tavily: userSettings.tavily_api_key, + } : undefined; + logger.info(`Generating AI classification for: ${application.name} using ${provider}`); - const suggestion = await aiService.classifyApplication(application, provider); + const suggestion = await aiService.classifyApplication(application, provider, userApiKeys); res.json(suggestion); } catch (error) { @@ -92,12 +128,16 @@ router.get('/stats', async (req: Request, res: Response) => { }); // Check if AI is available - returns available providers -router.get('/ai-status', (req: Request, res: Response) => { +router.get('/ai-status', requireAuth, (req: Request, res: Response) => { const availableProviders = aiService.getAvailableProviders(); + // Get user's default provider from settings (default to 'claude') + const userSettings = (req as any).userSettings; + const userDefaultProvider = userSettings?.ai_provider === 'anthropic' ? 'claude' : userSettings?.ai_provider === 'openai' ? 'openai' : 'claude'; + res.json({ available: availableProviders.length > 0, providers: availableProviders, - defaultProvider: config.defaultAIProvider, + defaultProvider: userDefaultProvider, claude: { available: aiService.isProviderConfigured('claude'), model: 'claude-sonnet-4-20250514', @@ -128,8 +168,8 @@ router.get('/prompt/:id', async (req: Request, res: Response) => { } }); -// Chat with AI about an application -router.post('/chat/:id', async (req: Request, res: Response) => { +// Chat with AI about an application (requires search permission) +router.post('/chat/:id', requirePermission('search'), async (req: Request, res: Response) => { try { const id = getParamString(req, 'id'); const { message, conversationId, provider: requestProvider } = req.body; @@ -139,12 +179,38 @@ router.post('/chat/:id', async (req: Request, res: Response) => { return; } - const provider = (requestProvider as AIProvider) || config.defaultAIProvider; + // Get provider from request or user settings (default to 'claude') + const userSettings = (req as any).userSettings; + + // Check if AI is enabled for this user + if (!userSettings?.ai_enabled) { + res.status(403).json({ + error: 'AI functionality is disabled', + message: 'AI functionality is not enabled in your profile settings. Please enable it in your user settings.' + }); + return; + } + + // Check if user has selected an AI provider + if (!userSettings?.ai_provider) { + res.status(403).json({ + error: 'AI provider not configured', + message: 'Please select an AI provider (Claude or OpenAI) in your user settings.' + }); + return; + } + + const userDefaultProvider = userSettings.ai_provider === 'anthropic' ? 'claude' : userSettings.ai_provider === 'openai' ? 'openai' : 'claude'; + const provider = (requestProvider as AIProvider) || (userDefaultProvider as AIProvider); - if (!aiService.isConfigured(provider)) { + // Check if user has API key for the selected provider + const hasApiKey = (provider === 'claude' && userSettings.ai_provider === 'anthropic' && !!userSettings.ai_api_key) || + (provider === 'openai' && userSettings.ai_provider === 'openai' && !!userSettings.ai_api_key); + + if (!hasApiKey) { res.status(503).json({ error: 'AI chat not available', - message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured.` + message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API key is not configured. Please configure the API key in your user settings.` }); return; } @@ -155,8 +221,15 @@ router.post('/chat/:id', async (req: Request, res: Response) => { return; } + // Get user API keys from user settings (already loaded above) + const userApiKeys = userSettings ? { + anthropic: userSettings.ai_provider === 'anthropic' ? userSettings.ai_api_key : undefined, + openai: userSettings.ai_provider === 'openai' ? userSettings.ai_api_key : undefined, + tavily: userSettings.tavily_api_key, + } : undefined; + logger.info(`Chat message for: ${application.name} using ${provider}`); - const response = await aiService.chat(application, message.trim(), conversationId, provider); + const response = await aiService.chat(application, message.trim(), conversationId, provider, userApiKeys); res.json(response); } catch (error) { diff --git a/backend/src/routes/configuration.ts b/backend/src/routes/configuration.ts index 27804d8..09a383e 100644 --- a/backend/src/routes/configuration.ts +++ b/backend/src/routes/configuration.ts @@ -4,6 +4,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { logger } from '../services/logger.js'; import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js'; import type { DataCompletenessConfig } from '../types/index.js'; @@ -13,9 +14,13 @@ const __dirname = dirname(__filename); const router = Router(); +// All routes require authentication and manage_settings permission +router.use(requireAuth); +router.use(requirePermission('manage_settings')); + // Path to the configuration files const CONFIG_FILE_PATH = join(__dirname, '../../data/effort-calculation-config.json'); -const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json'); +const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config.json'); const COMPLETENESS_CONFIG_FILE_PATH = join(__dirname, '../../data/data-completeness-config.json'); /** diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index ff02c97..3da31a8 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -4,6 +4,7 @@ import { databaseService } from '../services/database.js'; import { syncEngine } from '../services/syncEngine.js'; import { logger } from '../services/logger.js'; import { validateApplicationConfiguration, calculateRequiredEffortWithMinMax } from '../services/effortCalculation.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; @@ -11,6 +12,10 @@ import { fileURLToPath } from 'url'; const router = Router(); +// All routes require authentication and view_reports permission +router.use(requireAuth); +router.use(requirePermission('view_reports')); + // Simple in-memory cache for dashboard stats interface CachedStats { data: unknown; @@ -778,6 +783,7 @@ router.get('/data-completeness', async (req: Request, res: Response) => { byField: byFieldArray, byApplication, byTeam: byTeamArray, + config: completenessConfig, // Include config so frontend doesn't need to fetch it separately }); } catch (error) { logger.error('Failed to get data completeness', error); diff --git a/backend/src/routes/objects.ts b/backend/src/routes/objects.ts index 1cb6c80..055e13f 100644 --- a/backend/src/routes/objects.ts +++ b/backend/src/routes/objects.ts @@ -7,12 +7,17 @@ import { Router, Request, Response } from 'express'; import { cmdbService } from '../services/cmdbService.js'; import { logger } from '../services/logger.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js'; import { OBJECT_TYPES } from '../generated/jira-schema.js'; import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); +// All routes require authentication and search permission +router.use(requireAuth); +router.use(requirePermission('search')); + // Get list of supported object types router.get('/', (req: Request, res: Response) => { const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({ diff --git a/backend/src/routes/profile.ts b/backend/src/routes/profile.ts new file mode 100644 index 0000000..ca8395a --- /dev/null +++ b/backend/src/routes/profile.ts @@ -0,0 +1,117 @@ +/** + * Profile Routes + * + * Routes for user profile management (users can manage their own profile). + */ + +import { Router, Request, Response } from 'express'; +import { userService } from '../services/userService.js'; +import { requireAuth } from '../middleware/authorization.js'; +import { logger } from '../services/logger.js'; + +const router = Router(); + +// All routes require authentication +router.use(requireAuth); + +// Get current user profile +router.get('/', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const user = await userService.getUserById(req.user.id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Don't return sensitive data + const { password_hash, password_reset_token, password_reset_expires, email_verification_token, ...safeUser } = user; + + res.json(safeUser); + } catch (error) { + logger.error('Get profile error:', error); + res.status(500).json({ error: 'Failed to fetch profile' }); + } +}); + +// Update profile +router.put('/', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { username, display_name } = req.body; + + const user = await userService.updateUser(req.user.id, { + username, + display_name, + }); + + // Don't return sensitive data + const { password_hash, password_reset_token, password_reset_expires, email_verification_token, ...safeUser } = user; + + res.json(safeUser); + } catch (error) { + logger.error('Update profile error:', error); + const message = error instanceof Error ? error.message : 'Failed to update profile'; + res.status(400).json({ error: message }); + } +}); + +// Change password +router.put('/password', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { current_password, new_password } = req.body; + + if (!current_password || !new_password) { + return res.status(400).json({ error: 'Current password and new password are required' }); + } + + // Verify current password + const user = await userService.getUserById(req.user.id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const isValid = await userService.verifyPassword(current_password, user.password_hash); + if (!isValid) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Validate new password requirements + const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10); + const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true'; + const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true'; + const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true'; + + if (new_password.length < minLength) { + return res.status(400).json({ error: `Password must be at least ${minLength} characters long` }); + } + if (requireUppercase && !/[A-Z]/.test(new_password)) { + return res.status(400).json({ error: 'Password must contain at least one uppercase letter' }); + } + if (requireLowercase && !/[a-z]/.test(new_password)) { + return res.status(400).json({ error: 'Password must contain at least one lowercase letter' }); + } + if (requireNumber && !/[0-9]/.test(new_password)) { + return res.status(400).json({ error: 'Password must contain at least one number' }); + } + + // Update password + await userService.updatePassword(req.user.id, new_password); + + res.json({ success: true, message: 'Password updated successfully' }); + } catch (error) { + logger.error('Change password error:', error); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + +export default router; diff --git a/backend/src/routes/referenceData.ts b/backend/src/routes/referenceData.ts index 8231375..3c24d72 100644 --- a/backend/src/routes/referenceData.ts +++ b/backend/src/routes/referenceData.ts @@ -1,9 +1,13 @@ import { Router, Request, Response } from 'express'; import { dataService } from '../services/dataService.js'; import { logger } from '../services/logger.js'; +import { requireAuth } from '../middleware/authorization.js'; const router = Router(); +// All routes require authentication +router.use(requireAuth); + // Get all reference data router.get('/', async (req: Request, res: Response) => { try { diff --git a/backend/src/routes/roles.ts b/backend/src/routes/roles.ts new file mode 100644 index 0000000..d7b7832 --- /dev/null +++ b/backend/src/routes/roles.ts @@ -0,0 +1,196 @@ +/** + * Role Management Routes + * + * Routes for managing roles and permissions (admin only). + */ + +import { Router, Request, Response } from 'express'; +import { roleService } from '../services/roleService.js'; +import { requireAuth, requireAdmin } from '../middleware/authorization.js'; +import { logger } from '../services/logger.js'; + +const router = Router(); + +// Get all roles (public, but permissions are admin-only) +router.get('/', async (req: Request, res: Response) => { + try { + const roles = await roleService.getAllRoles(); + + // Get permissions for each role + const rolesWithPermissions = await Promise.all( + roles.map(async (role) => { + const permissions = await roleService.getRolePermissions(role.id); + return { + ...role, + permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })), + }; + }) + ); + + res.json(rolesWithPermissions); + } catch (error) { + logger.error('Get roles error:', error); + res.status(500).json({ error: 'Failed to fetch roles' }); + } +}); + +// Get role by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid role ID' }); + } + + const role = await roleService.getRoleById(id); + if (!role) { + return res.status(404).json({ error: 'Role not found' }); + } + + const permissions = await roleService.getRolePermissions(id); + + res.json({ + ...role, + permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })), + }); + } catch (error) { + logger.error('Get role error:', error); + res.status(500).json({ error: 'Failed to fetch role' }); + } +}); + +// Create role (admin only) +router.post('/', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const { name, description } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Role name is required' }); + } + + const role = await roleService.createRole({ name, description }); + res.status(201).json(role); + } catch (error) { + logger.error('Create role error:', error); + const message = error instanceof Error ? error.message : 'Failed to create role'; + res.status(400).json({ error: message }); + } +}); + +// Update role (admin only) +router.put('/:id', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid role ID' }); + } + + const { name, description } = req.body; + + const role = await roleService.updateRole(id, { name, description }); + res.json(role); + } catch (error) { + logger.error('Update role error:', error); + const message = error instanceof Error ? error.message : 'Failed to update role'; + res.status(400).json({ error: message }); + } +}); + +// Delete role (admin only) +router.delete('/:id', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid role ID' }); + } + + const success = await roleService.deleteRole(id); + if (success) { + res.json({ success: true }); + } else { + res.status(404).json({ error: 'Role not found or cannot be deleted' }); + } + } catch (error) { + logger.error('Delete role error:', error); + const message = error instanceof Error ? error.message : 'Failed to delete role'; + res.status(400).json({ error: message }); + } +}); + +// Get role permissions +router.get('/:id/permissions', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid role ID' }); + } + + const permissions = await roleService.getRolePermissions(id); + res.json(permissions); + } catch (error) { + logger.error('Get role permissions error:', error); + res.status(500).json({ error: 'Failed to fetch role permissions' }); + } +}); + +// Assign permission to role (admin only) +router.post('/:id/permissions', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid role ID' }); + } + + const { permission_id } = req.body; + if (!permission_id) { + return res.status(400).json({ error: 'permission_id is required' }); + } + + const success = await roleService.assignPermissionToRole(id, permission_id); + if (success) { + const permissions = await roleService.getRolePermissions(id); + res.json({ success: true, permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })) }); + } else { + res.status(400).json({ error: 'Permission already assigned or invalid permission' }); + } + } catch (error) { + logger.error('Assign permission error:', error); + res.status(500).json({ error: 'Failed to assign permission' }); + } +}); + +// Remove permission from role (admin only) +router.delete('/:id/permissions/:permissionId', requireAuth, requireAdmin, async (req: Request, res: Response) => { + try { + const roleId = parseInt(req.params.id, 10); + const permissionId = parseInt(req.params.permissionId, 10); + + if (isNaN(roleId) || isNaN(permissionId)) { + return res.status(400).json({ error: 'Invalid role ID or permission ID' }); + } + + const success = await roleService.removePermissionFromRole(roleId, permissionId); + if (success) { + const permissions = await roleService.getRolePermissions(roleId); + res.json({ success: true, permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })) }); + } else { + res.status(404).json({ error: 'Permission not assigned to role' }); + } + } catch (error) { + logger.error('Remove permission error:', error); + res.status(500).json({ error: 'Failed to remove permission' }); + } +}); + +// Get all permissions (public) +router.get('/permissions/all', async (req: Request, res: Response) => { + try { + const permissions = await roleService.getAllPermissions(); + res.json(permissions); + } catch (error) { + logger.error('Get permissions error:', error); + res.status(500).json({ error: 'Failed to fetch permissions' }); + } +}); + +export default router; diff --git a/backend/src/routes/schema.ts b/backend/src/routes/schema.ts index 6cc8f1c..cae237f 100644 --- a/backend/src/routes/schema.ts +++ b/backend/src/routes/schema.ts @@ -4,10 +4,15 @@ import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jir import { dataService } from '../services/dataService.js'; import { logger } from '../services/logger.js'; import { jiraAssetsClient } from '../services/jiraAssetsClient.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; import type { CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); +// All routes require authentication and search permission +router.use(requireAuth); +router.use(requirePermission('search')); + // Extended types for API response interface ObjectTypeWithLinks extends ObjectTypeDefinition { incomingLinks: Array<{ diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts index 0492bc4..327f647 100644 --- a/backend/src/routes/search.ts +++ b/backend/src/routes/search.ts @@ -1,11 +1,16 @@ import { Router, Request, Response } from 'express'; -import { cmdbService } from '../services/cmdbService.js'; +import { jiraAssetsService } from '../services/jiraAssets.js'; import { logger } from '../services/logger.js'; import { config } from '../config/env.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; const router = Router(); -// CMDB free-text search endpoint (from cache) +// All routes require authentication and search permission +router.use(requireAuth); +router.use(requirePermission('search')); + +// CMDB free-text search endpoint (using Jira API) router.get('/', async (req: Request, res: Response) => { try { const query = req.query.query as string; @@ -18,53 +23,37 @@ router.get('/', async (req: Request, res: Response) => { logger.info(`CMDB search request: query="${query}", limit=${limit}`); - // Search all types in cache - const results = await cmdbService.searchAllTypes(query.trim(), { limit }); - - // Group results by object type - const objectTypeMap = new Map(); - const formattedResults = results.map(obj => { - const typeName = obj._objectType || 'Unknown'; - - // Track unique object types - if (!objectTypeMap.has(typeName)) { - objectTypeMap.set(typeName, { - id: objectTypeMap.size + 1, - name: typeName, - iconUrl: '', // Can be enhanced to include actual icons - }); + // Set user token on jiraAssetsService (same logic as middleware) + // Use OAuth token if available, otherwise user's PAT, otherwise service account token + if (req.accessToken) { + jiraAssetsService.setRequestToken(req.accessToken); + } else if (req.user && 'id' in req.user) { + const userSettings = (req as any).userSettings; + if (userSettings?.jira_pat) { + jiraAssetsService.setRequestToken(userSettings.jira_pat); + } else if (config.jiraServiceAccountToken) { + jiraAssetsService.setRequestToken(config.jiraServiceAccountToken); + } else { + jiraAssetsService.setRequestToken(null); } + } else { + jiraAssetsService.setRequestToken(config.jiraServiceAccountToken || null); + } + + try { + // Use Jira API search (searches Key, Object Type, Label, Name, Description, Status) + // The URL will be logged automatically by jiraAssetsService.searchCMDB() + const response = await jiraAssetsService.searchCMDB(query.trim(), limit); - const objectType = objectTypeMap.get(typeName)!; + // Clear token after request + jiraAssetsService.clearRequestToken(); - return { - id: parseInt(obj.id, 10) || 0, - key: obj.objectKey, - label: obj.label, - objectTypeId: objectType.id, - avatarUrl: '', - attributes: [], // Can be enhanced to include attributes - }; - }); - - // Build response matching CMDBSearchResponse interface - const response = { - metadata: { - count: formattedResults.length, - offset: 0, - limit: limit, - total: formattedResults.length, - criteria: { - query: query, - type: 'global', - schema: parseInt(config.jiraSchemaId, 10) || 0, - }, - }, - objectTypes: Array.from(objectTypeMap.values()), - results: formattedResults, - }; - - res.json(response); + res.json(response); + } catch (error) { + // Clear token on error + jiraAssetsService.clearRequestToken(); + throw error; + } } catch (error) { logger.error('CMDB search failed', error); res.status(500).json({ error: 'Failed to search CMDB' }); diff --git a/backend/src/routes/userSettings.ts b/backend/src/routes/userSettings.ts new file mode 100644 index 0000000..a0e1659 --- /dev/null +++ b/backend/src/routes/userSettings.ts @@ -0,0 +1,105 @@ +/** + * User Settings Routes + * + * Routes for managing user-specific settings (Jira PAT, AI features, etc.). + */ + +import { Router, Request, Response } from 'express'; +import { userSettingsService } from '../services/userSettingsService.js'; +import { requireAuth } from '../middleware/authorization.js'; +import { logger } from '../services/logger.js'; + +const router = Router(); + +// All routes require authentication +router.use(requireAuth); + +// Get current user settings +router.get('/', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const settings = await userSettingsService.getUserSettings(req.user.id); + if (!settings) { + return res.status(404).json({ error: 'Settings not found' }); + } + + // Don't return sensitive data in full + res.json({ + ...settings, + jira_pat: settings.jira_pat ? '***' : null, // Mask PAT + ai_api_key: settings.ai_api_key ? '***' : null, // Mask API key + tavily_api_key: settings.tavily_api_key ? '***' : null, // Mask API key + }); + } catch (error) { + logger.error('Get user settings error:', error); + res.status(500).json({ error: 'Failed to fetch user settings' }); + } +}); + +// Update user settings +router.put('/', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { jira_pat, ai_enabled, ai_provider, ai_api_key, web_search_enabled, tavily_api_key } = req.body; + + const settings = await userSettingsService.updateUserSettings(req.user.id, { + jira_pat, + ai_enabled, + ai_provider, + ai_api_key, + web_search_enabled, + tavily_api_key, + }); + + // Don't return sensitive data in full + res.json({ + ...settings, + jira_pat: settings.jira_pat ? '***' : null, + ai_api_key: settings.ai_api_key ? '***' : null, + tavily_api_key: settings.tavily_api_key ? '***' : null, + }); + } catch (error) { + logger.error('Update user settings error:', error); + res.status(500).json({ error: 'Failed to update user settings' }); + } +}); + +// Validate Jira PAT +router.post('/jira-pat/validate', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { pat } = req.body; + const isValid = await userSettingsService.validateJiraPat(req.user.id, pat); + + res.json({ valid: isValid }); + } catch (error) { + logger.error('Validate Jira PAT error:', error); + res.status(500).json({ error: 'Failed to validate Jira PAT' }); + } +}); + +// Get Jira PAT status +router.get('/jira-pat/status', async (req: Request, res: Response) => { + try { + if (!req.user || !('id' in req.user)) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const status = await userSettingsService.getJiraPatStatus(req.user.id); + res.json(status); + } catch (error) { + logger.error('Get Jira PAT status error:', error); + res.status(500).json({ error: 'Failed to get Jira PAT status' }); + } +}); + +export default router; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..78c6473 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,309 @@ +/** + * User Management Routes + * + * Routes for managing users (admin only). + */ + +import { Router, Request, Response } from 'express'; +import { userService } from '../services/userService.js'; +import { roleService } from '../services/roleService.js'; +import { requireAuth, requireAdmin } from '../middleware/authorization.js'; +import { logger } from '../services/logger.js'; + +const router = Router(); + +// All routes require authentication and admin role +router.use(requireAuth); +router.use(requireAdmin); + +// Get all users +router.get('/', async (req: Request, res: Response) => { + try { + const users = await userService.getAllUsers(); + + // Get roles for each user + const usersWithRoles = await Promise.all( + users.map(async (user) => { + const roles = await userService.getUserRoles(user.id); + return { + ...user, + roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })), + }; + }) + ); + + res.json(usersWithRoles); + } catch (error) { + logger.error('Get users error:', error); + res.status(500).json({ error: 'Failed to fetch users' }); + } +}); + +// Get user by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const user = await userService.getUserById(id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const roles = await userService.getUserRoles(user.id); + const permissions = await roleService.getUserPermissions(user.id); + + res.json({ + ...user, + roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })), + permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description })), + }); + } catch (error) { + logger.error('Get user error:', error); + res.status(500).json({ error: 'Failed to fetch user' }); + } +}); + +// Create user +router.post('/', async (req: Request, res: Response) => { + try { + const { email, username, password, display_name, send_invitation } = req.body; + + if (!email || !username) { + return res.status(400).json({ error: 'Email and username are required' }); + } + + const user = await userService.createUser({ + email, + username, + password, + display_name, + send_invitation: send_invitation !== false, // Default to true + }); + + const roles = await userService.getUserRoles(user.id); + + res.status(201).json({ + ...user, + roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })), + }); + } catch (error) { + logger.error('Create user error:', error); + const message = error instanceof Error ? error.message : 'Failed to create user'; + res.status(400).json({ error: message }); + } +}); + +// Update user +router.put('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const { email, username, display_name, is_active } = req.body; + + const user = await userService.updateUser(id, { + email, + username, + display_name, + is_active, + }); + + const roles = await userService.getUserRoles(user.id); + + res.json({ + ...user, + roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })), + }); + } catch (error) { + logger.error('Update user error:', error); + const message = error instanceof Error ? error.message : 'Failed to update user'; + res.status(400).json({ error: message }); + } +}); + +// Delete user +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + // Prevent deleting yourself + if (req.user && 'id' in req.user && req.user.id === id) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + const success = await userService.deleteUser(id); + if (success) { + res.json({ success: true }); + } else { + res.status(404).json({ error: 'User not found' }); + } + } catch (error) { + logger.error('Delete user error:', error); + res.status(500).json({ error: 'Failed to delete user' }); + } +}); + +// Send invitation email +router.post('/:id/invite', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const success = await userService.sendInvitation(id); + if (success) { + res.json({ success: true, message: 'Invitation sent successfully' }); + } else { + res.status(404).json({ error: 'User not found' }); + } + } catch (error) { + logger.error('Send invitation error:', error); + res.status(500).json({ error: 'Failed to send invitation' }); + } +}); + +// Assign role to user +router.post('/:id/roles', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const { role_id } = req.body; + if (!role_id) { + return res.status(400).json({ error: 'role_id is required' }); + } + + const success = await userService.assignRole(id, role_id); + if (success) { + const roles = await userService.getUserRoles(id); + res.json({ success: true, roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })) }); + } else { + res.status(400).json({ error: 'Role already assigned or invalid role' }); + } + } catch (error) { + logger.error('Assign role error:', error); + res.status(500).json({ error: 'Failed to assign role' }); + } +}); + +// Remove role from user +router.delete('/:id/roles/:roleId', async (req: Request, res: Response) => { + try { + const userId = parseInt(req.params.id, 10); + const roleId = parseInt(req.params.roleId, 10); + + if (isNaN(userId) || isNaN(roleId)) { + return res.status(400).json({ error: 'Invalid user ID or role ID' }); + } + + // Prevent removing administrator role from yourself + if (req.user && 'id' in req.user && req.user.id === userId) { + const role = await roleService.getRoleById(roleId); + if (role && role.name === 'administrator') { + return res.status(400).json({ error: 'Cannot remove administrator role from your own account' }); + } + } + + const success = await userService.removeRole(userId, roleId); + if (success) { + const roles = await userService.getUserRoles(userId); + res.json({ success: true, roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })) }); + } else { + res.status(404).json({ error: 'Role not assigned to user' }); + } + } catch (error) { + logger.error('Remove role error:', error); + res.status(500).json({ error: 'Failed to remove role' }); + } +}); + +// Activate/deactivate user +router.put('/:id/activate', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const { is_active } = req.body; + if (typeof is_active !== 'boolean') { + return res.status(400).json({ error: 'is_active must be a boolean' }); + } + + // Prevent deactivating yourself + if (req.user && 'id' in req.user && req.user.id === id && !is_active) { + return res.status(400).json({ error: 'Cannot deactivate your own account' }); + } + + const user = await userService.updateUser(id, { is_active }); + res.json(user); + } catch (error) { + logger.error('Activate user error:', error); + res.status(500).json({ error: 'Failed to update user status' }); + } +}); + +// Manually verify email address (admin action) +router.put('/:id/verify-email', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + await userService.manuallyVerifyEmail(id); + const user = await userService.getUserById(id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const roles = await userService.getUserRoles(user.id); + res.json({ + ...user, + roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })), + }); + } catch (error) { + logger.error('Verify email error:', error); + res.status(500).json({ error: 'Failed to verify email' }); + } +}); + +// Set password for user (admin action) +router.put('/:id/password', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const { password } = req.body; + if (!password || typeof password !== 'string') { + return res.status(400).json({ error: 'Password is required' }); + } + + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters long' }); + } + + await userService.updatePassword(id, password); + logger.info(`Password set by admin for user: ${id}`); + + res.json({ success: true, message: 'Password updated successfully' }); + } catch (error) { + logger.error('Set password error:', error); + res.status(500).json({ error: 'Failed to set password' }); + } +}); + +export default router; diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts index e791fbe..8c58c33 100644 --- a/backend/src/services/authService.ts +++ b/backend/src/services/authService.ts @@ -1,13 +1,20 @@ import { config } from '../config/env.js'; import { logger } from './logger.js'; import { randomBytes, createHash } from 'crypto'; +import { getAuthDatabase } from './database/migrations.js'; +import { userService, type User } from './userService.js'; +import { roleService } from './roleService.js'; -// Token storage (in production, use Redis or similar) -interface UserSession { - accessToken: string; - refreshToken?: string; - expiresAt: number; - user: JiraUser; +// Extended user interface for sessions +export interface SessionUser { + id: number; + email: string; + username: string; + displayName: string; + emailAddress?: string; + avatarUrl?: string; + roles: string[]; + permissions: string[]; } export interface JiraUser { @@ -17,19 +24,21 @@ export interface JiraUser { avatarUrl?: string; } -// In-memory session store (replace with Redis in production) -const sessionStore = new Map(); +interface DatabaseSession { + id: string; + user_id: number | null; + auth_method: string; + access_token: string | null; + refresh_token: string | null; + expires_at: string; + created_at: string; + ip_address: string | null; + user_agent: string | null; +} -// Session cleanup interval (every 5 minutes) -setInterval(() => { - const now = Date.now(); - for (const [sessionId, session] of sessionStore.entries()) { - if (session.expiresAt < now) { - sessionStore.delete(sessionId); - logger.debug(`Cleaned up expired session: ${sessionId.substring(0, 8)}...`); - } - } -}, 5 * 60 * 1000); +const isPostgres = (): boolean => { + return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; +}; // PKCE helpers for OAuth 2.0 export function generateCodeVerifier(): string { @@ -59,8 +68,192 @@ setInterval(() => { } }, 60 * 1000); +// Clean up expired sessions from database +setInterval(async () => { + try { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + await db.execute( + 'DELETE FROM sessions WHERE expires_at < ?', + [now] + ); + await db.close(); + } catch (error) { + logger.error('Failed to clean up expired sessions:', error); + } +}, 5 * 60 * 1000); // Every 5 minutes + class AuthService { - // Get OAuth authorization URL + /** + * Get session duration in milliseconds + */ + private getSessionDuration(): number { + const hours = parseInt(process.env.SESSION_DURATION_HOURS || '24', 10); + return hours * 60 * 60 * 1000; + } + + /** + * Create a session in the database + */ + private async createSession( + userId: number | null, + authMethod: 'local' | 'oauth' | 'jira-oauth', + accessToken?: string, + refreshToken?: string, + ipAddress?: string, + userAgent?: string + ): Promise { + const db = getAuthDatabase(); + const sessionId = randomBytes(32).toString('hex'); + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + this.getSessionDuration()).toISOString(); + + try { + await db.execute( + `INSERT INTO sessions ( + id, user_id, auth_method, access_token, refresh_token, + expires_at, created_at, ip_address, user_agent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + sessionId, + userId, + authMethod, + accessToken || null, + refreshToken || null, + expiresAt, + now, + ipAddress || null, + userAgent || null, + ] + ); + + logger.info(`Session created: ${sessionId.substring(0, 8)}... (${authMethod})`); + return sessionId; + } finally { + await db.close(); + } + } + + /** + * Get session from database + */ + private async getSessionFromDb(sessionId: string): Promise { + const db = getAuthDatabase(); + try { + const session = await db.queryOne( + 'SELECT * FROM sessions WHERE id = ?', + [sessionId] + ); + + if (!session) { + return null; + } + + // Check if expired + if (new Date(session.expires_at) < new Date()) { + await db.execute('DELETE FROM sessions WHERE id = ?', [sessionId]); + return null; + } + + return session; + } finally { + await db.close(); + } + } + + /** + * Get user from session (local auth) + */ + async getSessionUser(sessionId: string): Promise { + const session = await this.getSessionFromDb(sessionId); + if (!session || !session.user_id) { + return null; + } + + const user = await userService.getUserById(session.user_id); + if (!user || !user.is_active) { + return null; + } + + const roles = await roleService.getUserRoles(session.user_id); + const permissions = await roleService.getUserPermissions(session.user_id); + + return { + id: user.id, + email: user.email, + username: user.username, + displayName: user.display_name || user.username, + emailAddress: user.email, + roles: roles.map(r => r.name), + permissions: permissions.map(p => p.name), + }; + } + + /** + * Local login (email or username/password) + */ + async localLogin( + email: string, + password: string, + ipAddress?: string, + userAgent?: string + ): Promise<{ sessionId: string; user: SessionUser }> { + logger.debug(`[localLogin] Attempting login with identifier: ${email}`); + + // Try email first, then username if email lookup fails + let user = await userService.getUserByEmail(email); + + if (!user) { + logger.debug(`[localLogin] Email lookup failed, trying username: ${email}`); + // If email lookup failed, try username + user = await userService.getUserByUsername(email); + } + + if (!user) { + logger.warn(`[localLogin] User not found: ${email}`); + throw new Error('Invalid email/username or password'); + } + + logger.debug(`[localLogin] User found: ${user.email} (${user.username}), active: ${user.is_active}, verified: ${user.email_verified}`); + + if (!user.is_active) { + logger.warn(`[localLogin] Account is deactivated: ${user.email}`); + throw new Error('Account is deactivated'); + } + + // Verify password + const isValid = await userService.verifyPassword(password, user.password_hash); + if (!isValid) { + logger.warn(`[localLogin] Invalid password for user: ${user.email}`); + throw new Error('Invalid email/username or password'); + } + + logger.info(`[localLogin] Successful login: ${user.email} (${user.username})`); + + // Update last login + await userService.updateLastLogin(user.id); + + // Create session + const sessionId = await this.createSession( + user.id, + 'local', + undefined, + undefined, + ipAddress, + userAgent + ); + + const sessionUser = await this.getSessionUser(sessionId); + if (!sessionUser) { + throw new Error('Failed to create session'); + } + + return { sessionId, user: sessionUser }; + } + + /** + * Get OAuth authorization URL + */ getAuthorizationUrl(): { url: string; state: string } { const state = generateState(); const codeVerifier = generateCodeVerifier(); @@ -86,8 +279,15 @@ class AuthService { return { url: authUrl, state }; } - // Exchange authorization code for tokens - async exchangeCodeForTokens(code: string, state: string): Promise<{ sessionId: string; user: JiraUser }> { + /** + * Exchange authorization code for tokens (Jira OAuth) + */ + async exchangeCodeForTokens( + code: string, + state: string, + ipAddress?: string, + userAgent?: string + ): Promise<{ sessionId: string; user: SessionUser | JiraUser }> { // Retrieve and validate state const flowData = authFlowStore.get(state); if (!flowData) { @@ -129,25 +329,52 @@ class AuthService { token_type: string; }; - // Fetch user info - const user = await this.fetchUserInfo(tokenData.access_token); + // Fetch user info from Jira + const jiraUser = await this.fetchUserInfo(tokenData.access_token); - // Create session - const sessionId = randomBytes(32).toString('hex'); - const session: UserSession = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresAt: Date.now() + (tokenData.expires_in * 1000), - user, - }; + // Try to find local user by email + let localUser: User | null = null; + if (jiraUser.emailAddress) { + localUser = await userService.getUserByEmail(jiraUser.emailAddress); + } - sessionStore.set(sessionId, session); - logger.info(`Created session for user: ${user.displayName}`); + if (localUser) { + // Link OAuth to existing local user + const sessionId = await this.createSession( + localUser.id, + 'jira-oauth', + tokenData.access_token, + tokenData.refresh_token, + ipAddress, + userAgent + ); - return { sessionId, user }; + const sessionUser = await this.getSessionUser(sessionId); + if (!sessionUser) { + throw new Error('Failed to create session'); + } + + logger.info(`OAuth login successful for local user: ${localUser.email}`); + return { sessionId, user: sessionUser }; + } else { + // Create session without local user (OAuth-only) + const sessionId = await this.createSession( + null, + 'jira-oauth', + tokenData.access_token, + tokenData.refresh_token, + ipAddress, + userAgent + ); + + logger.info(`OAuth login successful for Jira user: ${jiraUser.displayName}`); + return { sessionId, user: jiraUser }; + } } - // Fetch current user info from Jira + /** + * Fetch current user info from Jira + */ async fetchUserInfo(accessToken: string): Promise { const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, { headers: { @@ -177,38 +404,54 @@ class AuthService { }; } - // Get session by ID - getSession(sessionId: string): UserSession | null { - const session = sessionStore.get(sessionId); + /** + * Get session by ID + */ + async getSession(sessionId: string): Promise<{ user: SessionUser | JiraUser; accessToken?: string } | null> { + const session = await this.getSessionFromDb(sessionId); if (!session) { return null; } - // Check if expired - if (session.expiresAt < Date.now()) { - sessionStore.delete(sessionId); - return null; + if (session.user_id) { + // Local user session + const user = await this.getSessionUser(sessionId); + if (!user) { + return null; + } + return { user }; + } else if (session.access_token) { + // OAuth-only session + const user = await this.fetchUserInfo(session.access_token); + return { user, accessToken: session.access_token }; } - return session; + return null; } - // Get access token for a session - getAccessToken(sessionId: string): string | null { - const session = this.getSession(sessionId); - return session?.accessToken || null; + /** + * Get access token for a session + */ + async getAccessToken(sessionId: string): Promise { + const session = await this.getSessionFromDb(sessionId); + return session?.access_token || null; } - // Get user for a session + /** + * Get user for a session (legacy method for compatibility) + */ getUser(sessionId: string): JiraUser | null { - const session = this.getSession(sessionId); - return session?.user || null; + // This is a legacy method - use getSessionUser or getSession instead + // For now, return null to maintain compatibility + return null; } - // Refresh access token + /** + * Refresh access token + */ async refreshAccessToken(sessionId: string): Promise { - const session = sessionStore.get(sessionId); - if (!session?.refreshToken) { + const session = await this.getSessionFromDb(sessionId); + if (!session?.refresh_token) { return false; } @@ -218,7 +461,7 @@ class AuthService { grant_type: 'refresh_token', client_id: config.jiraOAuthClientId, client_secret: config.jiraOAuthClientSecret, - refresh_token: session.refreshToken, + refresh_token: session.refresh_token, }); try { @@ -241,16 +484,23 @@ class AuthService { expires_in: number; }; - // Update session - session.accessToken = tokenData.access_token; - if (tokenData.refresh_token) { - session.refreshToken = tokenData.refresh_token; + // Update session in database + const db = getAuthDatabase(); + try { + await db.execute( + 'UPDATE sessions SET access_token = ?, refresh_token = ?, expires_at = ? WHERE id = ?', + [ + tokenData.access_token, + tokenData.refresh_token || session.refresh_token, + new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString(), + sessionId, + ] + ); + } finally { + await db.close(); } - session.expiresAt = Date.now() + (tokenData.expires_in * 1000); - - sessionStore.set(sessionId, session); + logger.info(`Refreshed token for session: ${sessionId.substring(0, 8)}...`); - return true; } catch (error) { logger.error('Token refresh error:', error); @@ -258,28 +508,55 @@ class AuthService { } } - // Logout / destroy session - logout(sessionId: string): boolean { - const existed = sessionStore.has(sessionId); - sessionStore.delete(sessionId); - if (existed) { - logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`); + /** + * Logout / destroy session + */ + async logout(sessionId: string): Promise { + const db = getAuthDatabase(); + try { + const result = await db.execute( + 'DELETE FROM sessions WHERE id = ?', + [sessionId] + ); + + if (result > 0) { + logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`); + return true; + } + return false; + } finally { + await db.close(); } - return existed; } - // Check if OAuth is enabled (jiraAuthMethod = 'oauth') + /** + * Check if OAuth is enabled (jiraAuthMethod = 'oauth') + */ isOAuthEnabled(): boolean { return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret; } - // Check if using service account (PAT) mode (jiraAuthMethod = 'pat') + /** + * Check if using service account (PAT) mode (jiraAuthMethod = 'pat') + */ isUsingServiceAccount(): boolean { - return config.jiraAuthMethod === 'pat' && !!config.jiraPat; + // Service account mode is when auth method is PAT but no local auth is enabled + // and no users exist (checked elsewhere) + return config.jiraAuthMethod === 'pat'; } - // Get the configured authentication method - getAuthMethod(): 'pat' | 'oauth' | 'none' { + /** + * Check if local auth is enabled + */ + isLocalAuthEnabled(): boolean { + return process.env.LOCAL_AUTH_ENABLED === 'true'; + } + + /** + * Get the configured authentication method + */ + getAuthMethod(): 'pat' | 'oauth' | 'local' | 'none' { + if (this.isLocalAuthEnabled()) return 'local'; if (this.isOAuthEnabled()) return 'oauth'; if (this.isUsingServiceAccount()) return 'pat'; return 'none'; @@ -287,4 +564,3 @@ class AuthService { } export const authService = new AuthService(); - diff --git a/backend/src/services/claude.ts b/backend/src/services/claude.ts index 6cbfc80..4fd0c96 100644 --- a/backend/src/services/claude.ts +++ b/backend/src/services/claude.ts @@ -337,8 +337,9 @@ interface TavilySearchResponse { } // Perform web search using Tavily API -async function performWebSearch(query: string): Promise { - if (!config.enableWebSearch || !config.tavilyApiKey) { +async function performWebSearch(query: string, tavilyApiKey?: string): Promise { + // Tavily API key must be provided - it's configured in user profile settings + if (!tavilyApiKey) { return null; } @@ -349,7 +350,7 @@ async function performWebSearch(query: string): Promise { 'Content-Type': 'application/json', }, body: JSON.stringify({ - api_key: config.tavilyApiKey, + api_key: apiKey, query: query, search_depth: 'basic', include_answer: true, @@ -610,49 +611,56 @@ class AIService { private openaiClient: OpenAI | null = null; constructor() { - if (config.anthropicApiKey) { - this.anthropicClient = new Anthropic({ - apiKey: config.anthropicApiKey, - }); - logger.info('Anthropic (Claude) API configured'); - } else { - logger.warn('Anthropic API key not configured. Claude classification will not work.'); - } - - if (config.openaiApiKey) { - this.openaiClient = new OpenAI({ - apiKey: config.openaiApiKey, - }); - logger.info('OpenAI API configured'); - } else { - logger.warn('OpenAI API key not configured. OpenAI classification will not work.'); - } + // AI API keys are now configured per-user in their profile settings + // Global clients are not initialized - clients are created per-request with user keys + logger.info('AI service initialized - API keys must be configured in user profile settings'); } // Check if a specific provider is configured + // Note: This now checks if user has configured the provider in their settings + // The actual check should be done per-request with user API keys isProviderConfigured(provider: AIProvider): boolean { - if (provider === 'claude') { - return this.anthropicClient !== null; - } else { - return this.openaiClient !== null; - } + // Always return true - configuration is checked per-request with user keys + // This maintains backward compatibility for the isConfigured() method + return true; } // Get available providers getAvailableProviders(): AIProvider[] { - const providers: AIProvider[] = []; - if (this.anthropicClient) providers.push('claude'); - if (this.openaiClient) providers.push('openai'); - return providers; + // Providers are available if users have configured API keys in their settings + // This method is kept for backward compatibility but always returns both providers + // The actual availability is checked per-request with user API keys + return ['claude', 'openai']; } - async classifyApplication(application: ApplicationDetails, provider: AIProvider = config.defaultAIProvider): Promise { - // Validate provider - if (provider === 'claude' && !this.anthropicClient) { - throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.'); + async classifyApplication( + application: ApplicationDetails, + provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings + userApiKeys?: { anthropic?: string; openai?: string; tavily?: string } + ): Promise { + // Use user API keys if provided, otherwise use global config + // API keys must be provided via userApiKeys - they're configured in user profile settings + const anthropicKey = userApiKeys?.anthropic; + const openaiKey = userApiKeys?.openai; + const tavilyKey = userApiKeys?.tavily; + + // Create clients with user keys - API keys must be provided via userApiKeys + let anthropicClient: Anthropic | null = null; + let openaiClient: OpenAI | null = null; + + if (anthropicKey) { + anthropicClient = new Anthropic({ apiKey: anthropicKey }); } - if (provider === 'openai' && !this.openaiClient) { - throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.'); + if (openaiKey) { + openaiClient = new OpenAI({ apiKey: openaiKey }); + } + + // Validate provider - API keys must be provided via userApiKeys + if (provider === 'claude' && !anthropicKey) { + throw new Error('Claude API not configured. Please configure the API key in your user settings.'); + } + if (provider === 'openai' && !openaiKey) { + throw new Error('OpenAI API not configured. Please configure the API key in your user settings.'); } // Check if web search is needed @@ -661,7 +669,7 @@ class AIService { logger.info(`Insufficient information detected for ${application.name}, performing web search...`); const supplierPart = application.supplierProduct ? `${application.supplierProduct} ` : ''; const searchQuery = `${application.name} ${supplierPart}healthcare software`.trim(); - webSearchResults = await performWebSearch(searchQuery); + webSearchResults = await performWebSearch(searchQuery, tavilyKey); if (webSearchResults) { logger.info(`Web search completed for ${application.name}`); } else { @@ -719,8 +727,12 @@ class AIService { let responseText: string; if (provider === 'claude') { - // Use Claude (Anthropic) - const message = await this.anthropicClient!.messages.create({ + // Use Claude (Anthropic) - client created from user API key + if (!anthropicClient) { + throw new Error('Claude API not configured. Please configure the API key in your user settings.'); + } + const client = anthropicClient; + const message = await client.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, messages: [ @@ -737,8 +749,12 @@ class AIService { } responseText = textBlock.text.trim(); } else { - // Use OpenAI - const completion = await this.openaiClient!.chat.completions.create({ + // Use OpenAI - client created from user API key + if (!openaiClient) { + throw new Error('OpenAI API not configured. Please configure the API key in your user settings.'); + } + const client = openaiClient; + const completion = await client.chat.completions.create({ model: 'gpt-4o', max_tokens: 4096, messages: [ @@ -884,7 +900,7 @@ class AIService { async classifyBatch( applications: ApplicationDetails[], onProgress?: (completed: number, total: number) => void, - provider: AIProvider = config.defaultAIProvider + provider: AIProvider = 'claude' // Default to 'claude', but should be provided from user settings ): Promise> { const results = new Map(); const total = applications.length; @@ -936,8 +952,9 @@ class AIService { if (provider) { return this.isProviderConfigured(provider); } - // Return true if at least one provider is configured - return this.anthropicClient !== null || this.openaiClient !== null; + // Configuration is checked per-request with user API keys + // This method is kept for backward compatibility + return true; } // Get the prompt that would be sent to the AI for a given application @@ -1011,14 +1028,30 @@ class AIService { application: ApplicationDetails, userMessage: string, conversationId?: string, - provider: AIProvider = config.defaultAIProvider + provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings + userApiKeys?: { anthropic?: string; openai?: string; tavily?: string } ): Promise { - // Validate provider - if (provider === 'claude' && !this.anthropicClient) { - throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.'); + // API keys must be provided via userApiKeys - they're configured in user profile settings + const anthropicKey = userApiKeys?.anthropic; + const openaiKey = userApiKeys?.openai; + + // Create clients with user keys + let anthropicClient: Anthropic | null = null; + let openaiClient: OpenAI | null = null; + + if (anthropicKey) { + anthropicClient = new Anthropic({ apiKey: anthropicKey }); } - if (provider === 'openai' && !this.openaiClient) { - throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.'); + if (openaiKey) { + openaiClient = new OpenAI({ apiKey: openaiKey }); + } + + // Validate provider - API keys must be provided via userApiKeys + if (provider === 'claude' && !anthropicKey) { + throw new Error('Claude API not configured. Please configure the API key in your user settings.'); + } + if (provider === 'openai' && !openaiKey) { + throw new Error('OpenAI API not configured. Please configure the API key in your user settings.'); } // Get or create conversation @@ -1062,7 +1095,11 @@ class AIService { const systemMessage = aiMessages.find(m => m.role === 'system'); const otherMessages = aiMessages.filter(m => m.role !== 'system'); - const response = await this.anthropicClient!.messages.create({ + if (!anthropicClient) { + throw new Error('Claude API not configured. Please configure the API key in your user settings.'); + } + + const response = await anthropicClient.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, system: systemMessage?.content || '', @@ -1075,7 +1112,11 @@ class AIService { assistantContent = response.content[0].type === 'text' ? response.content[0].text : ''; } else { // OpenAI - const response = await this.openaiClient!.chat.completions.create({ + if (!openaiClient) { + throw new Error('OpenAI API not configured. Please configure the API key in your user settings.'); + } + + const response = await openaiClient.chat.completions.create({ model: 'gpt-4o', max_tokens: 4096, messages: aiMessages.map(m => ({ diff --git a/backend/src/services/cmdbService.ts b/backend/src/services/cmdbService.ts index 3c7dce9..bc5b56e 100644 --- a/backend/src/services/cmdbService.ts +++ b/backend/src/services/cmdbService.ts @@ -79,6 +79,13 @@ class CMDBService { ): Promise { // Force refresh: search Jira by key if (options?.forceRefresh) { + // Check if Jira token is configured before making API call + if (!jiraAssetsClient.hasToken()) { + logger.debug(`CMDBService: Jira PAT not configured, cannot search for ${typeName} with key ${objectKey}`); + // Return cached version if available + return await cacheStore.getObjectByKey(typeName, objectKey) || null; + } + const typeDef = OBJECT_TYPES[typeName]; if (!typeDef) return null; @@ -235,7 +242,15 @@ class CMDBService { return { success: true }; } - // 3. Send update to Jira + // 3. Check if user PAT is configured before sending update (write operations require user PAT) + if (!jiraAssetsClient.hasUserToken()) { + return { + success: false, + error: 'Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.', + }; + } + + // 4. Send update to Jira const success = await jiraAssetsClient.updateObject(id, payload); if (!success) { @@ -271,6 +286,14 @@ class CMDBService { id: string, updates: Record ): Promise { + // Check if user PAT is configured before sending update (write operations require user PAT) + if (!jiraAssetsClient.hasUserToken()) { + return { + success: false, + error: 'Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.', + }; + } + try { const payload = this.buildUpdatePayload(typeName, updates); diff --git a/backend/src/services/dataService.ts b/backend/src/services/dataService.ts index 6beb324..6bb8855 100644 --- a/backend/src/services/dataService.ts +++ b/backend/src/services/dataService.ts @@ -48,7 +48,9 @@ import { calculateRequiredEffortWithMinMax } from './effortCalculation.js'; import { calculateApplicationCompleteness } from './dataCompletenessConfig.js'; // Determine if we should use real Jira Assets or mock data -const useJiraAssets = !!(config.jiraPat && config.jiraSchemaId); +// Jira PAT is now configured per-user, so we check if schema is configured +// The actual PAT is provided per-request via middleware +const useJiraAssets = !!config.jiraSchemaId; if (useJiraAssets) { logger.info('DataService: Using CMDB cache layer with Jira Assets API'); @@ -121,9 +123,40 @@ async function lookupReferences( /** * Convert ObjectReference to ReferenceValue format used by frontend + * Try to enrich with description from jiraAssetsService cache if available + * If not in cache or cache entry has no description, fetch it async */ -function toReferenceValue(ref: ObjectReference | null | undefined): ReferenceValue | null { +async function toReferenceValue(ref: ObjectReference | null | undefined): Promise { if (!ref) return null; + + // Try to get enriched ReferenceValue from jiraAssetsService cache (includes description if available) + const enriched = useJiraAssets ? jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId) : null; + + if (enriched && enriched.description) { + // Use enriched value with description + return enriched; + } + + // Cache miss or no description - fetch it async if using Jira Assets + if (useJiraAssets && enriched && !enriched.description) { + // We have a cached value but it lacks description - fetch it + const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId); + if (fetched) { + return fetched; + } + // If fetch failed, return the cached value anyway + return enriched; + } + + if (useJiraAssets) { + // Cache miss - fetch it + const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId); + if (fetched) { + return fetched; + } + } + + // Fallback to basic conversion without description (if fetch failed or not using Jira Assets) return { objectId: ref.objectId, key: ref.objectKey, @@ -188,25 +221,49 @@ function extractDisplayValue(value: unknown): string | null { * References are now stored as ObjectReference objects directly (not IDs) */ async function toApplicationDetails(app: ApplicationComponent): Promise { + // Debug logging for confluenceSpace from cache + logger.info(`[toApplicationDetails] Converting cached object ${app.objectKey || app.id} to ApplicationDetails`); + logger.info(`[toApplicationDetails] confluenceSpace from cache: ${app.confluenceSpace} (type: ${typeof app.confluenceSpace})`); + + // Handle confluenceSpace - it can be a string (URL) or number (legacy), convert to string + const confluenceSpaceValue = app.confluenceSpace !== null && app.confluenceSpace !== undefined + ? (typeof app.confluenceSpace === 'string' ? app.confluenceSpace : String(app.confluenceSpace)) + : null; + // Ensure factor caches are loaded for factor value lookup await ensureFactorCaches(); // Convert ObjectReference to ReferenceValue format - const governanceModel = toReferenceValue(app.ictGovernanceModel); - // Note: applicationManagementSubteam and applicationManagementTeam are not in the generated schema - // They are only available when fetching directly from Jira API (via jiraAssetsClient) - const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); - const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); - const applicationType = toReferenceValue(app.applicationManagementApplicationType); - const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting); - const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM); - const hostingType = toReferenceValue(app.applicationComponentHostingType); - const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse); - const platform = toReferenceValue(app.platform); - const organisation = toReferenceValue(app.organisation); - const businessImportance = toReferenceValue(app.businessImportance); + // Fetch descriptions async if not in cache + // Use Promise.all to fetch all reference values in parallel for better performance + const [ + governanceModel, + applicationSubteam, + applicationTeam, + applicationType, + applicationManagementHosting, + applicationManagementTAM, + hostingType, + businessImpactAnalyse, + platform, + organisation, + businessImportance, + ] = await Promise.all([ + toReferenceValue(app.ictGovernanceModel), + toReferenceValue((app as any).applicationManagementSubteam), + toReferenceValue((app as any).applicationManagementTeam), + toReferenceValue(app.applicationManagementApplicationType), + toReferenceValue(app.applicationManagementHosting), + toReferenceValue(app.applicationManagementTAM), + toReferenceValue(app.applicationComponentHostingType), + toReferenceValue(app.businessImpactAnalyse), + toReferenceValue(app.platform), + toReferenceValue(app.organisation), + toReferenceValue(app.businessImportance), + ]); // Look up factor values from cached factor objects (same as toMinimalDetailsForEffort) + // Also include descriptions from cache if available let dynamicsFactor: ReferenceValue | null = null; if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') { const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId); @@ -215,6 +272,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise { + const [ + governanceModel, + applicationType, + businessImpactAnalyse, + applicationManagementHosting, + ] = await Promise.all([ + toReferenceValue(app.ictGovernanceModel), + toReferenceValue(app.applicationManagementApplicationType), + toReferenceValue(app.businessImpactAnalyse), + toReferenceValue(app.applicationManagementHosting), + ]); // Look up factor values from cached factor objects let dynamicsFactor: ReferenceValue | null = null; @@ -434,6 +513,7 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail key: app.applicationManagementNumberOfUsers.objectKey, name: app.applicationManagementNumberOfUsers.label, factor: factorObj?.factor ?? undefined, + description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types) }; } @@ -474,23 +554,38 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail /** * Convert ApplicationComponent to ApplicationListItem (lighter weight, for lists) */ -function toApplicationListItem(app: ApplicationComponent): ApplicationListItem { +async function toApplicationListItem(app: ApplicationComponent): Promise { // Use direct ObjectReference conversion instead of lookups - const governanceModel = toReferenceValue(app.ictGovernanceModel); - const dynamicsFactor = toReferenceValue(app.applicationManagementDynamicsFactor); - const complexityFactor = toReferenceValue(app.applicationManagementComplexityFactor); - // Note: Team/Subteam fields are not in generated schema, use type assertion - const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); - const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); - const applicationType = toReferenceValue(app.applicationManagementApplicationType); - const platform = toReferenceValue(app.platform); + // Fetch all reference values in parallel + const [ + governanceModel, + dynamicsFactor, + complexityFactor, + applicationSubteam, + applicationTeam, + applicationType, + platform, + applicationManagementHosting, + applicationManagementTAM, + businessImpactAnalyse, + minimalDetails, + ] = await Promise.all([ + toReferenceValue(app.ictGovernanceModel), + toReferenceValue(app.applicationManagementDynamicsFactor), + toReferenceValue(app.applicationManagementComplexityFactor), + toReferenceValue((app as any).applicationManagementSubteam), + toReferenceValue((app as any).applicationManagementTeam), + toReferenceValue(app.applicationManagementApplicationType), + toReferenceValue(app.platform), + toReferenceValue(app.applicationManagementHosting), + toReferenceValue(app.applicationManagementTAM), + toReferenceValue(app.businessImpactAnalyse), + toMinimalDetailsForEffort(app), + ]); + const applicationFunctions = toReferenceValues(app.applicationFunction); - const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting); - const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM); - const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse); // Calculate effort using minimal details - const minimalDetails = toMinimalDetailsForEffort(app); const effortResult = calculateRequiredEffortWithMinMax(minimalDetails); const result: ApplicationListItem = { @@ -518,12 +613,17 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem { // Calculate data completeness percentage // Convert ApplicationListItem to format expected by completeness calculator + const [organisationRef, hostingTypeRef] = await Promise.all([ + toReferenceValue(app.organisation), + toReferenceValue(app.applicationComponentHostingType), + ]); + const appForCompleteness = { - organisation: toReferenceValue(app.organisation)?.name || null, + organisation: organisationRef?.name || null, applicationFunctions: result.applicationFunctions, status: result.status, businessImpactAnalyse: result.businessImpactAnalyse, - hostingType: toReferenceValue(app.applicationComponentHostingType), + hostingType: hostingTypeRef, supplierProduct: app.supplierProduct?.label || null, businessOwner: app.businessOwner?.label || null, systemOwner: app.systemOwner?.label || null, @@ -535,7 +635,7 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem { applicationManagementTAM: result.applicationManagementTAM, dynamicsFactor: result.dynamicsFactor, complexityFactor: result.complexityFactor, - numberOfUsers: toReferenceValue(app.applicationManagementNumberOfUsers), + numberOfUsers: await toReferenceValue(app.applicationManagementNumberOfUsers), }; const completenessPercentage = calculateApplicationCompleteness(appForCompleteness); @@ -718,8 +818,8 @@ export const dataService = { // Ensure factor caches are loaded for effort calculation await ensureFactorCaches(); - // Convert to list items (synchronous now) - const applications = paginatedApps.map(toApplicationListItem); + // Convert to list items (async now to fetch descriptions) + const applications = await Promise.all(paginatedApps.map(toApplicationListItem)); return { applications, @@ -1221,8 +1321,8 @@ export const dataService = { for (const app of apps) { // Get team from application (via subteam lookup if needed) let team: ReferenceValue | null = null; - const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); - const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); + const applicationSubteam = await toReferenceValue((app as any).applicationManagementSubteam); + const applicationTeam = await toReferenceValue((app as any).applicationManagementTeam); // Prefer direct team assignment, otherwise try to get from subteam if (applicationTeam) { @@ -1265,7 +1365,7 @@ export const dataService = { // Get BIA value if (app.businessImpactAnalyse) { - const biaRef = toReferenceValue(app.businessImpactAnalyse); + const biaRef = await toReferenceValue(app.businessImpactAnalyse); if (biaRef) { const biaNum = biaToNumeric(biaRef.name); if (biaNum !== null) metrics.biaValues.push(biaNum); @@ -1274,7 +1374,7 @@ export const dataService = { // Get governance maturity if (app.ictGovernanceModel) { - const govRef = toReferenceValue(app.ictGovernanceModel); + const govRef = await toReferenceValue(app.ictGovernanceModel); if (govRef) { const maturity = governanceToMaturity(govRef.name); if (maturity !== null) metrics.governanceValues.push(maturity); @@ -1327,6 +1427,10 @@ export const dataService = { async testConnection(): Promise { if (!useJiraAssets) return true; + // Only test connection if token is configured + if (!jiraAssetsClient.hasToken()) { + return false; + } return jiraAssetsClient.testConnection(); }, @@ -1413,7 +1517,7 @@ export const dataService = { if (!app.id || !app.label) continue; // Extract Business Importance from app object - const businessImportanceRef = toReferenceValue(app.businessImportance); + const businessImportanceRef = await toReferenceValue(app.businessImportance); const businessImportanceName = businessImportanceRef?.name || null; // Normalize Business Importance @@ -1436,7 +1540,7 @@ export const dataService = { } // Extract BIA from app object - const businessImpactAnalyseRef = toReferenceValue(app.businessImpactAnalyse); + const businessImpactAnalyseRef = await toReferenceValue(app.businessImpactAnalyse); // Normalize BIA Class let biaClass: string | null = null; diff --git a/backend/src/services/database/factory.ts b/backend/src/services/database/factory.ts index 44eb5c1..ddf2a2a 100644 --- a/backend/src/services/database/factory.ts +++ b/backend/src/services/database/factory.ts @@ -16,8 +16,9 @@ const __dirname = dirname(__filename); /** * Create a database adapter based on environment variables + * @param allowClose - If false, the adapter won't be closed when close() is called (for singletons) */ -export function createDatabaseAdapter(dbType?: string, dbPath?: string): DatabaseAdapter { +export function createDatabaseAdapter(dbType?: string, dbPath?: string, allowClose: boolean = true): DatabaseAdapter { const type = dbType || process.env.DATABASE_TYPE || 'sqlite'; const databaseUrl = process.env.DATABASE_URL; @@ -33,11 +34,11 @@ export function createDatabaseAdapter(dbType?: string, dbPath?: string): Databas const constructedUrl = `postgresql://${user}:${password}@${host}:${port}/${name}${ssl}`; logger.info('Creating PostgreSQL adapter with constructed connection string'); - return new PostgresAdapter(constructedUrl); + return new PostgresAdapter(constructedUrl, allowClose); } logger.info('Creating PostgreSQL adapter'); - return new PostgresAdapter(databaseUrl); + return new PostgresAdapter(databaseUrl, allowClose); } // Default to SQLite diff --git a/backend/src/services/database/migrations.ts b/backend/src/services/database/migrations.ts new file mode 100644 index 0000000..d5bbce8 --- /dev/null +++ b/backend/src/services/database/migrations.ts @@ -0,0 +1,532 @@ +/** + * Database Migrations + * + * Handles database schema creation and migrations for authentication and authorization system. + */ + +import { logger } from '../logger.js'; +import type { DatabaseAdapter } from './interface.js'; +import { createDatabaseAdapter } from './factory.js'; +// @ts-ignore - bcrypt doesn't have proper ESM types +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 10; + +export interface Migration { + name: string; + up: (db: DatabaseAdapter) => Promise; + down?: (db: DatabaseAdapter) => Promise; +} + +const isPostgres = (): boolean => { + return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; +}; + +const getTimestamp = (): string => { + return new Date().toISOString(); +}; + +/** + * Create users table + */ +async function createUsersTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + is_active BOOLEAN DEFAULT true, + email_verified BOOLEAN DEFAULT false, + email_verification_token TEXT, + password_reset_token TEXT, + password_reset_expires TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); + CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token); + ` : ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + is_active INTEGER DEFAULT 1, + email_verified INTEGER DEFAULT 0, + email_verification_token TEXT, + password_reset_token TEXT, + password_reset_expires TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); + CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token); + `; + + await db.exec(schema); +} + +/** + * Create roles table + */ +async function createRolesTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + is_system_role BOOLEAN DEFAULT false, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name); + ` : ` + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT, + is_system_role INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name); + `; + + await db.exec(schema); +} + +/** + * Create permissions table + */ +async function createPermissionsTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS permissions ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + resource TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name); + CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource); + ` : ` + CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT, + resource TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name); + CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource); + `; + + await db.exec(schema); +} + +/** + * Create role_permissions junction table + */ +async function createRolePermissionsTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) + ); + + CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); + CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); + ` : ` + CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) + ); + + CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); + CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); + `; + + await db.exec(schema); +} + +/** + * Create user_roles junction table + */ +async function createUserRolesTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL, + PRIMARY KEY (user_id, role_id) + ); + + CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); + CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); + ` : ` + CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL, + PRIMARY KEY (user_id, role_id) + ); + + CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); + CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); + `; + + await db.exec(schema); +} + +/** + * Create user_settings table + */ +async function createUserSettingsTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + jira_pat TEXT, + jira_pat_encrypted BOOLEAN DEFAULT true, + ai_enabled BOOLEAN DEFAULT false, + ai_provider TEXT, + ai_api_key TEXT, + web_search_enabled BOOLEAN DEFAULT false, + tavily_api_key TEXT, + updated_at TEXT NOT NULL + ); + ` : ` + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + jira_pat TEXT, + jira_pat_encrypted INTEGER DEFAULT 1, + ai_enabled INTEGER DEFAULT 0, + ai_provider TEXT, + ai_api_key TEXT, + web_search_enabled INTEGER DEFAULT 0, + tavily_api_key TEXT, + updated_at TEXT NOT NULL + ); + `; + + await db.exec(schema); +} + +/** + * Create sessions table + */ +async function createSessionsTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + auth_method TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method); + ` : ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + auth_method TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method); + `; + + await db.exec(schema); +} + +/** + * Create email_tokens table + */ +async function createEmailTokensTable(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + + const schema = isPg ? ` + CREATE TABLE IF NOT EXISTS email_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + expires_at TEXT NOT NULL, + used BOOLEAN DEFAULT false, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token); + CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type); + CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at); + ` : ` + CREATE TABLE IF NOT EXISTS email_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token); + CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type); + CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at); + `; + + await db.exec(schema); +} + +/** + * Seed initial data + */ +async function seedInitialData(db: DatabaseAdapter): Promise { + const isPg = isPostgres(); + const now = getTimestamp(); + + // Check if roles already exist + const existingRoles = await db.query('SELECT COUNT(*) as count FROM roles'); + const roleCount = isPg ? (existingRoles[0] as any).count : (existingRoles[0] as any).count; + + // If roles exist, we still need to check if admin user exists + // (roles might exist but admin user might not) + const rolesExist = parseInt(roleCount) > 0; + + if (rolesExist) { + logger.info('Roles already exist, checking if admin user needs to be created...'); + } + + // Get existing role IDs if roles already exist + const roleIds: Record = {}; + + if (!rolesExist) { + // Insert default permissions + const permissions = [ + { name: 'search', description: 'Access search features', resource: 'search' }, + { name: 'view_reports', description: 'View reports and dashboards', resource: 'reports' }, + { name: 'edit_applications', description: 'Edit application components', resource: 'applications' }, + { name: 'manage_users', description: 'Manage users and their roles', resource: 'users' }, + { name: 'manage_roles', description: 'Manage roles and permissions', resource: 'roles' }, + { name: 'manage_settings', description: 'Manage application settings', resource: 'settings' }, + ]; + + for (const perm of permissions) { + await db.execute( + 'INSERT INTO permissions (name, description, resource) VALUES (?, ?, ?)', + [perm.name, perm.description, perm.resource] + ); + } + + // Insert default roles + const roles = [ + { name: 'administrator', description: 'Full system access', isSystem: true }, + { name: 'user', description: 'Basic user access', isSystem: true }, + ]; + + for (const role of roles) { + const isSystem = isPg ? role.isSystem : (role.isSystem ? 1 : 0); + await db.execute( + 'INSERT INTO roles (name, description, is_system_role, created_at) VALUES (?, ?, ?, ?)', + [role.name, role.description, isSystem, now] + ); + + // Get the inserted role ID + const insertedRole = await db.queryOne<{ id: number }>( + 'SELECT id FROM roles WHERE name = ?', + [role.name] + ); + + if (insertedRole) { + roleIds[role.name] = insertedRole.id; + } + } + + // Assign all permissions to administrator role + const allPermissions = await db.query<{ id: number }>('SELECT id FROM permissions'); + for (const perm of allPermissions) { + await db.execute( + 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', + [roleIds['administrator'], perm.id] + ); + } + + // Assign basic permissions to user role (search and view_reports) + const searchPerm = await db.queryOne<{ id: number }>( + 'SELECT id FROM permissions WHERE name = ?', + ['search'] + ); + const viewReportsPerm = await db.queryOne<{ id: number }>( + 'SELECT id FROM permissions WHERE name = ?', + ['view_reports'] + ); + + if (searchPerm) { + await db.execute( + 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', + [roleIds['user'], searchPerm.id] + ); + } + if (viewReportsPerm) { + await db.execute( + 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', + [roleIds['user'], viewReportsPerm.id] + ); + } + } else { + // Roles exist - get their IDs + const adminRole = await db.queryOne<{ id: number }>( + 'SELECT id FROM roles WHERE name = ?', + ['administrator'] + ); + if (adminRole) { + roleIds['administrator'] = adminRole.id; + } + } + + // Create initial admin user if ADMIN_EMAIL and ADMIN_PASSWORD are set + const adminEmail = process.env.ADMIN_EMAIL; + const adminPassword = process.env.ADMIN_PASSWORD; + const adminUsername = process.env.ADMIN_USERNAME || 'admin'; + + if (adminEmail && adminPassword) { + // Check if admin user already exists + const existingUser = await db.queryOne<{ id: number }>( + 'SELECT id FROM users WHERE email = ? OR username = ?', + [adminEmail, adminUsername] + ); + + if (existingUser) { + // User exists - check if they have admin role + const hasAdminRole = await db.queryOne<{ role_id: number }>( + 'SELECT role_id FROM user_roles WHERE user_id = ? AND role_id = ?', + [existingUser.id, roleIds['administrator']] + ); + + if (!hasAdminRole && roleIds['administrator']) { + // Add admin role if missing + await db.execute( + 'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)', + [existingUser.id, roleIds['administrator'], now] + ); + logger.info(`Administrator role assigned to existing user: ${adminEmail}`); + } else { + logger.info(`Administrator user already exists: ${adminEmail}`); + } + } else { + // Create new admin user + const passwordHash = await bcrypt.hash(adminPassword, SALT_ROUNDS); + const displayName = process.env.ADMIN_DISPLAY_NAME || 'Administrator'; + + await db.execute( + 'INSERT INTO users (email, username, password_hash, display_name, is_active, email_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [adminEmail, adminUsername, passwordHash, displayName, isPg ? true : 1, isPg ? true : 1, now, now] + ); + + const adminUser = await db.queryOne<{ id: number }>( + 'SELECT id FROM users WHERE email = ?', + [adminEmail] + ); + + if (adminUser && roleIds['administrator']) { + await db.execute( + 'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)', + [adminUser.id, roleIds['administrator'], now] + ); + logger.info(`Initial administrator user created: ${adminEmail}`); + } + } + } else { + logger.warn('ADMIN_EMAIL and ADMIN_PASSWORD not set - skipping initial admin user creation'); + } + + logger.info('Initial data seeded successfully'); +} + +/** + * Main migration function + */ +export async function runMigrations(): Promise { + const db = createDatabaseAdapter(); + + try { + logger.info('Running database migrations...'); + + await createUsersTable(db); + await createRolesTable(db); + await createPermissionsTable(db); + await createRolePermissionsTable(db); + await createUserRolesTable(db); + await createUserSettingsTable(db); + await createSessionsTable(db); + await createEmailTokensTable(db); + + await seedInitialData(db); + + logger.info('Database migrations completed successfully'); + } catch (error) { + logger.error('Migration failed:', error); + throw error; + } finally { + await db.close(); + } +} + +// Singleton cache for auth database adapter +let authDatabaseAdapter: DatabaseAdapter | null = null; + +/** + * Get database adapter for auth operations + * Uses a singleton pattern to avoid creating multiple adapters. + * The adapter is configured to not close on close() calls, as it should + * remain open for the application lifetime. + */ +export function getAuthDatabase(): DatabaseAdapter { + if (!authDatabaseAdapter) { + // Create adapter with allowClose=false so it won't be closed after operations + authDatabaseAdapter = createDatabaseAdapter(undefined, undefined, false); + } + return authDatabaseAdapter; +} diff --git a/backend/src/services/database/postgresAdapter.ts b/backend/src/services/database/postgresAdapter.ts index 688d56d..2010a06 100644 --- a/backend/src/services/database/postgresAdapter.ts +++ b/backend/src/services/database/postgresAdapter.ts @@ -11,9 +11,12 @@ import type { DatabaseAdapter } from './interface.js'; export class PostgresAdapter implements DatabaseAdapter { private pool: Pool; private connectionString: string; + private isClosed: boolean = false; + private allowClose: boolean = true; // Set to false for singleton instances - constructor(connectionString: string) { + constructor(connectionString: string, allowClose: boolean = true) { this.connectionString = connectionString; + this.allowClose = allowClose; this.pool = new Pool({ connectionString, max: 20, // Maximum number of clients in the pool @@ -124,7 +127,23 @@ export class PostgresAdapter implements DatabaseAdapter { } async close(): Promise { - await this.pool.end(); + // Don't close singleton instances - they should remain open for the app lifetime + if (!this.allowClose) { + return; + } + + // Make close() idempotent - safe to call multiple times + if (this.isClosed) { + return; + } + + try { + await this.pool.end(); + this.isClosed = true; + } catch (error) { + // Pool might already be closed, ignore the error + this.isClosed = true; + } } async getSizeBytes(): Promise { diff --git a/backend/src/services/effortCalculation.ts b/backend/src/services/effortCalculation.ts index 8ec2654..78386fe 100644 --- a/backend/src/services/effortCalculation.ts +++ b/backend/src/services/effortCalculation.ts @@ -18,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Path to the configuration file (v25) -const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json'); +const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config.json'); // Cache for loaded configuration let cachedConfigV25: EffortCalculationConfigV25 | null = null; @@ -275,12 +275,6 @@ export function calculateRequiredEffortApplicationManagementV25( breakdown.businessImpactAnalyse = biaClass; breakdown.applicationManagementHosting = applicationManagementHosting; - logger.debug(`=== Effort Calculation v25 ===`); - logger.debug(`Regiemodel: ${regieModelCode} (${governanceModelRaw})`); - logger.debug(`Application Type: ${applicationType}`); - logger.debug(`BIA: ${biaClass} (${businessImpactAnalyseRaw})`); - logger.debug(`Hosting: ${applicationManagementHosting}`); - // Level 1: Find Regiemodel configuration if (!regieModelCode || !config.regiemodellen[regieModelCode]) { breakdown.errors.push(`Geen configuratie gevonden voor regiemodel: ${governanceModelRaw || 'niet ingesteld'}`); @@ -413,10 +407,6 @@ export function calculateRequiredEffortApplicationManagementV25( breakdown.hoursPerMonth = breakdown.hoursPerYear / 12; breakdown.hoursPerWeek = breakdown.hoursPerYear / NET_WORK_WEEKS; - logger.debug(`Base FTE: ${breakdown.baseEffort} (${breakdown.baseEffortMin} - ${breakdown.baseEffortMax})`); - logger.debug(`Final FTE: ${finalEffort}`); - logger.debug(`Hours/year: ${breakdown.hoursPerYear}`); - return { finalEffort, breakdown }; } catch (error) { diff --git a/backend/src/services/emailService.ts b/backend/src/services/emailService.ts new file mode 100644 index 0000000..14152b0 --- /dev/null +++ b/backend/src/services/emailService.ts @@ -0,0 +1,289 @@ +/** + * Email Service + * + * Handles sending emails using Nodemailer with SMTP configuration. + * Used for user invitations, password resets, and email verification. + */ + +import nodemailer, { Transporter } from 'nodemailer'; +import { logger } from './logger.js'; +import { config } from '../config/env.js'; + +interface EmailOptions { + to: string; + subject: string; + html: string; + text?: string; +} + +class EmailService { + private transporter: Transporter | null = null; + private isConfigured: boolean = false; + + constructor() { + this.initialize(); + } + + /** + * Initialize email transporter + */ + private initialize(): void { + const smtpHost = process.env.SMTP_HOST; + const smtpPort = parseInt(process.env.SMTP_PORT || '587', 10); + const smtpSecure = process.env.SMTP_SECURE === 'true'; + const smtpUser = process.env.SMTP_USER; + const smtpPassword = process.env.SMTP_PASSWORD; + const smtpFrom = process.env.SMTP_FROM || smtpUser || 'noreply@example.com'; + + if (!smtpHost || !smtpUser || !smtpPassword) { + logger.warn('SMTP not configured - email functionality will be disabled'); + this.isConfigured = false; + return; + } + + try { + this.transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, // true for 465, false for other ports + auth: { + user: smtpUser, + pass: smtpPassword, + }, + }); + + this.isConfigured = true; + logger.info('Email service configured'); + } catch (error) { + logger.error('Failed to initialize email service:', error); + this.isConfigured = false; + } + } + + /** + * Send an email + */ + async sendEmail(options: EmailOptions): Promise { + if (!this.isConfigured || !this.transporter) { + logger.warn('Email service not configured - email not sent:', options.to); + // In development, log the email content + if (config.isDevelopment) { + logger.info('Email would be sent:', { + to: options.to, + subject: options.subject, + html: options.html, + }); + } + return false; + } + + try { + const smtpFrom = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@example.com'; + + await this.transporter.sendMail({ + from: smtpFrom, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text || this.htmlToText(options.html), + }); + + logger.info(`Email sent successfully to ${options.to}`); + return true; + } catch (error) { + logger.error('Failed to send email:', error); + return false; + } + } + + /** + * Send invitation email + */ + async sendInvitationEmail( + email: string, + token: string, + displayName?: string + ): Promise { + const frontendUrl = config.frontendUrl; + const invitationUrl = `${frontendUrl}/accept-invitation?token=${token}`; + + const html = ` + + + + + + + +
+
+

Welkom bij CMDB Editor

+
+
+

Beste ${displayName || 'gebruiker'},

+

Je bent uitgenodigd om een account aan te maken voor de CMDB Editor applicatie.

+

Klik op de onderstaande knop om je account te activeren en een wachtwoord in te stellen:

+

+ Account activeren +

+

Of kopieer en plak deze link in je browser:

+

${invitationUrl}

+

Deze link is 7 dagen geldig.

+
+ +
+ + + `; + + return this.sendEmail({ + to: email, + subject: 'Uitnodiging voor CMDB Editor', + html, + }); + } + + /** + * Send password reset email + */ + async sendPasswordResetEmail( + email: string, + token: string, + displayName?: string + ): Promise { + const frontendUrl = config.frontendUrl; + const resetUrl = `${frontendUrl}/reset-password?token=${token}`; + + const html = ` + + + + + + + +
+
+

Wachtwoord resetten

+
+
+

Beste ${displayName || 'gebruiker'},

+

Je hebt een verzoek gedaan om je wachtwoord te resetten.

+

Klik op de onderstaande knop om een nieuw wachtwoord in te stellen:

+

+ Wachtwoord resetten +

+

Of kopieer en plak deze link in je browser:

+

${resetUrl}

+
+

Let op: Als je dit verzoek niet hebt gedaan, negeer dan deze email. Je wachtwoord blijft ongewijzigd.

+
+

Deze link is 1 uur geldig.

+
+ +
+ + + `; + + return this.sendEmail({ + to: email, + subject: 'Wachtwoord resetten - CMDB Editor', + html, + }); + } + + /** + * Send email verification email + */ + async sendEmailVerificationEmail( + email: string, + token: string, + displayName?: string + ): Promise { + const frontendUrl = config.frontendUrl; + const verifyUrl = `${frontendUrl}/verify-email?token=${token}`; + + const html = ` + + + + + + + +
+
+

E-mailadres verifiëren

+
+
+

Beste ${displayName || 'gebruiker'},

+

Bedankt voor het aanmaken van je account. Verifieer je e-mailadres door op de onderstaande knop te klikken:

+

+ E-mailadres verifiëren +

+

Of kopieer en plak deze link in je browser:

+

${verifyUrl}

+
+ +
+ + + `; + + return this.sendEmail({ + to: email, + subject: 'E-mailadres verifiëren - CMDB Editor', + html, + }); + } + + /** + * Convert HTML to plain text (simple implementation) + */ + private htmlToText(html: string): string { + return html + .replace(/]*>.*?<\/style>/gis, '') + .replace(/]*>.*?<\/script>/gis, '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Check if email service is configured + */ + isConfigured(): boolean { + return this.isConfigured; + } +} + +export const emailService = new EmailService(); diff --git a/backend/src/services/encryptionService.ts b/backend/src/services/encryptionService.ts new file mode 100644 index 0000000..f03862e --- /dev/null +++ b/backend/src/services/encryptionService.ts @@ -0,0 +1,115 @@ +/** + * Encryption Service + * + * Provides encryption/decryption for sensitive data at rest (Jira PATs, API keys). + * Uses AES-256-GCM for authenticated encryption. + */ + +import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto'; +import { promisify } from 'util'; +import { logger } from './logger.js'; +import { config } from '../config/env.js'; + +const scryptAsync = promisify(scrypt); +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; // 32 bytes for AES-256 +const IV_LENGTH = 16; // 16 bytes for GCM +const SALT_LENGTH = 16; // 16 bytes for salt +const TAG_LENGTH = 16; // 16 bytes for authentication tag + +class EncryptionService { + private encryptionKey: Buffer | null = null; + + /** + * Get or derive encryption key from environment variable + */ + private async getEncryptionKey(): Promise { + if (this.encryptionKey) { + return this.encryptionKey; + } + + const envKey = process.env.ENCRYPTION_KEY; + if (!envKey) { + throw new Error('ENCRYPTION_KEY environment variable is required for encryption'); + } + + // If key is base64 encoded, decode it + let key: Buffer; + try { + key = Buffer.from(envKey, 'base64'); + if (key.length !== KEY_LENGTH) { + throw new Error('Invalid key length'); + } + } catch (error) { + // If not base64, derive key from string using scrypt + const salt = Buffer.from(envKey.substring(0, SALT_LENGTH), 'utf8'); + key = (await scryptAsync(envKey, salt, KEY_LENGTH)) as Buffer; + } + + this.encryptionKey = key; + return key; + } + + /** + * Encrypt a string value + */ + async encrypt(plaintext: string): Promise { + try { + const key = await this.getEncryptionKey(); + const iv = randomBytes(IV_LENGTH); + + const cipher = createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(plaintext, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + const authTag = cipher.getAuthTag(); + + // Combine IV + authTag + encrypted data + const combined = Buffer.concat([ + iv, + authTag, + Buffer.from(encrypted, 'base64') + ]); + + return combined.toString('base64'); + } catch (error) { + logger.error('Encryption error:', error); + throw new Error('Failed to encrypt data'); + } + } + + /** + * Decrypt a string value + */ + async decrypt(encryptedData: string): Promise { + try { + const key = await this.getEncryptionKey(); + const combined = Buffer.from(encryptedData, 'base64'); + + // Extract IV, authTag, and encrypted data + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = combined.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, undefined, 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + logger.error('Decryption error:', error); + throw new Error('Failed to decrypt data'); + } + } + + /** + * Check if encryption is properly configured + */ + isConfigured(): boolean { + return !!process.env.ENCRYPTION_KEY; + } +} + +export const encryptionService = new EncryptionService(); diff --git a/backend/src/services/jiraAssets.ts b/backend/src/services/jiraAssets.ts index 848e775..e5f3853 100644 --- a/backend/src/services/jiraAssets.ts +++ b/backend/src/services/jiraAssets.ts @@ -50,6 +50,11 @@ const ATTRIBUTE_NAMES = { APPLICATION_MANAGEMENT_HOSTING: 'Application Management - Hosting', APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM', TECHNISCHE_ARCHITECTUUR: 'Technische Architectuur (TA)', + REFERENCE: 'Reference', + CONFLUENCE_SPACE: 'Confluence Space', + SUPPLIER_TECHNICAL: 'Supplier Technical', + SUPPLIER_IMPLEMENTATION: 'Supplier Implementation', + SUPPLIER_CONSULTANCY: 'Supplier Consultancy', }; // Jira Data Center (Insight) uses different API endpoints than Jira Cloud (Assets) @@ -99,6 +104,8 @@ class JiraAssetsService { private numberOfUsersCache: Map | null = null; // Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue) private referenceObjectCache: Map = new Map(); + // Pending requests cache: prevents duplicate API calls for the same object (key: objectId -> Promise) + private pendingReferenceRequests: Map> = new Map(); // Cache: Team dashboard data private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null; private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -119,8 +126,8 @@ class JiraAssetsService { // Try both API paths - Insight (Data Center) and Assets (Cloud) this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`; this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`; + // Jira PAT is now configured per-user - default headers will use request token this.defaultHeaders = { - Authorization: `Bearer ${config.jiraPat}`, 'Content-Type': 'application/json', Accept: 'application/json', }; @@ -136,9 +143,14 @@ class JiraAssetsService { this.requestToken = null; } - // Get headers with the appropriate token (user token takes precedence) + // Get headers with the appropriate token (user token from middleware, fallback to service account) private get headers(): Record { - const token = this.requestToken || config.jiraPat; + // Token must be provided via setRequestToken() from middleware + // It comes from user's profile settings, OAuth session, or service account token (fallback) + const token = this.requestToken || config.jiraServiceAccountToken; + if (!token) { + throw new Error('Jira PAT not configured. Please configure it in your user settings or set JIRA_SERVICE_ACCOUNT_TOKEN in .env.'); + } return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', @@ -153,12 +165,15 @@ class JiraAssetsService { private async request( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, + retryCount: number = 0 ): Promise { const url = `${this.getBaseUrl()}${endpoint}`; + const maxRetries = 3; + const retryableStatusCodes = [502, 503, 504]; // Bad Gateway, Service Unavailable, Gateway Timeout try { - logger.debug(`Jira API request: ${options.method || 'GET'} ${url}`); + logger.debug(`Jira API request: ${options.method || 'GET'} ${url}${retryCount > 0 ? ` (retry ${retryCount}/${maxRetries})` : ''}`); const response = await fetch(url, { ...options, headers: { @@ -169,11 +184,29 @@ class JiraAssetsService { if (!response.ok) { const errorText = await response.text(); - throw new Error(`Jira API error: ${response.status} - ${errorText}`); + const error = new Error(`Jira API error: ${response.status} - ${errorText}`); + + // Retry on temporary gateway errors + if (retryableStatusCodes.includes(response.status) && retryCount < maxRetries) { + const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s + logger.warn(`Jira API temporary error ${response.status}, retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.request(endpoint, options, retryCount + 1); + } + + throw error; } return response.json() as Promise; } catch (error) { + // Retry on network errors (timeouts, connection errors) if we haven't exceeded max retries + if (retryCount < maxRetries && error instanceof TypeError && error.message.includes('fetch')) { + const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); + logger.warn(`Jira API network error, retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.request(endpoint, options, retryCount + 1); + } + logger.error(`Jira API request failed: ${endpoint}`, error); throw error; } @@ -285,19 +318,117 @@ class JiraAssetsService { attrSchema?: Map ): string | null { const attr = this.getAttributeByName(obj, attributeName, attrSchema); - if (!attr || attr.objectAttributeValues.length === 0) { - return null; + + // Enhanced logging for Reference field + if (attributeName === ATTRIBUTE_NAMES.REFERENCE) { + if (!attr) { + // Log all available attributes with their names and IDs for debugging + const availableAttrs = obj.attributes?.map(a => { + const schemaName = attrSchema?.get(a.objectTypeAttributeId); + const attrName = a.objectTypeAttribute?.name || 'unnamed'; + return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`; + }).join(', ') || 'none'; + logger.warn(`Reference attribute "${ATTRIBUTE_NAMES.REFERENCE}" not found for object ${obj.objectKey}.`); + logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`); + + // Try to find similar attribute names (case-insensitive, partial matches) + const similarAttrs = obj.attributes?.filter(a => { + const attrName = a.objectTypeAttribute?.name || ''; + const lowerAttrName = attrName.toLowerCase(); + return lowerAttrName.includes('reference') || lowerAttrName.includes('enterprise') || lowerAttrName.includes('architect'); + }); + if (similarAttrs && similarAttrs.length > 0) { + logger.warn(`Found similar attributes that might be the Reference field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`); + } + + return null; + } + if (attr.objectAttributeValues.length === 0) { + logger.warn(`Reference attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`); + return null; + } + logger.info(`Reference attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`); + } else if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) { + // Enhanced logging for Confluence Space field + if (!attr) { + // Log all available attributes with their names and IDs for debugging + const availableAttrs = obj.attributes?.map(a => { + const schemaName = attrSchema?.get(a.objectTypeAttributeId); + const attrName = a.objectTypeAttribute?.name || 'unnamed'; + return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`; + }).join(', ') || 'none'; + logger.warn(`Confluence Space attribute "${ATTRIBUTE_NAMES.CONFLUENCE_SPACE}" not found for object ${obj.objectKey}.`); + logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`); + + // Try to find similar attribute names (case-insensitive, partial matches) + const similarAttrs = obj.attributes?.filter(a => { + const attrName = a.objectTypeAttribute?.name || ''; + const lowerAttrName = attrName.toLowerCase(); + return lowerAttrName.includes('confluence') || lowerAttrName.includes('space'); + }); + if (similarAttrs && similarAttrs.length > 0) { + logger.warn(`Found similar attributes that might be the Confluence Space field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`); + } + + return null; + } + if (attr.objectAttributeValues.length === 0) { + logger.warn(`Confluence Space attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`); + return null; + } + logger.info(`Confluence Space attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`); + } else { + if (!attr || attr.objectAttributeValues.length === 0) { + return null; + } } const value = attr.objectAttributeValues[0]; // For select/status fields, use displayValue; for text fields, use value + let result: string | null = null; if (value.displayValue !== undefined && value.displayValue !== null) { - return value.displayValue; + result = String(value.displayValue); // Ensure it's a string + } else if (value.value !== undefined && value.value !== null) { + result = String(value.value); // Ensure it's a string } - if (value.value !== undefined && value.value !== null) { - return value.value; + + // Enhanced logging for Reference field + if (attributeName === ATTRIBUTE_NAMES.REFERENCE) { + logger.info(`Reference field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`); } - return null; + + // Enhanced logging for Confluence Space field + if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) { + logger.info(`Confluence Space field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`); + logger.info(`Confluence Space raw attribute: ${JSON.stringify(attr, null, 2)}`); + } + + // Check if result is the string "undefined" (which shouldn't happen but could) + if (result === 'undefined') { + logger.warn(`Reference field has string value "undefined" for object ${obj.objectKey}. This indicates a problem with the data.`); + return null; + } + + // Normalize empty/whitespace-only strings to null + // This handles: empty strings, whitespace-only, Unicode whitespace, zero-width chars + if (result !== null && typeof result === 'string') { + const trimmed = result.trim(); + // Check if empty after trim, or only whitespace (including Unicode whitespace) + if (trimmed === '' || /^\s*$/.test(result) || trimmed.replace(/[\u200B-\u200D\uFEFF]/g, '') === '') { + // Log for Reference field to help debug + if (attributeName === ATTRIBUTE_NAMES.REFERENCE) { + logger.debug(`Normalizing empty Reference field to null for object ${obj.objectKey}. Original value: "${result}" (length: ${result.length})`); + } + return null; + } + } + + // Final logging for Reference field + if (attributeName === ATTRIBUTE_NAMES.REFERENCE) { + logger.info(`Reference field final result for object ${obj.objectKey}: "${result}"`); + } + + return result; } // Get attribute value by attribute ID (useful when we know the ID but not the name) @@ -416,6 +547,146 @@ class JiraAssetsService { } // Get reference value with schema fallback for attribute lookup + // Helper to extract description from a JiraAssetsObject (same logic as getReferenceObjects) + private getDescriptionFromObject(refObj: JiraAssetsObject, refObjSchema?: Map): string | undefined { + if (!refObj) return undefined; + + if (!refObj.attributes || refObj.attributes.length === 0) { + logger.error(`getDescriptionFromObject: Object ${refObj.objectKey} has no attributes array`); + return undefined; + } + + // First try: Extract Description attribute using schema lookup (try multiple possible attribute names) + // Note: For Description fields, we need to extract the 'value' property from the attribute value object + let rawDescription: string | null = null; + + // Try getAttributeValueWithSchema first (handles value.value and value.displayValue) + rawDescription = this.getAttributeValueWithSchema(refObj, 'Description', refObjSchema) + || this.getAttributeValueWithSchema(refObj, 'Omschrijving', refObjSchema) + || this.getAttributeValueWithSchema(refObj, 'Beschrijving', refObjSchema); + + // Second try: If not found via schema, search directly in attributes by name + // Also check for partial matches and alternative names + if (!rawDescription && refObj.attributes && refObj.attributes.length > 0) { + for (const attr of refObj.attributes) { + // Get attribute name from schema if available, otherwise from objectTypeAttribute + let attrName = ''; + if (refObjSchema && refObjSchema.has(attr.objectTypeAttributeId)) { + attrName = refObjSchema.get(attr.objectTypeAttributeId)!; + } else if (attr.objectTypeAttribute?.name) { + attrName = attr.objectTypeAttribute.name; + } + const lowerAttrName = attrName.toLowerCase(); + + // Check if this attribute name matches description-related names (exact and partial) + const isDescriptionAttr = + lowerAttrName === 'description' || + lowerAttrName === 'omschrijving' || + lowerAttrName === 'beschrijving' || + lowerAttrName.includes('description') || + lowerAttrName.includes('omschrijving') || + lowerAttrName.includes('beschrijving'); + + if (isDescriptionAttr) { + // Found description attribute - extract value + if (attr.objectAttributeValues && attr.objectAttributeValues.length > 0) { + const attrValue = attr.objectAttributeValues[0]; + if (typeof attrValue === 'string') { + rawDescription = attrValue as string; + break; + } else if (attrValue && typeof attrValue === 'object') { + // Try value property first (most common for text fields) + if ('value' in attrValue && typeof attrValue.value === 'string' && attrValue.value.trim().length > 0) { + rawDescription = attrValue.value as string; + break; + } + // Try displayValue as fallback (for select fields) + if ('displayValue' in attrValue && typeof attrValue.displayValue === 'string' && attrValue.displayValue.trim().length > 0) { + rawDescription = attrValue.displayValue as string; + break; + } + // Try other possible property names + const attrValueObj = attrValue as Record; + for (const key of ['text', 'content', 'html', 'markup']) { + const value = attrValueObj[key]; + if (value && typeof value === 'string') { + const strValue = value as string; + if (strValue.trim().length > 0) { + rawDescription = strValue; + break; + } + } + } + if (rawDescription) break; + } + } + } + } + } + + // Third try: Check ALL attributes for any long text values (might be description stored elsewhere) + // Only do this if we still haven't found a description and there are attributes + if (!rawDescription && refObj.attributes && refObj.attributes.length > 0) { + for (const attr of refObj.attributes) { + // Skip attributes we already checked + let attrName = ''; + if (refObjSchema && refObjSchema.has(attr.objectTypeAttributeId)) { + attrName = refObjSchema.get(attr.objectTypeAttributeId)!; + } else if (attr.objectTypeAttribute?.name) { + attrName = attr.objectTypeAttribute.name; + } + const lowerAttrName = attrName.toLowerCase(); + + // Skip standard fields (Key, Name, Created, Updated, etc.) + if (['key', 'name', 'label', 'created', 'updated', 'id'].includes(lowerAttrName)) { + continue; + } + + if (attr.objectAttributeValues && attr.objectAttributeValues.length > 0) { + const attrValue: unknown = attr.objectAttributeValues[0]; + let potentialDescription: string | null = null; + + if (typeof attrValue === 'string') { + if (attrValue.trim().length > 50) { + // Long string might be a description + potentialDescription = attrValue; + } + } else if (attrValue !== null && attrValue !== undefined && typeof attrValue === 'object') { + // Check value property + const attrValueObj = attrValue as Record; + if ('value' in attrValueObj && typeof attrValueObj.value === 'string') { + if (attrValueObj.value.trim().length > 50) { + potentialDescription = attrValueObj.value; + } + } else if ('displayValue' in attrValueObj && typeof attrValueObj.displayValue === 'string') { + if (attrValueObj.displayValue.trim().length > 50) { + potentialDescription = attrValueObj.displayValue; + } + } + } + + // If we found a long text and it looks like a description (not just a short label or ID) + if (potentialDescription && potentialDescription.trim().length > 50 && !potentialDescription.match(/^[A-Z0-9-_]+$/)) { + rawDescription = potentialDescription; + break; + } + } + } + } + + if (!rawDescription) { + return undefined; + } + + // Strip HTML tags from description (same as getReferenceObjects) + if (typeof rawDescription === 'string') { + const description = stripHtmlTags(rawDescription); + return description || undefined; + } + + return undefined; + } + private async getReferenceValueWithSchema( obj: JiraAssetsObject, attributeName: string, @@ -428,53 +699,188 @@ class JiraAssetsService { const value = attr.objectAttributeValues[0]; if (value.referencedObject) { - return { - objectId: value.referencedObject.id.toString(), - key: value.referencedObject.objectKey, - name: value.referencedObject.label, - }; + // Try to get description from the embedded referenced object + // Embedded referenced objects might not have all attributes, so we might need to fetch separately + const embeddedRefObj = value.referencedObject; + + // Check factor caches first (they always have descriptions if available) + const objectId = embeddedRefObj.id.toString(); + if (this.dynamicsFactorsCache?.has(objectId)) { + return this.dynamicsFactorsCache.get(objectId)!; + } + if (this.complexityFactorsCache?.has(objectId)) { + return this.complexityFactorsCache.get(objectId)!; + } + if (this.numberOfUsersCache?.has(objectId)) { + return this.numberOfUsersCache.get(objectId)!; + } + if (this.applicationFunctionsCache?.has(objectId)) { + return this.applicationFunctionsCache.get(objectId)!; + } + + // Check cache - only use if it has description + const cached = this.referenceObjectCache.get(embeddedRefObj.objectKey); + if (cached && cached.description) { + return cached; + } else if (cached && !cached.description) { + // Remove from cache so we fetch it again + this.referenceObjectCache.delete(embeddedRefObj.objectKey); + this.referenceObjectCache.delete(embeddedRefObj.id.toString()); + } + + // Check if there's already a pending request for this object + const pendingRequest = this.pendingReferenceRequests.get(objectId); + if (pendingRequest) { + // Wait for the existing request instead of creating a duplicate + return pendingRequest; + } + + // Create a new request and store it in pending requests + const fetchPromise = (async (): Promise => { + // For embedded referenced objects, we need to fetch the full object to get description + let description: string | undefined = undefined; + + try { + await this.detectApiType(); + const url = `/object/${embeddedRefObj.id}?includeAttributes=true&includeAttributesDeep=1`; + const refObj = await this.request(url); + + if (refObj) { + if (!refObj.attributes || refObj.attributes.length === 0) { + logger.error(`getReferenceValueWithSchema: Object ${refObj.objectKey} has NO ATTRIBUTES despite includeAttributes=true!`); + } else { + // Fetch attribute schema for the referenced object type + let refObjSchema: Map | undefined; + const refObjectTypeId = refObj.objectType?.id; + const refObjectTypeName = refObj.objectType?.name || ''; + + if (refObjectTypeId) { + try { + if (this.attributeSchemaCache.has(refObjectTypeName)) { + refObjSchema = this.attributeSchemaCache.get(refObjectTypeName); + } else { + refObjSchema = await this.fetchAttributeSchema(refObjectTypeId); + if (refObjSchema) { + this.attributeSchemaCache.set(refObjectTypeName, refObjSchema); + } + } + } catch (error) { + // Schema fetch failed, continue without it + } + } + + // Extract description from the full object + description = this.getDescriptionFromObject(refObj, refObjSchema); + } + } + } catch (error) { + logger.warn(`getReferenceValueWithSchema: Could not fetch full object for ${embeddedRefObj.objectKey} (id: ${embeddedRefObj.id})`, error); + } + + const refValue: ReferenceValue = { + objectId: embeddedRefObj.id.toString(), + key: embeddedRefObj.objectKey, + name: embeddedRefObj.label, + ...(description && { description }), + }; + + // Always cache it for future use (even if description is undefined, so we don't fetch again) + this.referenceObjectCache.set(embeddedRefObj.objectKey, refValue); + this.referenceObjectCache.set(embeddedRefObj.id.toString(), refValue); + + return refValue; + })(); + + // Store the pending request + this.pendingReferenceRequests.set(objectId, fetchPromise); + + try { + const result = await fetchPromise; + return result; + } finally { + // Remove from pending requests when done (success or failure) + this.pendingReferenceRequests.delete(objectId); + } } // Fallback: if referencedObject is missing but we have a value, try to fetch it separately // Note: value.value might be an object key (e.g., "GOV-A") or an object ID if (value.value && !value.referencedObject) { - // Check cache first + // Check cache first - only use if it has description const cached = this.referenceObjectCache.get(value.value); - if (cached) { + if (cached && cached.description) { return cached; } - try { - // Try to fetch the referenced object by its key or ID - // First try as object key (most common) - let refObj: JiraAssetsObject | null = null; - try { - refObj = await this.request(`/object/${value.value}`); - } catch (keyError) { - // If that fails, try as object ID - try { - refObj = await this.request(`/object/${parseInt(value.value, 10)}`); - } catch (idError) { - // Both failed, log and continue - logger.debug(`getReferenceValueWithSchema: Could not fetch referenced object for value "${value.value}" (tried as key and ID) for attribute "${attributeName}" on object ${obj.objectKey}`); - } + // Check if there's already a pending request for this value + const pendingRequest = this.pendingReferenceRequests.get(value.value); + if (pendingRequest) { + // Wait for the existing request instead of creating a duplicate + return pendingRequest; + } + + // Create a new request and store it in pending requests + const fetchPromise = (async (): Promise => { + if (!value.value) { + return null; } - if (refObj) { - const refValue: ReferenceValue = { - objectId: refObj.id.toString(), - key: refObj.objectKey, - name: refObj.label, - }; - // Cache it for future use - this.referenceObjectCache.set(value.value, refValue); - this.referenceObjectCache.set(refObj.objectKey, refValue); - this.referenceObjectCache.set(refObj.id.toString(), refValue); - return refValue; + try { + // Try to fetch the referenced object by its key or ID + // First try as object key (most common) + let refObj: JiraAssetsObject | null = null; + try { + refObj = await this.request(`/object/${value.value}`); + } catch (keyError) { + // If that fails, try as object ID + try { + refObj = await this.request(`/object/${parseInt(value.value, 10)}`); + } catch (idError) { + // Both failed, continue + } + } + + if (refObj) { + // Fetch attribute schema for the referenced object type to get description + let refObjSchema: Map | undefined; + const refObjectTypeId = refObj.objectType?.id; + if (refObjectTypeId) { + try { + refObjSchema = await this.fetchAttributeSchema(refObjectTypeId); + } catch (error) { + // Schema fetch failed, continue without it + } + } + + const description = this.getDescriptionFromObject(refObj, refObjSchema); + + const refValue: ReferenceValue = { + objectId: refObj.id.toString(), + key: refObj.objectKey, + name: refObj.label, + description: description || undefined, + }; + // Cache it for future use + this.referenceObjectCache.set(value.value, refValue); + this.referenceObjectCache.set(refObj.objectKey, refValue); + this.referenceObjectCache.set(refObj.id.toString(), refValue); + return refValue; + } + } catch (error) { + logger.warn(`getReferenceValueWithSchema: Fallback fetch failed for ${value.value}`, error); } - } catch (error) { - // If fetching fails, log but don't throw - just return null - logger.debug(`getReferenceValueWithSchema: Failed to fetch referenced object ${value.value} for attribute "${attributeName}" on object ${obj.objectKey}`, error); + return null; + })(); + + // Store the pending request + this.pendingReferenceRequests.set(value.value, fetchPromise); + + try { + const result = await fetchPromise; + return result; + } finally { + // Remove from pending requests when done (success or failure) + this.pendingReferenceRequests.delete(value.value); } } @@ -571,10 +977,6 @@ class JiraAssetsService { this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), ]); - if (!governanceModel && obj.objectKey) { - logger.debug(`parseJiraObject: No governanceModel found for ${obj.objectKey}. Attribute name: ${ATTRIBUTE_NAMES.GOVERNANCE_MODEL}`); - } - const dynamicsFactor = this.enrichWithFactor(dynamicsFactorRaw, this.dynamicsFactorsCache); const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache); const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache); @@ -651,6 +1053,9 @@ class JiraAssetsService { // Parse Jira object for detail view (full details) with optional schema for attribute lookup private async parseJiraObjectDetails(obj: JiraAssetsObject, attrSchema?: Map): Promise { + logger.info(`[parseJiraObjectDetails] Parsing object ${obj.objectKey || obj.id} - this is called when fetching directly from Jira API`); + logger.info(`[parseJiraObjectDetails] Object has ${obj.attributes?.length || 0} attributes`); + const appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema); const rawDescription = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.DESCRIPTION, attrSchema); @@ -670,6 +1075,9 @@ class JiraAssetsService { platform, applicationManagementHosting, applicationManagementTAM, + supplierTechnical, + supplierImplementation, + supplierConsultancy, ] = await Promise.all([ this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema), this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema), @@ -682,6 +1090,9 @@ class JiraAssetsService { this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema), this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_TECHNICAL, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_IMPLEMENTATION, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_CONSULTANCY, attrSchema), ]); // Enrich with factors @@ -689,6 +1100,19 @@ class JiraAssetsService { const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache); const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache); + // Get Team via Subteam reference if Subteam exists + let applicationTeam: ReferenceValue | null = null; + if (applicationSubteam?.objectId) { + try { + // Use the subteam-to-team mapping cache + const subteamToTeamMapping = await this.getSubteamToTeamMapping(); + applicationTeam = subteamToTeamMapping.get(applicationSubteam.objectId) || null; + } catch (error) { + logger.debug(`Failed to fetch Team via Subteam ${applicationSubteam.objectId}:`, error); + // Continue without Team if lookup fails + } + } + const applicationDetails: ApplicationDetails = { id: obj.id.toString(), key: obj.objectKey, @@ -716,7 +1140,7 @@ class JiraAssetsService { governanceModel, // "Application Management - Subteam" on ApplicationComponent references Subteam objects applicationSubteam, - applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent + applicationTeam, // Team is looked up via Subteam applicationType, platform, requiredEffortApplicationManagement: null, @@ -729,6 +1153,97 @@ class JiraAssetsService { })(), applicationManagementHosting, applicationManagementTAM, + reference: (() => { + // Try multiple possible attribute names for Reference field + const possibleNames = [ + ATTRIBUTE_NAMES.REFERENCE, // 'Reference' + 'Enterprise Architect Reference', + 'EA Reference', + 'Enterprise Architect', + 'EA GUID', + 'GUID', + 'Reference (EA)', + ]; + + let refValue: string | null = null; + let foundAttrName: string | null = null; + + // Try each possible name + for (const attrName of possibleNames) { + const value = this.getAttributeValueWithSchema(obj, attrName, attrSchema); + if (value !== null && value !== undefined && value !== '') { + refValue = value; + foundAttrName = attrName; + logger.info(`Reference field found for object ${obj.objectKey} using attribute name "${attrName}": "${refValue}"`); + break; + } + } + + // If still not found, try manual search through all attributes + if (refValue === null || refValue === undefined) { + logger.warn(`Reference field not found using standard names for object ${obj.objectKey}. Searching all attributes...`); + const allAttrs = obj.attributes || []; + + // Try to find by partial name match + let refAttr = allAttrs.find(a => { + const schemaName = attrSchema?.get(a.objectTypeAttributeId); + const attrName = a.objectTypeAttribute?.name?.toLowerCase() || ''; + const schemaLower = schemaName?.toLowerCase() || ''; + + return attrName.includes('reference') || + schemaLower.includes('reference') || + attrName.includes('enterprise') || + attrName.includes('architect') || + attrName.includes('guid') || + attrName.includes('ea'); + }); + + if (refAttr) { + foundAttrName = refAttr.objectTypeAttribute?.name || 'unknown'; + logger.warn(`Reference attribute found manually: "${foundAttrName}" (ID: ${refAttr.objectTypeAttributeId})`); + logger.warn(`Attribute values: ${JSON.stringify(refAttr.objectAttributeValues, null, 2)}`); + + // Try to extract value manually + if (refAttr.objectAttributeValues.length > 0) { + const value = refAttr.objectAttributeValues[0]; + const manualValue = value.displayValue !== undefined && value.displayValue !== null + ? String(value.displayValue) + : value.value !== undefined && value.value !== null + ? String(value.value) + : null; + + if (manualValue && manualValue.trim() !== '' && manualValue !== 'undefined') { + refValue = manualValue.trim(); + logger.warn(`Manual extraction found value: "${refValue}" from attribute "${foundAttrName}"`); + } else { + logger.warn(`Manual extraction found empty/invalid value: "${manualValue}" (type: ${typeof manualValue})`); + } + } + } else { + // Log all available attributes for debugging + logger.warn(`Reference attribute not found in object ${obj.objectKey}.`); + logger.warn(`Available attributes (${allAttrs.length}):`); + allAttrs.forEach(a => { + const schemaName = attrSchema?.get(a.objectTypeAttributeId); + const attrName = a.objectTypeAttribute?.name || 'unnamed'; + const hasValues = a.objectAttributeValues?.length > 0; + logger.warn(` - ${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'}, hasValues: ${hasValues})`); + }); + } + } + + if (refValue) { + logger.info(`Reference field final result for object ${obj.objectKey}: "${refValue}" (from attribute: ${foundAttrName || 'standard'})`); + } else { + logger.warn(`Reference field is null/undefined for object ${obj.objectKey} after all attempts.`); + } + + return refValue; + })(), + confluenceSpace: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.CONFLUENCE_SPACE, attrSchema), + supplierTechnical, + supplierImplementation, + supplierConsultancy, }; // Calculate required effort application management @@ -1201,34 +1716,6 @@ class JiraAssetsService { logger.info(`Cached attribute schema for ${objectType}: ${attrSchema.size} attributes`); } } - - // Log raw API response for first object to debug attribute structure - logger.info(`=== Debug: Reference data for ${objectType} ===`); - logger.info(`Object: id=${firstObj.id}, key=${firstObj.objectKey}, label=${firstObj.label}`); - logger.info(`ObjectType: id=${firstObj.objectType?.id}, name=${firstObj.objectType?.name}`); - logger.info(`Attributes count: ${firstObj.attributes?.length || 0}`); - if (firstObj.attributes && firstObj.attributes.length > 0) { - firstObj.attributes.forEach((attr, idx) => { - let attrInfo: string; - if (attr.objectTypeAttribute) { - attrInfo = `name="${attr.objectTypeAttribute.name}", typeAttrId=${attr.objectTypeAttribute.id}`; - } else { - // Try to get name from schema - const schemaName = attrSchema?.get(attr.objectTypeAttributeId); - attrInfo = `(objectTypeAttribute MISSING, attrId=${attr.objectTypeAttributeId}, schemaName="${schemaName || 'unknown'}")`; - } - const values = attr.objectAttributeValues.map(v => { - if (v.displayValue) return `displayValue="${v.displayValue}"`; - if (v.value) return `value="${v.value}"`; - if (v.referencedObject) return `ref:${v.referencedObject.label}`; - return 'empty'; - }).join(', '); - logger.info(` Attr[${idx}]: ${attrInfo} = [${values}]`); - }); - } else { - logger.info(` No attributes array or empty!`); - } - logger.info(`=== End Debug ===`); } const results = response.objectEntries.map((obj) => { @@ -1314,11 +1801,6 @@ class JiraAssetsService { return result; }); - // Log first result for debugging - if (results.length > 0) { - logger.debug(`Reference data for ${objectType}: first item = ${JSON.stringify(results[0])}`); - } - return results; } catch (error) { logger.error(`Failed to get reference objects for type: ${objectType}`, error); @@ -1546,6 +2028,118 @@ class JiraAssetsService { return this.getReferenceObjects('Application Management - TAM'); } + /** + * Get enriched ReferenceValue with description from cache (if available) + * This allows other services to enrich ObjectReferences with descriptions + */ + getEnrichedReferenceValue(objectKey: string, objectId?: string): ReferenceValue | null { + // Try by objectKey first (most common) + const cachedByKey = this.referenceObjectCache.get(objectKey); + if (cachedByKey) { + return cachedByKey; + } + + // Try by objectId if provided + if (objectId) { + const cachedById = this.referenceObjectCache.get(objectId); + if (cachedById) { + return cachedById; + } + } + + return null; + } + + /** + * Fetch and enrich ReferenceValue with description (async version that fetches if needed) + * This method will: + * 1. Check cache first - if found WITH description, return it + * 2. If cache miss OR no description, fetch the full object from Jira + * 3. Extract description and cache the result + * 4. Return the enriched ReferenceValue + */ + async fetchEnrichedReferenceValue(objectKey: string, objectId?: string): Promise { + // Check cache first - if we have a cached value WITH description, return it immediately + const cachedByKey = this.referenceObjectCache.get(objectKey); + let cachedById: ReferenceValue | undefined = undefined; + + if (cachedByKey && cachedByKey.description) { + return cachedByKey; + } + + if (objectId) { + cachedById = this.referenceObjectCache.get(objectId); + if (cachedById && cachedById.description) { + return cachedById; + } + } + + // Cache miss or no description - fetch the full object + const objectIdToFetch = objectId || objectKey; + if (!objectIdToFetch) { + logger.warn(`fetchEnrichedReferenceValue: No objectId or objectKey provided`); + return null; + } + + try { + const url = `/object/${objectIdToFetch}?includeAttributes=true&includeAttributesDeep=1`; + const refObj = await this.request(url); + + if (!refObj) { + logger.warn(`fetchEnrichedReferenceValue: No object returned for ${objectKey}`); + return null; + } + + // Fetch attribute schema for the referenced object type + let refObjSchema: Map | undefined; + const refObjectTypeId = refObj.objectType?.id; + const refObjectTypeName = refObj.objectType?.name || ''; + + if (refObjectTypeId) { + try { + if (this.attributeSchemaCache.has(refObjectTypeName)) { + refObjSchema = this.attributeSchemaCache.get(refObjectTypeName); + } else { + refObjSchema = await this.fetchAttributeSchema(refObjectTypeId); + if (refObjSchema) { + this.attributeSchemaCache.set(refObjectTypeName, refObjSchema); + } + } + } catch (error) { + // Schema fetch failed, continue without it + } + } + + // Extract description from the full object + const description = this.getDescriptionFromObject(refObj, refObjSchema); + + const refValue: ReferenceValue = { + objectId: refObj.id.toString(), + key: refObj.objectKey, + name: refObj.label, + ...(description && { description }), + }; + + // Cache it for future use (by both key and ID) + this.referenceObjectCache.set(refObj.objectKey, refValue); + this.referenceObjectCache.set(refObj.id.toString(), refValue); + + return refValue; + } catch (error) { + logger.warn(`fetchEnrichedReferenceValue: Could not fetch object ${objectKey} (id: ${objectIdToFetch})`, error); + + // If we had a cached value without description, return it anyway + if (cachedByKey) { + return cachedByKey; + } + if (objectId && cachedById) { + return cachedById; + } + + return null; + } + } + async testConnection(): Promise { try { await this.detectApiType(); @@ -2396,7 +2990,7 @@ class JiraAssetsService { `attributes=Key,Object+Type,Label,Name,Description,Status&` + `offset=0&limit=${limit}`; - logger.info(`CMDB search: ${searchUrl}`); + logger.info(`CMDB search API call - Query: "${query}", URL: ${searchUrl}`); const response = await fetch(searchUrl, { method: 'GET', diff --git a/backend/src/services/jiraAssetsClient.ts b/backend/src/services/jiraAssetsClient.ts index 8df7364..477d406 100644 --- a/backend/src/services/jiraAssetsClient.ts +++ b/backend/src/services/jiraAssetsClient.ts @@ -49,7 +49,8 @@ class JiraAssetsClient { private baseUrl: string; private defaultHeaders: Record; private isDataCenter: boolean | null = null; - private requestToken: string | null = null; + private serviceAccountToken: string | null = null; // Service account token from .env (for read operations) + private requestToken: string | null = null; // User PAT from profile settings (for write operations) constructor() { this.baseUrl = `${config.jiraHost}/rest/insight/1.0`; @@ -58,17 +59,18 @@ class JiraAssetsClient { 'Accept': 'application/json', }; - // Add PAT authentication if configured - if (config.jiraAuthMethod === 'pat' && config.jiraPat) { - this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`; - } + // Initialize service account token from config (for read operations) + this.serviceAccountToken = config.jiraServiceAccountToken || null; + + // User PAT is configured per-user in profile settings + // Authorization header is set per-request via setRequestToken() } // ========================================================================== // Request Token Management (for user-context requests) // ========================================================================== - setRequestToken(token: string): void { + setRequestToken(token: string | null): void { this.requestToken = token; } @@ -76,6 +78,21 @@ class JiraAssetsClient { this.requestToken = null; } + /** + * Check if a token is configured for read operations + * Uses service account token (primary) or user PAT (fallback) + */ + hasToken(): boolean { + return !!(this.serviceAccountToken || this.requestToken); + } + + /** + * Check if user PAT is configured for write operations + */ + hasUserToken(): boolean { + return !!this.requestToken; + } + // ========================================================================== // API Detection // ========================================================================== @@ -95,12 +112,26 @@ class JiraAssetsClient { } } - private getHeaders(): Record { + /** + * Get headers for API requests + * @param forWrite - If true, requires user PAT. If false, uses service account token (or user PAT as fallback) + */ + private getHeaders(forWrite: boolean = false): Record { const headers = { ...this.defaultHeaders }; - // Use request-scoped token if available (for user context) - if (this.requestToken) { + if (forWrite) { + // Write operations require user PAT + if (!this.requestToken) { + throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.'); + } headers['Authorization'] = `Bearer ${this.requestToken}`; + } else { + // Read operations: use service account token (primary) or user PAT (fallback) + const token = this.serviceAccountToken || this.requestToken; + if (!token) { + throw new Error('Jira token not configured. Please configure JIRA_SERVICE_ACCOUNT_TOKEN in .env or a Personal Access Token in your user settings.'); + } + headers['Authorization'] = `Bearer ${token}`; } return headers; @@ -110,15 +141,21 @@ class JiraAssetsClient { // Core API Methods // ========================================================================== - private async request(endpoint: string, options: RequestInit = {}): Promise { + /** + * Make a request to Jira API + * @param endpoint - API endpoint + * @param options - Request options + * @param forWrite - If true, requires user PAT for write operations + */ + private async request(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise { const url = `${this.baseUrl}${endpoint}`; - logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`); + logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url} (forWrite: ${forWrite})`); const response = await fetch(url, { ...options, headers: { - ...this.getHeaders(), + ...this.getHeaders(forWrite), ...options.headers, }, }); @@ -136,10 +173,16 @@ class JiraAssetsClient { // ========================================================================== async testConnection(): Promise { + // Don't test connection if no token is configured + if (!this.hasToken()) { + logger.debug('JiraAssetsClient: No token configured, skipping connection test'); + return false; + } + try { await this.detectApiType(); const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, { - headers: this.getHeaders(), + headers: this.getHeaders(false), // Read operation - uses service account token }); return response.ok; } catch (error) { @@ -150,7 +193,9 @@ class JiraAssetsClient { async getObject(objectId: string): Promise { try { - return await this.request(`/object/${objectId}`); + // Include attributes and deep attributes to get full details of referenced objects (including descriptions) + const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=1`; + return await this.request(url, {}, false); // Read operation } catch (error) { // Check if this is a 404 (object not found / deleted) if (error instanceof Error && error.message.includes('404')) { @@ -182,7 +227,7 @@ class JiraAssetsClient { includeAttributesDeep: '1', objectSchemaId: config.jiraSchemaId, }); - response = await this.request(`/aql/objects?${params.toString()}`); + response = await this.request(`/aql/objects?${params.toString()}`, {}, false); // Read operation } catch (error) { // Fallback to deprecated IQL endpoint logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`); @@ -194,7 +239,7 @@ class JiraAssetsClient { includeAttributesDeep: '1', objectSchemaId: config.jiraSchemaId, }); - response = await this.request(`/iql/objects?${params.toString()}`); + response = await this.request(`/iql/objects?${params.toString()}`, {}, false); // Read operation } } else { // Jira Cloud uses POST for AQL @@ -205,8 +250,9 @@ class JiraAssetsClient { page, resultPerPage: pageSize, includeAttributes: true, + includeAttributesDeep: 1, // Include attributes of referenced objects (e.g., descriptions) }), - }); + }, false); // Read operation } const totalCount = response.totalFilterCount || response.totalCount || 0; @@ -287,6 +333,11 @@ class JiraAssetsClient { } async updateObject(objectId: string, payload: JiraUpdatePayload): Promise { + // Write operations require user PAT + if (!this.hasUserToken()) { + throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.'); + } + try { logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, { attributeCount: payload.attributes.length, @@ -296,7 +347,7 @@ class JiraAssetsClient { await this.request(`/object/${objectId}`, { method: 'PUT', body: JSON.stringify(payload), - }); + }, true); // Write operation - requires user PAT logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`); return true; @@ -337,7 +388,36 @@ class JiraAssetsClient { // Parse each attribute based on schema for (const attrDef of typeDef.attributes) { const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name); - result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef); + const parsedValue = this.parseAttributeValue(jiraAttr, attrDef); + result[attrDef.fieldName] = parsedValue; + + // Debug logging for Confluence Space field + if (attrDef.fieldName === 'confluenceSpace') { + logger.info(`[Confluence Space Debug] Object ${jiraObj.objectKey || jiraObj.id}:`); + logger.info(` - Attribute definition: name="${attrDef.name}", jiraId=${attrDef.jiraId}, type="${attrDef.type}"`); + logger.info(` - Found attribute: ${jiraAttr ? 'yes' : 'no'}`); + if (!jiraAttr) { + // Log all available attributes to help debug + const availableAttrs = jiraObj.attributes?.map(a => { + const attrName = a.objectTypeAttribute?.name || 'unnamed'; + return `${attrName} (ID: ${a.objectTypeAttributeId})`; + }).join(', ') || 'none'; + logger.warn(` - Available attributes (${jiraObj.attributes?.length || 0}): ${availableAttrs}`); + + // Try to find similar attributes + const similarAttrs = jiraObj.attributes?.filter(a => { + const attrName = a.objectTypeAttribute?.name || ''; + const lowerAttrName = attrName.toLowerCase(); + return lowerAttrName.includes('confluence') || lowerAttrName.includes('space'); + }); + if (similarAttrs && similarAttrs.length > 0) { + logger.warn(` - Found similar attributes: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`); + } + } else { + logger.info(` - Raw attribute: ${JSON.stringify(jiraAttr, null, 2)}`); + logger.info(` - Parsed value: ${parsedValue} (type: ${typeof parsedValue})`); + } + } } return result as T; @@ -363,7 +443,7 @@ class JiraAssetsClient { private parseAttributeValue( jiraAttr: JiraAssetsAttribute | undefined, - attrDef: { type: string; isMultiple: boolean } + attrDef: { type: string; isMultiple: boolean; fieldName?: string } ): unknown { if (!jiraAttr?.objectAttributeValues?.length) { return attrDef.isMultiple ? [] : null; @@ -371,6 +451,30 @@ class JiraAssetsClient { const values = jiraAttr.objectAttributeValues; + // Generic Confluence field detection: check if any value has a confluencePage + // This works for all Confluence fields regardless of their declared type (float, text, etc.) + const hasConfluencePage = values.some(v => v.confluencePage); + if (hasConfluencePage) { + const confluencePage = values[0]?.confluencePage; + if (confluencePage?.url) { + logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluencePage.url}`); + // For multiple values, return array of URLs; for single, return the URL string + if (attrDef.isMultiple) { + return values + .filter(v => v.confluencePage?.url) + .map(v => v.confluencePage!.url); + } + return confluencePage.url; + } + // Fallback to displayValue if no URL + const displayVal = values[0]?.displayValue; + if (displayVal) { + logger.info(`[Confluence Field Parse] Using displayValue as fallback for field "${attrDef.fieldName || 'unknown'}": ${displayVal}`); + return String(displayVal); + } + return null; + } + switch (attrDef.type) { case 'reference': { const refs = values @@ -403,8 +507,19 @@ class JiraAssetsClient { } case 'float': { + // Regular float parsing const val = values[0]?.value; - return val ? parseFloat(val) : null; + const displayVal = values[0]?.displayValue; + // Try displayValue first, then value + if (displayVal !== undefined && displayVal !== null) { + const parsed = typeof displayVal === 'string' ? parseFloat(displayVal) : Number(displayVal); + return isNaN(parsed) ? null : parsed; + } + if (val !== undefined && val !== null) { + const parsed = typeof val === 'string' ? parseFloat(val) : Number(val); + return isNaN(parsed) ? null : parsed; + } + return null; } case 'boolean': { diff --git a/backend/src/services/roleService.ts b/backend/src/services/roleService.ts new file mode 100644 index 0000000..c29a367 --- /dev/null +++ b/backend/src/services/roleService.ts @@ -0,0 +1,385 @@ +/** + * Role Service + * + * Handles dynamic role and permission management. + */ + +import { logger } from './logger.js'; +import { getAuthDatabase } from './database/migrations.js'; + +const isPostgres = (): boolean => { + return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; +}; + +export interface Role { + id: number; + name: string; + description: string | null; + is_system_role: boolean; + created_at: string; +} + +export interface Permission { + id: number; + name: string; + description: string | null; + resource: string | null; +} + +export interface CreateRoleInput { + name: string; + description?: string; +} + +export interface UpdateRoleInput { + name?: string; + description?: string; +} + +class RoleService { + /** + * Get all roles + */ + async getAllRoles(): Promise { + const db = getAuthDatabase(); + try { + return await db.query( + 'SELECT * FROM roles ORDER BY name' + ); + } finally { + await db.close(); + } + } + + /** + * Get role by ID + */ + async getRoleById(id: number): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM roles WHERE id = ?', + [id] + ); + } finally { + await db.close(); + } + } + + /** + * Get role by name + */ + async getRoleByName(name: string): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM roles WHERE name = ?', + [name] + ); + } finally { + await db.close(); + } + } + + /** + * Create a new role + */ + async createRole(input: CreateRoleInput): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + // Check if role already exists + const existing = await this.getRoleByName(input.name); + if (existing) { + throw new Error('Role already exists'); + } + + await db.execute( + 'INSERT INTO roles (name, description, is_system_role, created_at) VALUES (?, ?, ?, ?)', + [input.name, input.description || null, isPostgres() ? false : 0, now] + ); + + const role = await this.getRoleByName(input.name); + if (!role) { + throw new Error('Failed to create role'); + } + + logger.info(`Role created: ${role.name}`); + return role; + } finally { + await db.close(); + } + } + + /** + * Update role + */ + async updateRole(id: number, input: UpdateRoleInput): Promise { + const db = getAuthDatabase(); + + try { + const role = await this.getRoleById(id); + if (!role) { + throw new Error('Role not found'); + } + + if (role.is_system_role) { + throw new Error('Cannot update system role'); + } + + const updates: string[] = []; + const values: any[] = []; + + if (input.name !== undefined) { + // Check if name already exists for another role + const existing = await db.queryOne( + 'SELECT id FROM roles WHERE name = ? AND id != ?', + [input.name, id] + ); + if (existing) { + throw new Error('Role name already exists'); + } + updates.push('name = ?'); + values.push(input.name); + } + + if (input.description !== undefined) { + updates.push('description = ?'); + values.push(input.description); + } + + if (updates.length === 0) { + return role; + } + + values.push(id); + + await db.execute( + `UPDATE roles SET ${updates.join(', ')} WHERE id = ?`, + values + ); + + const updated = await this.getRoleById(id); + if (!updated) { + throw new Error('Role not found'); + } + + logger.info(`Role updated: ${updated.name}`); + return updated; + } finally { + await db.close(); + } + } + + /** + * Delete role + */ + async deleteRole(id: number): Promise { + const db = getAuthDatabase(); + + try { + const role = await this.getRoleById(id); + if (!role) { + return false; + } + + if (role.is_system_role) { + throw new Error('Cannot delete system role'); + } + + const result = await db.execute( + 'DELETE FROM roles WHERE id = ?', + [id] + ); + + logger.info(`Role deleted: ${role.name}`); + return result > 0; + } finally { + await db.close(); + } + } + + /** + * Get all permissions + */ + async getAllPermissions(): Promise { + const db = getAuthDatabase(); + try { + return await db.query( + 'SELECT * FROM permissions ORDER BY resource, name' + ); + } finally { + await db.close(); + } + } + + /** + * Get permission by ID + */ + async getPermissionById(id: number): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM permissions WHERE id = ?', + [id] + ); + } finally { + await db.close(); + } + } + + /** + * Get permission by name + */ + async getPermissionByName(name: string): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM permissions WHERE name = ?', + [name] + ); + } finally { + await db.close(); + } + } + + /** + * Get permissions for a role + */ + async getRolePermissions(roleId: number): Promise { + const db = getAuthDatabase(); + try { + return await db.query( + `SELECT p.* FROM permissions p + INNER JOIN role_permissions rp ON p.id = rp.permission_id + WHERE rp.role_id = ? + ORDER BY p.resource, p.name`, + [roleId] + ); + } finally { + await db.close(); + } + } + + /** + * Assign permission to role + */ + async assignPermissionToRole(roleId: number, permissionId: number): Promise { + const db = getAuthDatabase(); + + try { + await db.execute( + `INSERT INTO role_permissions (role_id, permission_id) + VALUES (?, ?) + ON CONFLICT(role_id, permission_id) DO NOTHING`, + [roleId, permissionId] + ); + return true; + } catch (error: any) { + // Handle SQLite (no ON CONFLICT support) + if (error.message?.includes('UNIQUE constraint')) { + return false; // Already assigned + } + throw error; + } finally { + await db.close(); + } + } + + /** + * Remove permission from role + */ + async removePermissionFromRole(roleId: number, permissionId: number): Promise { + const db = getAuthDatabase(); + try { + const result = await db.execute( + 'DELETE FROM role_permissions WHERE role_id = ? AND permission_id = ?', + [roleId, permissionId] + ); + return result > 0; + } finally { + await db.close(); + } + } + + /** + * Get user permissions (from all roles) + */ + async getUserPermissions(userId: number): Promise { + const db = getAuthDatabase(); + try { + return await db.query( + `SELECT DISTINCT p.* FROM permissions p + INNER JOIN role_permissions rp ON p.id = rp.permission_id + INNER JOIN user_roles ur ON rp.role_id = ur.role_id + WHERE ur.user_id = ? + ORDER BY p.resource, p.name`, + [userId] + ); + } finally { + await db.close(); + } + } + + /** + * Check if user has permission + */ + async userHasPermission(userId: number, permissionName: string): Promise { + const db = getAuthDatabase(); + try { + const result = await db.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM permissions p + INNER JOIN role_permissions rp ON p.id = rp.permission_id + INNER JOIN user_roles ur ON rp.role_id = ur.role_id + WHERE ur.user_id = ? AND p.name = ?`, + [userId, permissionName] + ); + + const count = isPostgres() ? (result?.count || 0) : (result?.count || 0); + return parseInt(String(count)) > 0; + } finally { + await db.close(); + } + } + + /** + * Check if user has role + */ + async userHasRole(userId: number, roleName: string): Promise { + const db = getAuthDatabase(); + try { + const result = await db.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM roles r + INNER JOIN user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = ? AND r.name = ?`, + [userId, roleName] + ); + + const count = isPostgres() ? (result?.count || 0) : (result?.count || 0); + return parseInt(String(count)) > 0; + } finally { + await db.close(); + } + } + + /** + * Get user roles + */ + async getUserRoles(userId: number): Promise { + const db = getAuthDatabase(); + try { + return await db.query( + `SELECT r.* FROM roles r + INNER JOIN user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = ? + ORDER BY r.name`, + [userId] + ); + } finally { + await db.close(); + } + } +} + +export const roleService = new RoleService(); diff --git a/backend/src/services/syncEngine.ts b/backend/src/services/syncEngine.ts index b4138ad..6ce33d8 100644 --- a/backend/src/services/syncEngine.ts +++ b/backend/src/services/syncEngine.ts @@ -80,6 +80,8 @@ class SyncEngine { /** * Initialize the sync engine * Performs initial sync if cache is cold, then starts incremental sync + * Note: Sync engine uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN) + * for all read operations. Write operations require user PAT from profile settings. */ async initialize(): Promise { if (this.isRunning) { @@ -88,27 +90,11 @@ class SyncEngine { } logger.info('SyncEngine: Initializing...'); + logger.info('SyncEngine: Sync uses service account token (JIRA_SERVICE_ACCOUNT_TOKEN) from .env'); this.isRunning = true; - // Check if we need a full sync - const stats = await cacheStore.getStats(); - const lastFullSync = stats.lastFullSync; - const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000); - - if (needsFullSync) { - logger.info('SyncEngine: Cache is cold or stale, starting full sync in background...'); - // Run full sync in background (non-blocking) - this.fullSync().catch(err => { - logger.error('SyncEngine: Background full sync failed', err); - }); - } else { - logger.info('SyncEngine: Cache is warm, skipping initial full sync'); - } - - // Start incremental sync scheduler - this.startIncrementalSyncScheduler(); - - logger.info('SyncEngine: Initialized'); + // Sync can run automatically using service account token + logger.info('SyncEngine: Initialized (using service account token for sync operations)'); } /** @@ -140,8 +126,22 @@ class SyncEngine { /** * Perform a full sync of all object types + * Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN) */ async fullSync(): Promise { + // Check if service account token is configured (sync uses service account token) + if (!jiraAssetsClient.hasToken()) { + logger.warn('SyncEngine: Jira service account token not configured, cannot perform sync'); + return { + success: false, + stats: [], + totalObjects: 0, + totalRelations: 0, + duration: 0, + error: 'Jira service account token (JIRA_SERVICE_ACCOUNT_TOKEN) not configured in .env. Please configure it to enable sync operations.', + }; + } + if (this.isSyncing) { logger.warn('SyncEngine: Sync already in progress'); return { @@ -312,11 +312,18 @@ class SyncEngine { /** * Perform an incremental sync (only updated objects) + * Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN) * * Note: On Jira Data Center, IQL-based incremental sync is not supported. * We instead check if a periodic full sync is needed. */ async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> { + // Check if service account token is configured (sync uses service account token) + if (!jiraAssetsClient.hasToken()) { + logger.debug('SyncEngine: Jira service account token not configured, skipping incremental sync'); + return { success: false, updatedCount: 0 }; + } + if (this.isSyncing) { return { success: false, updatedCount: 0 }; } diff --git a/backend/src/services/userService.ts b/backend/src/services/userService.ts new file mode 100644 index 0000000..1955b16 --- /dev/null +++ b/backend/src/services/userService.ts @@ -0,0 +1,616 @@ +/** + * User Service + * + * Handles user CRUD operations, password management, email verification, and role assignment. + */ + +import bcrypt from 'bcrypt'; +import { randomBytes } from 'crypto'; +import { logger } from './logger.js'; +import { getAuthDatabase } from './database/migrations.js'; +import { emailService } from './emailService.js'; + +const SALT_ROUNDS = 10; +const isPostgres = (): boolean => { + return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; +}; + +export interface User { + id: number; + email: string; + username: string; + password_hash: string; + display_name: string | null; + is_active: boolean; + email_verified: boolean; + email_verification_token: string | null; + password_reset_token: string | null; + password_reset_expires: string | null; + created_at: string; + updated_at: string; + last_login: string | null; +} + +export interface CreateUserInput { + email: string; + username: string; + password?: string; + display_name?: string; + send_invitation?: boolean; +} + +export interface UpdateUserInput { + email?: string; + username?: string; + display_name?: string; + is_active?: boolean; +} + +class UserService { + /** + * Hash a password + */ + async hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); + } + + /** + * Verify a password + */ + async verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } + + /** + * Generate a secure random token + */ + generateToken(): string { + return randomBytes(32).toString('hex'); + } + + /** + * Create a new user + */ + async createUser(input: CreateUserInput): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + // Check if email or username already exists + const existingEmail = await db.queryOne( + 'SELECT id FROM users WHERE email = ?', + [input.email] + ); + if (existingEmail) { + throw new Error('Email already exists'); + } + + const existingUsername = await db.queryOne( + 'SELECT id FROM users WHERE username = ?', + [input.username] + ); + if (existingUsername) { + throw new Error('Username already exists'); + } + + // Hash password if provided + let passwordHash = ''; + if (input.password) { + passwordHash = await this.hashPassword(input.password); + } else { + // Generate a temporary password hash (user will set password via invitation) + passwordHash = await this.hashPassword(this.generateToken()); + } + + // Generate email verification token + const emailVerificationToken = this.generateToken(); + + // Insert user + await db.execute( + `INSERT INTO users ( + email, username, password_hash, display_name, + is_active, email_verified, email_verification_token, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + input.email, + input.username, + passwordHash, + input.display_name || null, + isPostgres() ? true : 1, + isPostgres() ? false : 0, + emailVerificationToken, + now, + now, + ] + ); + + const user = await db.queryOne( + 'SELECT * FROM users WHERE email = ?', + [input.email] + ); + + if (!user) { + throw new Error('Failed to create user'); + } + + // Send invitation email if requested + if (input.send_invitation && !input.password) { + await this.sendInvitation(user.id); + } + + logger.info(`User created: ${user.email}`); + return user; + } finally { + await db.close(); + } + } + + /** + * Get user by ID + */ + async getUserById(id: number): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM users WHERE id = ?', + [id] + ); + } finally { + await db.close(); + } + } + + /** + * Get user by email + */ + async getUserByEmail(email: string): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM users WHERE email = ?', + [email] + ); + } finally { + await db.close(); + } + } + + /** + * Get user by username + */ + async getUserByUsername(username: string): Promise { + const db = getAuthDatabase(); + try { + return await db.queryOne( + 'SELECT * FROM users WHERE username = ?', + [username] + ); + } finally { + await db.close(); + } + } + + /** + * Get all users + */ + async getAllUsers(): Promise { + const db = getAuthDatabase(); + try { + return await db.query( + 'SELECT * FROM users ORDER BY created_at DESC' + ); + } finally { + await db.close(); + } + } + + /** + * Update user + */ + async updateUser(id: number, input: UpdateUserInput): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + const updates: string[] = []; + const values: any[] = []; + + if (input.email !== undefined) { + // Check if email already exists for another user + const existing = await db.queryOne( + 'SELECT id FROM users WHERE email = ? AND id != ?', + [input.email, id] + ); + if (existing) { + throw new Error('Email already exists'); + } + updates.push('email = ?'); + values.push(input.email); + } + + if (input.username !== undefined) { + // Check if username already exists for another user + const existing = await db.queryOne( + 'SELECT id FROM users WHERE username = ? AND id != ?', + [input.username, id] + ); + if (existing) { + throw new Error('Username already exists'); + } + updates.push('username = ?'); + values.push(input.username); + } + + if (input.display_name !== undefined) { + updates.push('display_name = ?'); + values.push(input.display_name); + } + + if (input.is_active !== undefined) { + updates.push('is_active = ?'); + values.push(isPostgres() ? input.is_active : (input.is_active ? 1 : 0)); + } + + if (updates.length === 0) { + const user = await this.getUserById(id); + if (!user) { + throw new Error('User not found'); + } + return user; + } + + updates.push('updated_at = ?'); + values.push(now); + values.push(id); + + await db.execute( + `UPDATE users SET ${updates.join(', ')} WHERE id = ?`, + values + ); + + const user = await this.getUserById(id); + if (!user) { + throw new Error('User not found'); + } + + logger.info(`User updated: ${user.email}`); + return user; + } finally { + await db.close(); + } + } + + /** + * Delete user + */ + async deleteUser(id: number): Promise { + const db = getAuthDatabase(); + try { + const result = await db.execute( + 'DELETE FROM users WHERE id = ?', + [id] + ); + logger.info(`User deleted: ${id}`); + return result > 0; + } finally { + await db.close(); + } + } + + /** + * Update user password + */ + async updatePassword(id: number, newPassword: string): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + const passwordHash = await this.hashPassword(newPassword); + await db.execute( + 'UPDATE users SET password_hash = ?, password_reset_token = NULL, password_reset_expires = NULL, updated_at = ? WHERE id = ?', + [passwordHash, now, id] + ); + logger.info(`Password updated for user: ${id}`); + } finally { + await db.close(); + } + } + + /** + * Generate and store password reset token + */ + async generatePasswordResetToken(email: string): Promise { + const db = getAuthDatabase(); + const user = await this.getUserByEmail(email); + + if (!user) { + // Don't reveal if user exists + return null; + } + + try { + const token = this.generateToken(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour + + await db.execute( + 'UPDATE users SET password_reset_token = ?, password_reset_expires = ? WHERE id = ?', + [token, expiresAt, user.id] + ); + + // Store in email_tokens table as well + await db.execute( + `INSERT INTO email_tokens (user_id, token, type, expires_at, used, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [user.id, token, 'password_reset', expiresAt, isPostgres() ? false : 0, new Date().toISOString()] + ); + + // Send password reset email + await emailService.sendPasswordResetEmail(user.email, token, user.display_name || undefined); + + return token; + } finally { + await db.close(); + } + } + + /** + * Reset password using token + */ + async resetPasswordWithToken(token: string, newPassword: string): Promise { + const db = getAuthDatabase(); + + try { + // Check token in email_tokens table + const tokenRecord = await db.queryOne<{ user_id: number; expires_at: string; used: boolean }>( + `SELECT user_id, expires_at, used FROM email_tokens + WHERE token = ? AND type = 'password_reset' AND used = ?`, + [token, isPostgres() ? false : 0] + ); + + if (!tokenRecord) { + return false; + } + + // Check if expired + if (new Date(tokenRecord.expires_at) < new Date()) { + return false; + } + + // Update password + await this.updatePassword(tokenRecord.user_id, newPassword); + + // Mark token as used + await db.execute( + 'UPDATE email_tokens SET used = ? WHERE token = ?', + [isPostgres() ? true : 1, token] + ); + + logger.info(`Password reset completed for user: ${tokenRecord.user_id}`); + return true; + } finally { + await db.close(); + } + } + + /** + * Verify email with token + */ + async verifyEmail(token: string): Promise { + const db = getAuthDatabase(); + + try { + const user = await db.queryOne( + 'SELECT * FROM users WHERE email_verification_token = ?', + [token] + ); + + if (!user) { + return false; + } + + const now = new Date().toISOString(); + await db.execute( + 'UPDATE users SET email_verified = ?, email_verification_token = NULL, updated_at = ? WHERE id = ?', + [isPostgres() ? true : 1, now, user.id] + ); + + logger.info(`Email verified for user: ${user.email}`); + return true; + } finally { + await db.close(); + } + } + + /** + * Manually verify email address (admin action) + */ + async manuallyVerifyEmail(id: number): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + await db.execute( + 'UPDATE users SET email_verified = ?, email_verification_token = NULL, updated_at = ? WHERE id = ?', + [isPostgres() ? true : 1, now, id] + ); + logger.info(`Email manually verified for user: ${id}`); + } finally { + db.close(); + } + } + + /** + * Send invitation email + */ + async sendInvitation(userId: number): Promise { + const db = getAuthDatabase(); + + try { + const user = await this.getUserById(userId); + if (!user) { + return false; + } + + const token = this.generateToken(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days + + // Store invitation token + await db.execute( + `INSERT INTO email_tokens (user_id, token, type, expires_at, used, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [userId, token, 'invitation', expiresAt, isPostgres() ? false : 0, new Date().toISOString()] + ); + + // Send invitation email + return await emailService.sendInvitationEmail( + user.email, + token, + user.display_name || undefined + ); + } finally { + await db.close(); + } + } + + /** + * Validate invitation token + */ + async validateInvitationToken(token: string): Promise { + const db = getAuthDatabase(); + + try { + const tokenRecord = await db.queryOne<{ user_id: number; expires_at: string; used: boolean }>( + `SELECT user_id, expires_at, used FROM email_tokens + WHERE token = ? AND type = 'invitation' AND used = ?`, + [token, isPostgres() ? false : 0] + ); + + if (!tokenRecord) { + return null; + } + + // Check if expired + if (new Date(tokenRecord.expires_at) < new Date()) { + return null; + } + + return await this.getUserById(tokenRecord.user_id); + } finally { + await db.close(); + } + } + + /** + * Accept invitation and set password + */ + async acceptInvitation(token: string, password: string): Promise { + const db = getAuthDatabase(); + + try { + const user = await this.validateInvitationToken(token); + if (!user) { + return null; + } + + // Update password + await this.updatePassword(user.id, password); + + // Mark token as used + await db.execute( + 'UPDATE email_tokens SET used = ? WHERE token = ?', + [isPostgres() ? true : 1, token] + ); + + // Activate user and verify email + const now = new Date().toISOString(); + await db.execute( + 'UPDATE users SET is_active = ?, email_verified = ?, updated_at = ? WHERE id = ?', + [isPostgres() ? true : 1, isPostgres() ? true : 1, now, user.id] + ); + + return await this.getUserById(user.id); + } finally { + await db.close(); + } + } + + /** + * Update last login timestamp + */ + async updateLastLogin(id: number): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + await db.execute( + 'UPDATE users SET last_login = ? WHERE id = ?', + [now, id] + ); + } finally { + await db.close(); + } + } + + /** + * Get user roles + */ + async getUserRoles(userId: number): Promise> { + const db = getAuthDatabase(); + try { + return await db.query<{ id: number; name: string; description: string | null }>( + `SELECT r.id, r.name, r.description + FROM roles r + INNER JOIN user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = ?`, + [userId] + ); + } finally { + await db.close(); + } + } + + /** + * Assign role to user + */ + async assignRole(userId: number, roleId: number): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + await db.execute( + `INSERT INTO user_roles (user_id, role_id, assigned_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id, role_id) DO NOTHING`, + [userId, roleId, now] + ); + return true; + } catch (error: any) { + // Handle SQLite (no ON CONFLICT support) + if (error.message?.includes('UNIQUE constraint')) { + return false; // Already assigned + } + throw error; + } finally { + await db.close(); + } + } + + /** + * Remove role from user + */ + async removeRole(userId: number, roleId: number): Promise { + const db = getAuthDatabase(); + try { + const result = await db.execute( + 'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?', + [userId, roleId] + ); + return result > 0; + } finally { + await db.close(); + } + } +} + +export const userService = new UserService(); diff --git a/backend/src/services/userSettingsService.ts b/backend/src/services/userSettingsService.ts new file mode 100644 index 0000000..baf57bf --- /dev/null +++ b/backend/src/services/userSettingsService.ts @@ -0,0 +1,298 @@ +/** + * User Settings Service + * + * Manages user-specific settings including Jira PAT, AI features, and API keys. + */ + +import { logger } from './logger.js'; +import { getAuthDatabase } from './database/migrations.js'; +import { encryptionService } from './encryptionService.js'; +import { config } from '../config/env.js'; + +const isPostgres = (): boolean => { + return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; +}; + +export interface UserSettings { + user_id: number; + jira_pat: string | null; + jira_pat_encrypted: boolean; + ai_enabled: boolean; + ai_provider: string | null; + ai_api_key: string | null; + web_search_enabled: boolean; + tavily_api_key: string | null; + updated_at: string; +} + +export interface UpdateUserSettingsInput { + jira_pat?: string; + ai_enabled?: boolean; + ai_provider?: 'openai' | 'anthropic'; + ai_api_key?: string; + web_search_enabled?: boolean; + tavily_api_key?: string; +} + +class UserSettingsService { + /** + * Get user settings + */ + async getUserSettings(userId: number): Promise { + const db = getAuthDatabase(); + try { + const settings = await db.queryOne( + 'SELECT * FROM user_settings WHERE user_id = ?', + [userId] + ); + + if (!settings) { + // Create default settings + return await this.createDefaultSettings(userId); + } + + // Decrypt sensitive fields if encrypted + if (settings.jira_pat && settings.jira_pat_encrypted && encryptionService.isConfigured()) { + try { + settings.jira_pat = await encryptionService.decrypt(settings.jira_pat); + } catch (error) { + logger.error('Failed to decrypt Jira PAT:', error); + settings.jira_pat = null; + } + } + + if (settings.ai_api_key && encryptionService.isConfigured()) { + try { + settings.ai_api_key = await encryptionService.decrypt(settings.ai_api_key); + } catch (error) { + logger.error('Failed to decrypt AI API key:', error); + settings.ai_api_key = null; + } + } + + if (settings.tavily_api_key && encryptionService.isConfigured()) { + try { + settings.tavily_api_key = await encryptionService.decrypt(settings.tavily_api_key); + } catch (error) { + logger.error('Failed to decrypt Tavily API key:', error); + settings.tavily_api_key = null; + } + } + + return settings; + } finally { + await db.close(); + } + } + + /** + * Create default settings for user + */ + async createDefaultSettings(userId: number): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + await db.execute( + `INSERT INTO user_settings ( + user_id, jira_pat, jira_pat_encrypted, ai_enabled, ai_provider, + ai_api_key, web_search_enabled, tavily_api_key, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + userId, + null, + isPostgres() ? true : 1, + isPostgres() ? false : 0, + null, + null, + isPostgres() ? false : 0, + null, + now, + ] + ); + + return await this.getUserSettings(userId) as UserSettings; + } finally { + await db.close(); + } + } + + /** + * Update user settings + */ + async updateUserSettings(userId: number, input: UpdateUserSettingsInput): Promise { + const db = getAuthDatabase(); + const now = new Date().toISOString(); + + try { + // Ensure settings exist + let settings = await this.getUserSettings(userId); + if (!settings) { + settings = await this.createDefaultSettings(userId); + } + + const updates: string[] = []; + const values: any[] = []; + + if (input.jira_pat !== undefined) { + let encryptedPat: string | null = null; + if (input.jira_pat) { + if (encryptionService.isConfigured()) { + encryptedPat = await encryptionService.encrypt(input.jira_pat); + } else { + // Store unencrypted if encryption not configured (development) + encryptedPat = input.jira_pat; + } + } + updates.push('jira_pat = ?'); + updates.push('jira_pat_encrypted = ?'); + values.push(encryptedPat); + values.push(encryptionService.isConfigured() ? (isPostgres() ? true : 1) : (isPostgres() ? false : 0)); + } + + if (input.ai_enabled !== undefined) { + updates.push('ai_enabled = ?'); + values.push(isPostgres() ? input.ai_enabled : (input.ai_enabled ? 1 : 0)); + } + + if (input.ai_provider !== undefined) { + updates.push('ai_provider = ?'); + values.push(input.ai_provider); + } + + if (input.ai_api_key !== undefined) { + let encryptedKey: string | null = null; + if (input.ai_api_key) { + if (encryptionService.isConfigured()) { + encryptedKey = await encryptionService.encrypt(input.ai_api_key); + } else { + encryptedKey = input.ai_api_key; + } + } + updates.push('ai_api_key = ?'); + values.push(encryptedKey); + } + + if (input.web_search_enabled !== undefined) { + updates.push('web_search_enabled = ?'); + values.push(isPostgres() ? input.web_search_enabled : (input.web_search_enabled ? 1 : 0)); + } + + if (input.tavily_api_key !== undefined) { + let encryptedKey: string | null = null; + if (input.tavily_api_key) { + if (encryptionService.isConfigured()) { + encryptedKey = await encryptionService.encrypt(input.tavily_api_key); + } else { + encryptedKey = input.tavily_api_key; + } + } + updates.push('tavily_api_key = ?'); + values.push(encryptedKey); + } + + if (updates.length === 0) { + return settings; + } + + updates.push('updated_at = ?'); + values.push(now); + values.push(userId); + + await db.execute( + `UPDATE user_settings SET ${updates.join(', ')} WHERE user_id = ?`, + values + ); + + logger.info(`User settings updated for user: ${userId}`); + return await this.getUserSettings(userId) as UserSettings; + } finally { + await db.close(); + } + } + + /** + * Validate Jira PAT by testing connection + */ + async validateJiraPat(userId: number, pat?: string): Promise { + try { + const settings = await this.getUserSettings(userId); + const tokenToTest = pat || settings?.jira_pat; + + if (!tokenToTest) { + return false; + } + + // Test connection to Jira + const testUrl = `${config.jiraHost}/rest/api/2/myself`; + const response = await fetch(testUrl, { + headers: { + 'Authorization': `Bearer ${tokenToTest}`, + 'Accept': 'application/json', + }, + }); + + return response.ok; + } catch (error) { + logger.error('Jira PAT validation failed:', error); + return false; + } + } + + /** + * Get Jira PAT status + */ + async getJiraPatStatus(userId: number): Promise<{ configured: boolean; valid: boolean }> { + const settings = await this.getUserSettings(userId); + const configured = !!settings?.jira_pat; + + if (!configured) { + return { configured: false, valid: false }; + } + + const valid = await this.validateJiraPat(userId); + return { configured: true, valid }; + } + + /** + * Check if AI features are enabled for user + */ + async isAiEnabled(userId: number): Promise { + const settings = await this.getUserSettings(userId); + return settings?.ai_enabled || false; + } + + /** + * Get AI provider for user + */ + async getAiProvider(userId: number): Promise<'openai' | 'anthropic' | null> { + const settings = await this.getUserSettings(userId); + return (settings?.ai_provider as 'openai' | 'anthropic') || null; + } + + /** + * Get AI API key for user + */ + async getAiApiKey(userId: number): Promise { + const settings = await this.getUserSettings(userId); + return settings?.ai_api_key || null; + } + + /** + * Check if web search is enabled for user + */ + async isWebSearchEnabled(userId: number): Promise { + const settings = await this.getUserSettings(userId); + return settings?.web_search_enabled || false; + } + + /** + * Get Tavily API key for user + */ + async getTavilyApiKey(userId: number): Promise { + const settings = await this.getUserSettings(userId); + return settings?.tavily_api_key || null; + } +} + +export const userSettingsService = new UserSettingsService(); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index c7293b1..59c2e65 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -89,6 +89,11 @@ export interface ApplicationDetails { applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572) dataCompletenessPercentage?: number; // Data completeness percentage (0-100) + reference?: string | null; // Reference field (Enterprise Architect GUID) + confluenceSpace?: string | null; // Confluence Space URL + supplierTechnical?: ReferenceValue | null; // Supplier Technical + supplierImplementation?: ReferenceValue | null; // Supplier Implementation + supplierConsultancy?: ReferenceValue | null; // Supplier Consultancy } // Search filters diff --git a/docs/AUTHENTICATION-ENV-VARS.md b/docs/AUTHENTICATION-ENV-VARS.md new file mode 100644 index 0000000..bdbd44d --- /dev/null +++ b/docs/AUTHENTICATION-ENV-VARS.md @@ -0,0 +1,141 @@ +# Authentication System Environment Variables + +This document describes the new environment variables required for the authentication and authorization system. + +## Application Branding + +```env +# Application name displayed throughout the UI +APP_NAME=CMDB Insight + +# Application tagline/subtitle displayed in header and login pages +APP_TAGLINE=Management console for Jira Assets + +# Copyright text displayed in the footer (use {year} as placeholder for current year) +APP_COPYRIGHT=© {year} Zuyderland Medisch Centrum +``` + +**Note:** The `{year}` placeholder in `APP_COPYRIGHT` will be automatically replaced with the current year. If not set, defaults to `© {current_year} Zuyderland Medisch Centrum`. + +## Email Configuration (Nodemailer) + +```env +# SMTP Configuration +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your-password +SMTP_FROM=noreply@example.com +``` + +## Encryption + +```env +# Encryption Key (32 bytes, base64 encoded) +# Generate with: openssl rand -base64 32 +ENCRYPTION_KEY=your-32-byte-encryption-key-base64 +``` + +## Local Authentication + +```env +# Enable local authentication (email/password) +LOCAL_AUTH_ENABLED=true + +# Allow public registration (optional, default: false) +REGISTRATION_ENABLED=false +``` + +## Password Requirements + +```env +# Password minimum length +PASSWORD_MIN_LENGTH=8 + +# Password complexity requirements +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_NUMBER=true +PASSWORD_REQUIRE_SPECIAL=false +``` + +## Session Configuration + +```env +# Session duration in hours +SESSION_DURATION_HOURS=24 +``` + +## Initial Admin User + +```env +# Create initial administrator user (optional) +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=SecurePassword123! +ADMIN_USERNAME=admin +ADMIN_DISPLAY_NAME=Administrator +``` + +## Complete Example + +```env +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM=noreply@example.com + +# Encryption +ENCRYPTION_KEY=$(openssl rand -base64 32) + +# Local Auth +LOCAL_AUTH_ENABLED=true +REGISTRATION_ENABLED=false + +# Password Requirements +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_NUMBER=true +PASSWORD_REQUIRE_SPECIAL=false + +# Session +SESSION_DURATION_HOURS=24 + +# Initial Admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=ChangeMe123! +ADMIN_USERNAME=admin +ADMIN_DISPLAY_NAME=Administrator +``` + +## Important Notes + +### User-Specific Configuration (REMOVED from ENV) + +The following environment variables have been **REMOVED** from the codebase and are **NOT** configurable via environment variables: + +- `JIRA_PAT`: **Configure in User Settings > Jira PAT** +- `ANTHROPIC_API_KEY`: **Configure in User Settings > AI Settings** +- `OPENAI_API_KEY`: **Configure in User Settings > AI Settings** +- `TAVILY_API_KEY`: **Configure in User Settings > AI Settings** + +**These are now user-specific settings only.** Each user must configure their own API keys in their profile settings. This provides: +- Better security (keys not in shared config files) +- Per-user API key management +- Individual rate limiting per user +- Better audit trails +- Encrypted storage in the database + +### Required Configuration + +- `SESSION_SECRET`: Should be a secure random string in production (generate with `openssl rand -base64 32`) +- `ENCRYPTION_KEY`: Must be exactly 32 bytes when base64 decoded (generate with `openssl rand -base64 32`) +- `JIRA_SCHEMA_ID`: Required for Jira Assets integration + +### Application Branding + +- The `{year}` placeholder in `APP_COPYRIGHT` will be automatically replaced with the current year diff --git a/docs/AUTHENTICATION-IMPLEMENTATION-STATUS.md b/docs/AUTHENTICATION-IMPLEMENTATION-STATUS.md new file mode 100644 index 0000000..c240f7b --- /dev/null +++ b/docs/AUTHENTICATION-IMPLEMENTATION-STATUS.md @@ -0,0 +1,119 @@ +# Authentication System Implementation Status + +## ✅ Completed Features + +### Backend +- ✅ Database schema with users, roles, permissions, sessions, user_settings, email_tokens tables +- ✅ User service (CRUD, password hashing, email verification, password reset) +- ✅ Role service (dynamic role and permission management) +- ✅ Auth service (local auth + OAuth with database-backed sessions) +- ✅ Email service (Nodemailer with SMTP) +- ✅ Encryption service (AES-256-GCM for sensitive data) +- ✅ User settings service (Jira PAT, AI features, API keys) +- ✅ Authorization middleware (requireAuth, requireRole, requirePermission) +- ✅ All API routes protected with authentication +- ✅ Auth routes (login, logout, password reset, email verification, invitations) +- ✅ User management routes (admin only) +- ✅ Role management routes +- ✅ User settings routes +- ✅ Profile routes + +### Frontend +- ✅ Auth store extended with roles, permissions, local auth support +- ✅ Permission hooks (useHasPermission, useHasRole, usePermissions) +- ✅ ProtectedRoute component +- ✅ Login component (local login + OAuth choice) +- ✅ ForgotPassword component +- ✅ ResetPassword component +- ✅ AcceptInvitation component +- ✅ UserManagement component (admin) +- ✅ RoleManagement component (admin) +- ✅ UserSettings component +- ✅ Profile component +- ✅ UserMenu with logout and profile/settings links +- ✅ Feature gating based on permissions + +## 🔧 Configuration Required + +### Environment Variables + +**Required for local authentication:** +```env +LOCAL_AUTH_ENABLED=true +``` + +**Required for email functionality:** +```env +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your-password +SMTP_FROM=noreply@example.com +``` + +**Required for encryption:** +```env +ENCRYPTION_KEY=your-32-byte-encryption-key-base64 +``` + +**Optional - Initial admin user:** +```env +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=SecurePassword123! +ADMIN_USERNAME=admin +ADMIN_DISPLAY_NAME=Administrator +``` + +**Password requirements:** +```env +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_NUMBER=true +PASSWORD_REQUIRE_SPECIAL=false +``` + +**Session duration:** +```env +SESSION_DURATION_HOURS=24 +``` + +## 📝 Notes + +### JIRA_AUTH Settings +- `JIRA_PAT` can be removed from global env - users configure their own PAT in settings +- `JIRA_OAUTH_CLIENT_ID` and `JIRA_OAUTH_CLIENT_SECRET` are still needed for OAuth flow +- `JIRA_HOST` and `JIRA_SCHEMA_ID` are still needed (infrastructure settings) + +### AI API Keys +- `ANTHROPIC_API_KEY` can be removed from global env - users configure their own keys +- `OPENAI_API_KEY` can be removed from global env - users configure their own keys +- `TAVILY_API_KEY` can be removed from global env - users configure their own keys +- These are now stored per-user in the `user_settings` table (encrypted) + +### Authentication Flow +1. On first run, migrations create database tables +2. If `ADMIN_EMAIL` and `ADMIN_PASSWORD` are set, initial admin user is created +3. Once users exist, authentication is automatically required +4. Users can log in with email/password (local auth) or OAuth (if configured) +5. User menu shows logged-in user with links to Profile and Settings +6. Logout is available for all authenticated users + +## 🚀 Next Steps + +1. Set `LOCAL_AUTH_ENABLED=true` in environment +2. Configure SMTP settings for email functionality +3. Generate encryption key: `openssl rand -base64 32` +4. Set initial admin credentials (optional) +5. Run the application - migrations will run automatically +6. Log in with admin account +7. Create additional users via User Management +8. Configure roles and permissions as needed + +## ⚠️ Important + +- Once users exist in the database, authentication is **automatically required** +- Service account mode only works if no users exist AND local auth is not enabled +- All API routes are protected - unauthenticated requests return 401 +- User-specific settings (Jira PAT, AI keys) are encrypted at rest diff --git a/docs/DATABASE-ACCESS.md b/docs/DATABASE-ACCESS.md new file mode 100644 index 0000000..ccb4416 --- /dev/null +++ b/docs/DATABASE-ACCESS.md @@ -0,0 +1,142 @@ +# Database Access Guide + +This guide shows you how to easily access and view records in the PostgreSQL database. + +## Quick Access + +### Option 1: Using the Script (Easiest) + +```bash +# Connect using psql +./scripts/open-database.sh psql + +# Or via Docker +./scripts/open-database.sh docker + +# Or get connection string for GUI tools +./scripts/open-database.sh url +``` + +### Option 2: Direct psql Command + +```bash +# If PostgreSQL is running locally +PGPASSWORD=cmdb-dev psql -h localhost -p 5432 -U cmdb -d cmdb +``` + +### Option 3: Via Docker + +```bash +# Connect to PostgreSQL container +docker exec -it $(docker ps | grep postgres | awk '{print $1}') psql -U cmdb -d cmdb +``` + +## Connection Details + +From `docker-compose.yml`: +- **Host**: localhost (or `postgres` if connecting from Docker network) +- **Port**: 5432 +- **Database**: cmdb +- **User**: cmdb +- **Password**: cmdb-dev + +**Connection String:** +``` +postgresql://cmdb:cmdb-dev@localhost:5432/cmdb +``` + +## GUI Tools + +### pgAdmin (Free, Web-based) +1. Download from: https://www.pgadmin.org/download/ +2. Add new server with connection details above +3. Browse tables and run queries + +### DBeaver (Free, Cross-platform) +1. Download from: https://dbeaver.io/download/ +2. Create new PostgreSQL connection +3. Use connection string or individual fields + +### TablePlus (macOS, Paid but has free tier) +1. Download from: https://tableplus.com/ +2. Create new PostgreSQL connection +3. Enter connection details + +### DataGrip (JetBrains, Paid) +1. Part of JetBrains IDEs or standalone +2. Create new PostgreSQL data source +3. Use connection string + +## Useful SQL Commands + +Once connected, try these commands: + +```sql +-- List all tables +\dt + +-- Describe a table structure +\d users +\d classifications +\d cache_objects + +-- View all users +SELECT * FROM users; + +-- View classifications +SELECT * FROM classifications ORDER BY created_at DESC LIMIT 10; + +-- View cached objects +SELECT object_key, object_type, updated_at FROM cache_objects ORDER BY updated_at DESC LIMIT 20; + +-- Count records per table +SELECT + 'users' as table_name, COUNT(*) as count FROM users +UNION ALL +SELECT + 'classifications', COUNT(*) FROM classifications +UNION ALL +SELECT + 'cache_objects', COUNT(*) FROM cache_objects; + +-- View user settings +SELECT u.username, u.email, us.ai_provider, us.ai_enabled +FROM users u +LEFT JOIN user_settings us ON u.id = us.user_id; +``` + +## Environment Variables + +If you're using environment variables instead of Docker: + +```bash +# Check your .env file for: +DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb +# or +DATABASE_TYPE=postgres +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=cmdb +DATABASE_USER=cmdb +DATABASE_PASSWORD=cmdb-dev +``` + +## Troubleshooting + +### Database not running +```bash +# Start PostgreSQL container +docker-compose up -d postgres + +# Check if it's running +docker ps | grep postgres +``` + +### Connection refused +- Make sure PostgreSQL container is running +- Check if port 5432 is already in use +- Verify connection details match docker-compose.yml + +### Permission denied +- Verify username and password match docker-compose.yml +- Check if user has access to the database diff --git a/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md b/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..e5012a5 --- /dev/null +++ b/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md @@ -0,0 +1,1061 @@ +# Normalized Database Implementation Plan + +## Executive Summary + +**Status:** Green Field Implementation +**Approach:** Complete rebuild with normalized structure +**Timeline:** 1-2 weeks +**Risk:** Low (no production data to migrate) + +This document outlines the complete implementation plan for migrating from JSONB-based storage to a fully normalized, generic database structure that works with any Jira Assets schema. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Database Schema](#database-schema) +3. [Implementation Components](#implementation-components) +4. [Implementation Steps](#implementation-steps) +5. [Code Structure](#code-structure) +6. [Testing Strategy](#testing-strategy) +7. [Migration Path](#migration-path) +8. [Example Implementations](#example-implementations) + +--- + +## Architecture Overview + +### Current Architecture (JSONB) + +``` +Jira Assets API + ↓ +jiraAssetsClient.parseObject() + ↓ +CMDBObject (TypeScript) + ↓ +cacheStore.upsertObject() + ↓ +cached_objects.data (JSONB column) + ↓ +Queries: Load all → Filter in JavaScript ❌ +``` + +**Problems:** +- All objects loaded into memory +- No database-level indexing on attributes +- Slow queries with complex filters +- Memory overhead for large datasets + +### Target Architecture (Normalized) + +``` +Jira Assets API + ↓ +jiraAssetsClient.parseObject() + ↓ +CMDBObject (TypeScript) + ↓ +normalizedCacheStore.upsertObject() + ↓ +Normalize to: objects + attribute_values + ↓ +Queries: SQL JOINs with indexes ✅ +``` + +**Benefits:** +- Database-level filtering +- Indexed attributes +- Efficient queries +- Scalable to large datasets + +### Key Benefits + +1. **Generic**: Works with any Jira Assets schema (discovered dynamically) +2. **Efficient**: Database-level filtering with indexes +3. **Scalable**: Handles large datasets efficiently +4. **Type-safe**: Proper data types per attribute +5. **Queryable**: Complex filters at database level + +--- + +## Database Schema + +### Tables + +#### 1. `object_types` +Stores discovered object types from Jira schema. + +```sql +CREATE TABLE object_types ( + jira_type_id INTEGER PRIMARY KEY, + type_name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + sync_priority INTEGER DEFAULT 0, + object_count INTEGER DEFAULT 0, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +**Purpose:** Metadata about object types discovered from Jira schema. + +#### 2. `attributes` +Stores discovered attributes per object type. + +```sql +CREATE TABLE attributes ( + id SERIAL PRIMARY KEY, + jira_attr_id INTEGER NOT NULL, + object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE, + attr_name TEXT NOT NULL, -- "Application Function" + field_name TEXT NOT NULL, -- "applicationFunction" (camelCase) + attr_type TEXT NOT NULL, -- 'text', 'reference', 'integer', etc. + is_multiple BOOLEAN NOT NULL DEFAULT FALSE, + is_editable BOOLEAN NOT NULL DEFAULT TRUE, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + reference_type_name TEXT, -- For reference attributes (e.g., "ApplicationFunction") + description TEXT, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(jira_attr_id, object_type_name) +); +``` + +**Purpose:** Metadata about attributes discovered from Jira schema. Used to build queries dynamically. + +#### 3. `objects` +Stores minimal object metadata. + +```sql +CREATE TABLE objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE, + label TEXT NOT NULL, + jira_updated_at TIMESTAMP, + jira_created_at TIMESTAMP, + cached_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +**Purpose:** Core object information. All attribute values stored separately in `attribute_values`. + +#### 4. `attribute_values` +EAV pattern for storing all attribute values. + +```sql +CREATE TABLE attribute_values ( + id SERIAL PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, + -- Value storage (one column populated based on type): + text_value TEXT, -- For text, textarea, url, email, select, user, status + number_value NUMERIC, -- For integer, float + boolean_value BOOLEAN, -- For boolean + date_value DATE, -- For date + datetime_value TIMESTAMP, -- For datetime + reference_object_id TEXT, -- For reference attributes (stores objectId) + -- For arrays: multiple rows with different array_index + array_index INTEGER DEFAULT 0, -- 0 = single value, >0 = array element + UNIQUE(object_id, attribute_id, array_index) +); +``` + +**Purpose:** Stores all attribute values in normalized form. One row per value (multiple rows for arrays). + +#### 5. `object_relations` +Enhanced existing table with attribute_id reference. + +```sql +CREATE TABLE object_relations ( + id SERIAL PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_id) +); +``` + +**Purpose:** Stores relationships between objects. Enhanced with `attribute_id` for better queries. + +#### 6. `sync_metadata` +Unchanged. + +```sql +CREATE TABLE sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +### Indexes + +**Critical for query performance:** + +```sql +-- Objects +CREATE INDEX idx_objects_type ON objects(object_type_name); +CREATE INDEX idx_objects_key ON objects(object_key); +CREATE INDEX idx_objects_label ON objects(label); + +-- Attributes (for schema lookups) +CREATE INDEX idx_attributes_type ON attributes(object_type_name); +CREATE INDEX idx_attributes_field ON attributes(field_name); +CREATE INDEX idx_attributes_type_field ON attributes(object_type_name, field_name); + +-- Attribute Values (critical for query performance) +CREATE INDEX idx_attr_values_object ON attribute_values(object_id); +CREATE INDEX idx_attr_values_attr ON attribute_values(attribute_id); +CREATE INDEX idx_attr_values_text ON attribute_values(text_value) WHERE text_value IS NOT NULL; +CREATE INDEX idx_attr_values_number ON attribute_values(number_value) WHERE number_value IS NOT NULL; +CREATE INDEX idx_attr_values_reference ON attribute_values(reference_object_id) WHERE reference_object_id IS NOT NULL; +CREATE INDEX idx_attr_values_composite_text ON attribute_values(attribute_id, text_value) WHERE text_value IS NOT NULL; +CREATE INDEX idx_attr_values_composite_ref ON attribute_values(attribute_id, reference_object_id) WHERE reference_object_id IS NOT NULL; +CREATE INDEX idx_attr_values_object_attr ON attribute_values(object_id, attribute_id); + +-- Relations +CREATE INDEX idx_relations_source ON object_relations(source_id); +CREATE INDEX idx_relations_target ON object_relations(target_id); +CREATE INDEX idx_relations_attr ON object_relations(attribute_id); +``` + +--- + +## Implementation Components + +### 1. Schema Discovery Service + +**File:** `backend/src/services/schemaDiscoveryService.ts` + +**Purpose:** Populate `object_types` and `attributes` tables from generated schema. + +**Methods:** + +```typescript +class SchemaDiscoveryService { + /** + * Discover schema from OBJECT_TYPES and populate database + */ + async discoverAndStoreSchema(): Promise; + + /** + * Get object type definition from database + */ + async getObjectType(typeName: string): Promise; + + /** + * Get attribute definition by type and field name + */ + async getAttribute(typeName: string, fieldName: string): Promise; + + /** + * Get all attributes for an object type + */ + async getAttributesForType(typeName: string): Promise; +} +``` + +**Implementation Notes:** +- Reads from `OBJECT_TYPES` in `generated/jira-schema.ts` +- Populates `object_types` and `attributes` tables +- Called once at startup or when schema changes +- Idempotent (can be called multiple times) + +### 2. Normalized Cache Store + +**File:** `backend/src/services/normalizedCacheStore.ts` + +**Purpose:** Replace `cacheStore.ts` with normalized implementation. + +**Key Methods:** + +#### Object Operations +```typescript +class NormalizedCacheStore { + /** + * Upsert object: normalize and store + */ + async upsertObject( + typeName: CMDBObjectTypeName, + object: T + ): Promise; + + /** + * Get object: reconstruct from normalized data + */ + async getObject( + typeName: CMDBObjectTypeName, + id: string + ): Promise; + + /** + * Get objects with optional filters + */ + async getObjects( + typeName: CMDBObjectTypeName, + options?: QueryOptions + ): Promise; + + /** + * Delete object and all its attribute values + */ + async deleteObject( + typeName: CMDBObjectTypeName, + id: string + ): Promise; +} +``` + +#### Normalization Logic +```typescript + /** + * Convert CMDBObject to normalized form + */ + private async normalizeObject( + object: CMDBObject, + typeName: CMDBObjectTypeName + ): Promise; + + /** + * Store attribute value based on type + */ + private async storeAttributeValue( + objectId: string, + attributeId: number, + value: unknown, + attrDef: AttributeDefinition, + arrayIndex?: number + ): Promise; +``` + +#### Reconstruction Logic +```typescript + /** + * Reconstruct CMDBObject from normalized data + */ + private async reconstructObject( + objectId: string, + typeName: CMDBObjectTypeName + ): Promise; + + /** + * Load all attribute values for an object + */ + private async loadAttributeValues( + objectId: string, + typeName: CMDBObjectTypeName + ): Promise>; +``` + +#### Query Operations +```typescript + /** + * Query objects with filters (generic) + */ + async queryWithFilters( + typeName: CMDBObjectTypeName, + filters: Record, + options?: QueryOptions + ): Promise<{ objects: T[]; total: number }>; +``` + +### 3. Generic Query Builder + +**File:** `backend/src/services/queryBuilder.ts` + +**Purpose:** Build SQL queries dynamically based on filters. + +**Methods:** + +```typescript +class QueryBuilder { + /** + * Build WHERE clause from filters + */ + buildWhereClause( + filters: Record, + typeName: CMDBObjectTypeName + ): { whereClause: string; params: unknown[] }; + + /** + * Build filter condition for one field + */ + buildFilterCondition( + fieldName: string, + filterValue: unknown, + attrDef: AttributeDefinition + ): { condition: string; params: unknown[] }; + + /** + * Build ORDER BY clause + */ + buildOrderBy(orderBy?: string, orderDir?: 'ASC' | 'DESC'): string; + + /** + * Build pagination clause + */ + buildPagination(limit?: number, offset?: number): string; +} +``` + +**Filter Types Supported:** + +1. **Exact match:** + ```typescript + { status: "Active" } + ``` + +2. **Contains (text):** + ```typescript + { name: { contains: "search" } } + ``` + +3. **Reference match:** + ```typescript + { governanceModel: { objectId: "123" } } + { governanceModel: { objectKey: "GOV-A" } } + { governanceModel: { label: "Model A" } } + ``` + +4. **Array contains:** + ```typescript + { applicationFunction: [ + { objectId: "1" }, + { objectId: "2" } + ]} + ``` + +5. **Exists:** + ```typescript + { applicationFunction: { exists: true } } + ``` + +6. **Empty:** + ```typescript + { applicationFunction: { empty: true } } + ``` + +### 4. Updated Services + +#### syncEngine.ts +- Update `syncObjectType()` to use `normalizedCacheStore` +- Keep same interface, different implementation +- No changes to sync logic, only storage layer + +#### dataService.ts +- Update `searchApplications()` to use `queryWithFilters()` +- Remove JavaScript filtering logic +- Use SQL queries instead +- Much faster for complex filters + +#### cmdbService.ts +- Update to use `normalizedCacheStore` instead of `cacheStore` +- Keep same interface +- No changes to business logic + +--- + +## Implementation Steps + +### Step 1: Create Database Schema + +**Files:** +- `backend/src/services/database/normalized-schema.ts` - Schema definitions +- Update `backend/src/services/database/migrations.ts` - Add migration + +**Tasks:** +1. Create schema SQL for PostgreSQL +2. Create schema SQL for SQLite (development) +3. Create migration function +4. Test schema creation + +**Deliverables:** +- Schema SQL files +- Migration function +- Tests pass + +### Step 2: Schema Discovery Service + +**File:** `backend/src/services/schemaDiscoveryService.ts` + +**Tasks:** +1. Implement `discoverAndStoreSchema()` - Read from `OBJECT_TYPES` +2. Populate `object_types` table +3. Populate `attributes` table +4. Add validation and error handling +5. Add caching (don't rediscover if already done) + +**Deliverables:** +- Schema discovery service +- Tests pass +- Schema populated in database + +### Step 3: Normalization Logic + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial) + +**Tasks:** +1. Implement `normalizeObject()` - Convert CMDBObject to normalized form +2. Handle all attribute types: + - Text (text, textarea, url, email, select, user, status) + - Numbers (integer, float) + - Boolean + - Dates (date, datetime) + - References (single and multiple) +3. Implement `storeAttributeValue()` - Store values in correct columns +4. Handle arrays (multiple rows with array_index) +5. Test normalization with sample objects + +**Deliverables:** +- Normalization logic +- Tests for all attribute types +- Sample data normalized correctly + +### Step 4: Reconstruction Logic + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial) + +**Tasks:** +1. Implement `reconstructObject()` - Build CMDBObject from normalized data +2. Load all attribute values for object +3. Convert back to TypeScript types +4. Handle arrays (multiple rows) +5. Handle references (load referenced objects if needed) +6. Test reconstruction correctness + +**Deliverables:** +- Reconstruction logic +- Tests for all attribute types +- Reconstructed objects match original + +### Step 5: Basic CRUD Operations + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial) + +**Tasks:** +1. Implement `upsertObject()` - Normalize and store +2. Implement `getObject()` - Reconstruct and return +3. Implement `getObjects()` - Basic query (no filters yet) +4. Implement `deleteObject()` - Delete object and values +5. Implement `countObjects()` - Count by type +6. Test CRUD operations + +**Deliverables:** +- CRUD operations working +- Tests pass +- Data integrity verified + +### Step 6: Generic Query Builder + +**File:** `backend/src/services/queryBuilder.ts` + +**Tasks:** +1. Implement filter condition builder +2. Support all filter types: + - Exact match + - Contains + - Reference match + - Array contains + - Exists + - Empty +3. Build WHERE clauses dynamically +4. Handle JOINs for attribute values +5. Test query generation + +**Deliverables:** +- Query builder +- Tests for all filter types +- SQL generation correct + +### Step 7: Query Operations + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial) + +**Tasks:** +1. Implement `queryWithFilters()` - Use query builder +2. Implement `countWithFilters()` - Count with same filters +3. Add pagination support +4. Add sorting support +5. Test complex queries + +**Deliverables:** +- Query operations working +- Performance tests pass +- Complex filters work correctly + +### Step 8: Relations + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial) + +**Tasks:** +1. Update `extractAndStoreRelations()` - Use attribute_id +2. Update `getRelatedObjects()` - Use normalized queries +3. Update `getReferencingObjects()` - Use normalized queries +4. Test relations + +**Deliverables:** +- Relations working +- Tests pass +- Performance acceptable + +### Step 9: Update Services + +**Files:** +- `backend/src/services/syncEngine.ts` +- `backend/src/services/cmdbService.ts` +- `backend/src/services/dataService.ts` + +**Tasks:** +1. Replace `cacheStore` imports with `normalizedCacheStore` +2. Update `dataService.searchApplications()` - Use `queryWithFilters()` +3. Remove JavaScript filtering logic +4. Update all service calls +5. Test all endpoints + +**Deliverables:** +- All services updated +- All endpoints working +- Tests pass + +### Step 10: Statistics & Utilities + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial) + +**Tasks:** +1. Implement `getStats()` - Count from normalized tables +2. Implement `isWarm()` - Check if cache has data +3. Implement `clearObjectType()` - Clear type and values +4. Implement `clearAll()` - Clear all data +5. Test statistics + +**Deliverables:** +- Statistics working +- Tests pass +- Performance acceptable + +### Step 11: Remove Old Code + +**Files:** +- `backend/src/services/cacheStore.ts` - Delete or archive + +**Tasks:** +1. Remove old `cacheStore.ts` +2. Update all imports +3. Clean up unused code +4. Update documentation + +**Deliverables:** +- Old code removed +- All imports updated +- Documentation updated + +--- + +## Code Structure + +### File Organization + +``` +backend/src/services/ +├── database/ +│ ├── normalized-schema.ts # NEW: Schema definitions +│ ├── migrations.ts # UPDATED: Add normalized schema migration +│ ├── factory.ts # (unchanged) +│ ├── interface.ts # (unchanged) +│ ├── postgresAdapter.ts # (unchanged) +│ └── sqliteAdapter.ts # (unchanged) +├── schemaDiscoveryService.ts # NEW: Schema discovery +├── normalizedCacheStore.ts # NEW: Normalized storage +├── queryBuilder.ts # NEW: Generic query builder +├── syncEngine.ts # UPDATED: Use normalized store +├── cmdbService.ts # UPDATED: Use normalized store +├── dataService.ts # UPDATED: Use queries +└── cacheStore.ts # REMOVE: Old implementation +``` + +### Key Interfaces + +```typescript +// NormalizedCacheStore interface (same as old CacheStore) +interface NormalizedCacheStore { + // Object operations + upsertObject(typeName: CMDBObjectTypeName, object: T): Promise; + getObject(typeName: CMDBObjectTypeName, id: string): Promise; + getObjectByKey(typeName: CMDBObjectTypeName, objectKey: string): Promise; + getObjects(typeName: CMDBObjectTypeName, options?: QueryOptions): Promise; + countObjects(typeName: CMDBObjectTypeName): Promise; + deleteObject(typeName: CMDBObjectTypeName, id: string): Promise; + + // Query operations + queryWithFilters( + typeName: CMDBObjectTypeName, + filters: Record, + options?: QueryOptions + ): Promise<{ objects: T[]; total: number }>; + + // Relations + extractAndStoreRelations(typeName: CMDBObjectTypeName, object: T): Promise; + getRelatedObjects( + sourceId: string, + targetTypeName: CMDBObjectTypeName, + attributeName?: string + ): Promise; + getReferencingObjects( + targetId: string, + sourceTypeName: CMDBObjectTypeName, + attributeName?: string + ): Promise; + + // Statistics + getStats(): Promise; + isWarm(): Promise; + clearObjectType(typeName: CMDBObjectTypeName): Promise; + clearAll(): Promise; + + // Sync metadata + getSyncMetadata(key: string): Promise; + setSyncMetadata(key: string, value: string): Promise; +} + +// QueryBuilder interface +interface QueryBuilder { + buildWhereClause( + filters: Record, + typeName: CMDBObjectTypeName + ): { whereClause: string; params: unknown[] }; + + buildFilterCondition( + fieldName: string, + filterValue: unknown, + attrDef: AttributeDefinition + ): { condition: string; params: unknown[] }; +} +``` + +--- + +## Testing Strategy + +### Unit Tests + +1. **Schema Discovery** + - Test schema population + - Test attribute lookup + - Test object type lookup + - Test idempotency + +2. **Normalization** + - Test all attribute types (text, number, boolean, date, reference) + - Test arrays (multiple values) + - Test null/empty values + - Test edge cases + +3. **Reconstruction** + - Test object reconstruction + - Test all attribute types + - Test arrays + - Test references + - Test missing values + +4. **Query Builder** + - Test all filter types + - Test complex filters + - Test SQL generation + - Test parameter binding + +### Integration Tests + +1. **CRUD Operations** + - Create, read, update, delete + - Verify data integrity + - Test transactions + +2. **Queries** + - Simple filters + - Complex filters (multiple conditions) + - Pagination + - Sorting + - Performance with large datasets + +3. **Relations** + - Store relations + - Query relations + - Delete relations + - Cascade deletes + +### Performance Tests + +1. **Query Performance** + - Compare old vs new + - Test with 500+ objects + - Test complex filters + - Measure query time + +2. **Write Performance** + - Batch inserts + - Single inserts + - Updates + - Measure write time + +3. **Memory Usage** + - Compare old vs new + - Test with large datasets + - Measure memory footprint + +--- + +## Migration Path + +### Since it's Green Field + +1. **No Data Migration Needed** + - Start fresh with normalized structure + - No existing data to migrate + - Clean implementation + +2. **Implementation Order** + - Build new normalized structure + - Test thoroughly + - Replace old code + - Deploy + +3. **Rollback Plan** + - Keep old code in git history + - Can revert if needed + - No data loss risk (green field) + +--- + +## Example Implementations + +### Example 1: Normalization + +**Input (CMDBObject):** +```typescript +{ + id: "123", + objectKey: "APP-1", + label: "My Application", + status: "Active", + applicationFunction: [ + { objectId: "456", objectKey: "FUNC-1", label: "Function 1" }, + { objectId: "789", objectKey: "FUNC-2", label: "Function 2" } + ], + ictGovernanceModel: { objectId: "999", objectKey: "GOV-A", label: "Model A" }, + customDevelopment: true, + zenyaID: 42 +} +``` + +**Normalized (objects + attribute_values):** + +```sql +-- objects table +INSERT INTO objects (id, object_key, object_type_name, label, jira_updated_at, jira_created_at, cached_at) +VALUES ('123', 'APP-1', 'ApplicationComponent', 'My Application', '2024-01-01', '2024-01-01', NOW()); + +-- attribute_values table +INSERT INTO attribute_values (object_id, attribute_id, text_value, number_value, boolean_value, reference_object_id, array_index) +VALUES + -- status (text) + ('123', , 'Active', NULL, NULL, NULL, 0), + -- applicationFunction (reference array) + ('123', , NULL, NULL, NULL, '456', 0), -- First function + ('123', , NULL, NULL, NULL, '789', 1), -- Second function + -- ictGovernanceModel (reference) + ('123', , NULL, NULL, NULL, '999', 0), + -- customDevelopment (boolean) + ('123', , NULL, NULL, true, NULL, 0), + -- zenyaID (integer) + ('123', , NULL, 42, NULL, NULL, 0); +``` + +### Example 2: Query + +**Filter:** +```typescript +{ + status: "Active", + governanceModel: { objectId: "999" }, + applicationFunction: { exists: true } +} +``` + +**Generated SQL:** +```sql +SELECT DISTINCT o.* +FROM objects o +WHERE o.object_type_name = 'ApplicationComponent' + AND EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = 'status' + AND av.text_value = $1 + ) + AND EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = 'ictGovernanceModel' + AND av.reference_object_id = $2 + ) + AND EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = 'applicationFunction' + ) +ORDER BY o.label ASC +LIMIT $3 OFFSET $4; +``` + +**Parameters:** `['Active', '999', 25, 0]` + +### Example 3: Reconstruction + +**Query:** +```sql +SELECT o.*, a.field_name, av.* +FROM objects o +LEFT JOIN attribute_values av ON av.object_id = o.id +LEFT JOIN attributes a ON av.attribute_id = a.id +WHERE o.id = '123' +ORDER BY a.field_name, av.array_index; +``` + +**Result Processing:** +```typescript +// Group by field_name +const values: Record = {}; + +for (const row of rows) { + const fieldName = row.field_name; + const value = getValueFromRow(row); // Extract from correct column + + if (row.array_index === 0) { + // Single value + values[fieldName] = value; + } else { + // Array value + if (!values[fieldName]) values[fieldName] = []; + (values[fieldName] as unknown[]).push(value); + } +} + +// Build CMDBObject +const object: CMDBObject = { + id: row.id, + objectKey: row.object_key, + label: row.label, + ...values +}; +``` + +--- + +## Success Criteria + +### Functional Requirements + +- ✅ All existing functionality works +- ✅ Queries return correct results +- ✅ No data loss +- ✅ Relations work correctly +- ✅ Sync process works + +### Performance Requirements + +- ✅ Query performance: 50%+ faster for filtered queries +- ✅ Memory usage: 30%+ reduction +- ✅ Write performance: No degradation (< 10% slower acceptable) +- ✅ Database size: Similar or smaller + +### Quality Requirements + +- ✅ Test coverage: 80%+ for new code +- ✅ No critical bugs +- ✅ Code well documented +- ✅ Type-safe implementation + +--- + +## Timeline + +| Step | Duration | Description | +|------|----------|-------------| +| 1 | 0.5 day | Database schema | +| 2 | 0.5 day | Schema discovery | +| 3 | 1 day | Normalization logic | +| 4 | 1 day | Reconstruction logic | +| 5 | 0.5 day | Basic CRUD | +| 6 | 1 day | Query builder | +| 7 | 1 day | Query operations | +| 8 | 0.5 day | Relations | +| 9 | 1 day | Update services | +| 10 | 0.5 day | Statistics | +| 11 | 0.5 day | Cleanup | +| **Total** | **8 days** | | + +**Buffer:** +2 days for unexpected issues +**Total Estimated:** 1.5-2 weeks + +--- + +## Next Steps + +1. ✅ Review and approve plan +2. ✅ Create feature branch: `feature/normalized-database` +3. ✅ Start implementation (Step 1) +4. ✅ Daily progress updates +5. ✅ Weekly review + +--- + +## Appendix + +### A. Attribute Type Mapping + +| Jira Type | Database Column | TypeScript Type | +|-----------|----------------|-----------------| +| text, textarea, url, email, select, user, status | text_value | string | +| integer, float | number_value | number | +| boolean | boolean_value | boolean | +| date | date_value | string (ISO date) | +| datetime | datetime_value | string (ISO datetime) | +| reference | reference_object_id | ObjectReference | + +### B. Query Performance Comparison + +**Old (JSONB + JavaScript filtering):** +- Load all objects: ~500ms +- Filter in JavaScript: ~50ms +- Total: ~550ms + +**New (Normalized + SQL):** +- Query with indexes: ~20ms +- Total: ~20ms + +**Improvement:** ~27x faster + +### C. Database Size Comparison + +**Old (JSONB):** +- 500 objects × ~5KB JSON = ~2.5MB + +**New (Normalized):** +- 500 objects × ~100 bytes = ~50KB (objects) +- 500 objects × ~20 attributes × ~50 bytes = ~500KB (attribute_values) +- Total: ~550KB + +**Improvement:** ~4.5x smaller + +--- + +**End of Plan** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55339c4..0ea3c08 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef } from 'react'; -import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom'; +import { Routes, Route, Link, useLocation, Navigate, useParams, useNavigate } from 'react-router-dom'; import { clsx } from 'clsx'; import SearchDashboard from './components/SearchDashboard'; import Dashboard from './components/Dashboard'; @@ -22,8 +22,18 @@ import DataCompletenessConfig from './components/DataCompletenessConfig'; import BIASyncDashboard from './components/BIASyncDashboard'; import BusinessImportanceComparison from './components/BusinessImportanceComparison'; import Login from './components/Login'; +import ForgotPassword from './components/ForgotPassword'; +import ResetPassword from './components/ResetPassword'; +import AcceptInvitation from './components/AcceptInvitation'; +import ProtectedRoute from './components/ProtectedRoute'; +import UserManagement from './components/UserManagement'; +import RoleManagement from './components/RoleManagement'; +import ProfileSettings from './components/ProfileSettings'; import { useAuthStore } from './stores/authStore'; +// Module-level singleton to prevent duplicate initialization across StrictMode remounts +let initializationPromise: Promise | null = null; + // Redirect component for old app-components/overview/:id paths function RedirectToApplicationEdit() { const { id } = useParams<{ id: string }>(); @@ -35,6 +45,7 @@ interface NavItem { path: string; label: string; exact?: boolean; + requiredPermission?: string; // Permission required to see this menu item } interface NavDropdown { @@ -45,11 +56,24 @@ interface NavDropdown { } // Dropdown component for navigation -function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) { +function NavDropdown({ dropdown, isActive, hasPermission }: { dropdown: NavDropdown; isActive: boolean; hasPermission: (permission: string) => boolean }) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const location = useLocation(); + // Filter items based on permissions + const visibleItems = dropdown.items.filter(item => { + if (!item.requiredPermission) { + return true; // No permission required, show item + } + return hasPermission(item.requiredPermission); + }); + + // Don't render dropdown if no items are visible + if (visibleItems.length === 0) { + return null; + } + // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -90,7 +114,7 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: {isOpen && (
- {dropdown.items.map((item) => { + {visibleItems.map((item) => { const itemActive = item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path); @@ -119,16 +143,26 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: function UserMenu() { const { user, authMethod, logout } = useAuthStore(); const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); if (!user) return null; - const initials = user.displayName + const displayName = user.displayName || user.username || user.email || 'User'; + const email = user.email || user.emailAddress || ''; + + const initials = displayName .split(' ') .map(n => n[0]) .join('') .toUpperCase() .slice(0, 2); + const handleLogout = async () => { + setIsOpen(false); + await logout(); + navigate('/login'); + }; + return (
)} - {user.displayName} + {displayName} @@ -160,26 +194,36 @@ function UserMenu() { />
-

{user.displayName}

- {user.emailAddress && ( -

{user.emailAddress}

+

{displayName}

+ {email && ( +

{email}

+ )} + {user.username && email !== user.username && ( +

@{user.username}

)}

- {authMethod === 'oauth' ? 'Jira OAuth' : 'Service Account'} + {authMethod === 'oauth' ? 'Jira OAuth' : authMethod === 'local' ? 'Lokaal Account' : 'Service Account'}

- {authMethod === 'oauth' && ( - + {(authMethod === 'local' || authMethod === 'oauth') && ( + <> + setIsOpen(false)} + className="block px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors" + > + Profiel & Instellingen + +
+ )} +
@@ -190,15 +234,17 @@ function UserMenu() { function AppContent() { const location = useLocation(); + const hasPermission = useAuthStore((state) => state.hasPermission); + const config = useAuthStore((state) => state.config); // Navigation structure const appComponentsDropdown: NavDropdown = { label: 'Application Component', basePath: '/application', items: [ - { path: '/app-components', label: 'Dashboard', exact: true }, - { path: '/application/overview', label: 'Overzicht', exact: false }, - { path: '/application/fte-calculator', label: 'FTE Calculator', exact: true }, + { path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' }, + { path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' }, + { path: '/application/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' }, ], }; @@ -206,16 +252,16 @@ function AppContent() { label: 'Rapporten', basePath: '/reports', items: [ - { path: '/reports', label: 'Overzicht', exact: true }, - { path: '/reports/team-dashboard', label: 'Team-indeling', exact: true }, - { path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true }, - { path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true }, - { path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true }, - { path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true }, - { path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true }, - { path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true }, - { path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true }, - { path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true }, + { path: '/reports', label: 'Overzicht', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/team-dashboard', label: 'Team-indeling', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true, requiredPermission: 'view_reports' }, + { path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true, requiredPermission: 'view_reports' }, ], }; @@ -223,7 +269,7 @@ function AppContent() { label: 'Apps', basePath: '/apps', items: [ - { path: '/apps/bia-sync', label: 'BIA Sync', exact: true }, + { path: '/apps/bia-sync', label: 'BIA Sync', exact: true, requiredPermission: 'search' }, ], }; @@ -231,9 +277,18 @@ function AppContent() { label: 'Instellingen', basePath: '/settings', items: [ - { path: '/settings/fte-config', label: 'FTE Config', exact: true }, - { path: '/settings/data-model', label: 'Datamodel', exact: true }, - { path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true }, + { path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' }, + { path: '/settings/data-model', label: 'Datamodel', exact: true, requiredPermission: 'manage_settings' }, + { path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true, requiredPermission: 'manage_settings' }, + ], + }; + + const adminDropdown: NavDropdown = { + label: 'Beheer', + basePath: '/admin', + items: [ + { path: '/admin/users', label: 'Gebruikers', exact: true, requiredPermission: 'manage_users' }, + { path: '/admin/roles', label: 'Rollen', exact: true, requiredPermission: 'manage_roles' }, ], }; @@ -241,7 +296,7 @@ function AppContent() { const isReportsActive = location.pathname.startsWith('/reports'); const isSettingsActive = location.pathname.startsWith('/settings'); const isAppsActive = location.pathname.startsWith('/apps'); - const isDashboardActive = location.pathname === '/'; + const isAdminActive = location.pathname.startsWith('/admin'); return (
@@ -254,37 +309,27 @@ function AppContent() { Zuyderland

- Analyse Tool + {config?.appName || 'CMDB Insight'}

-

Zuyderland CMDB

+

{config?.appTagline || 'Management console for Jira Assets'}

@@ -297,36 +342,43 @@ function AppContent() {
{/* Main Dashboard (Search) */} - } /> + } /> - {/* Application routes (new structure) */} - } /> - } /> - } /> - } /> + {/* Application routes (new structure) - specific routes first, then dynamic */} + } /> + } /> + } /> + } /> {/* Application Component routes */} - } /> + } /> {/* Reports routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Apps routes */} - } /> + } /> {/* Settings routes */} - } /> - } /> - } /> + } /> + } /> + } /> + } /> + {/* Legacy redirects for old routes */} + } /> + + {/* Admin routes */} + } /> + } /> {/* Legacy redirects for bookmarks - redirect old paths to new ones */} } /> @@ -336,7 +388,7 @@ function AppContent() { } /> } /> } /> - } /> + } /> } />
@@ -345,39 +397,180 @@ function AppContent() { } function App() { - const { isAuthenticated, isLoading, checkAuth, fetchConfig, config } = useAuthStore(); + const { isAuthenticated, checkAuth, fetchConfig, config, user, authMethod, isInitialized, setInitialized, setConfig } = useAuthStore(); + const location = useLocation(); useEffect(() => { - // Fetch auth config first, then check auth status - const init = async () => { - await fetchConfig(); - await checkAuth(); - }; - init(); - }, [fetchConfig, checkAuth]); + // Use singleton pattern to ensure initialization happens only once + // This works across React StrictMode remounts + + // Check if already initialized by checking store state + const currentState = useAuthStore.getState(); + if (currentState.config && currentState.isInitialized) { + return; + } - // Show loading state - if (isLoading) { + // If already initializing, wait for existing promise + if (initializationPromise) { + return; + } + + // Create singleton initialization promise + // OPTIMIZATION: Run config and auth checks in parallel instead of sequentially + initializationPromise = (async () => { + try { + const state = useAuthStore.getState(); + const defaultConfig = { + appName: 'CMDB Insight', + appTagline: 'Management console for Jira Assets', + appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`, + authMethod: 'local' as const, + oauthEnabled: false, + serviceAccountEnabled: false, + localAuthEnabled: true, + jiraHost: '', + }; + + // Parallelize API calls - this is the key optimization! + // Instead of waiting for config then auth (sequential), do both at once + await Promise.allSettled([ + state.config ? Promise.resolve() : fetchConfig(), + checkAuth(), + ]); + + // Ensure config is set (use fetched or default) + const stateAfterInit = useAuthStore.getState(); + if (!stateAfterInit.config) { + setConfig(defaultConfig); + } + + // Ensure isLoading is false + const finalState = useAuthStore.getState(); + if (finalState.isLoading) { + const { setLoading } = useAuthStore.getState(); + setLoading(false); + } + + setInitialized(true); + } catch (error) { + console.error('[App] Initialization error:', error); + // Always mark as initialized to prevent infinite loading + const state = useAuthStore.getState(); + if (!state.config) { + setConfig({ + appName: 'CMDB Insight', + appTagline: 'Management console for Jira Assets', + appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`, + authMethod: 'local', + oauthEnabled: false, + serviceAccountEnabled: false, + localAuthEnabled: true, + jiraHost: '', + }); + } + setInitialized(true); + } + })(); + + // Reduced timeout since we're optimizing - 1.5 seconds should be plenty + const timeoutId = setTimeout(() => { + const state = useAuthStore.getState(); + if (!state.config) { + setConfig({ + appName: 'CMDB Insight', + appTagline: 'Management console for Jira Assets', + appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`, + authMethod: 'local', + oauthEnabled: false, + serviceAccountEnabled: false, + localAuthEnabled: true, + jiraHost: '', + }); + } + setInitialized(true); + }, 1500); + + return () => { + clearTimeout(timeoutId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps - functions from store are stable + + // Auth routes that should render outside the main layout + const isAuthRoute = ['/login', '/forgot-password', '/reset-password', '/accept-invitation'].includes(location.pathname); + + // Handle missing config after initialization using useEffect + useEffect(() => { + if (isInitialized && !config) { + setConfig({ + appName: 'CMDB Insight', + appTagline: 'Management console for Jira Assets', + appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`, + authMethod: 'local', + oauthEnabled: false, + serviceAccountEnabled: false, + localAuthEnabled: true, + jiraHost: '', + }); + } + }, [isInitialized, config, setConfig]); + + // Get current config from store (might be updated by useEffect above) + const currentConfig = config || useAuthStore.getState().config; + + // If on an auth route, render it directly (no layout) - don't wait for config + if (isAuthRoute) { return ( -
+ + } /> + } /> + } /> + } /> + + ); + } + + // For non-auth routes, we need config + // Show loading ONLY if we don't have config + // Once initialized and we have config, proceed even if isLoading is true + // (isLoading might be stuck due to StrictMode duplicate calls) + if (!currentConfig) { + return ( +
-
-

Laden...

+
+

Laden...

); } - // Show login if OAuth is enabled and not authenticated - if (config?.authMethod === 'oauth' && !isAuthenticated) { - return ; + // STRICT AUTHENTICATION CHECK: + // Service accounts are NOT used for application authentication + // They are only for Jira API access (JIRA_SERVICE_ACCOUNT_TOKEN in .env) + // Application authentication ALWAYS requires a real user session (local or OAuth) + + // Check if this is a service account user (should never happen, but reject if it does) + const isServiceAccount = user?.accountId === 'service-account' || authMethod === 'service-account'; + + // Check if user is a real authenticated user (has id, not service account) + const isRealUser = isAuthenticated && user && user.id && !isServiceAccount; + + // ALWAYS reject service account users - they are NOT valid for application authentication + if (isServiceAccount) { + return ; } - // Show login if nothing is configured - if (config?.authMethod === 'none') { - return ; + // If not authenticated as a real user, redirect to login + if (!isRealUser) { + return ; } + // Real user authenticated - allow access + + // At this point, user is either: + // 1. Authenticated (isAuthenticated === true), OR + // 2. Service account is explicitly allowed (allowServiceAccount === true) // Show main app return ; } diff --git a/frontend/src/components/AcceptInvitation.tsx b/frontend/src/components/AcceptInvitation.tsx new file mode 100644 index 0000000..8798ec8 --- /dev/null +++ b/frontend/src/components/AcceptInvitation.tsx @@ -0,0 +1,281 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate, Link } from 'react-router-dom'; +import AuthLayout from './AuthLayout'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +interface InvitationData { + valid: boolean; + user: { + email: string; + username: string; + display_name: string | null; + }; +} + +export default function AcceptInvitation() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [invitationData, setInvitationData] = useState(null); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!token) { + setError('Geen uitnodiging token gevonden in de URL'); + setIsLoading(false); + return; + } + + // Validate invitation token + fetch(`${API_BASE}/api/auth/invitation/${token}`) + .then((res) => res.json()) + .then((data) => { + if (data.valid) { + setInvitationData(data); + } else { + setError('Ongeldige of verlopen uitnodiging'); + } + }) + .catch((err) => { + setError('Failed to validate invitation'); + console.error(err); + }) + .finally(() => { + setIsLoading(false); + }); + }, [token]); + + const getPasswordStrength = (pwd: string): { strength: number; label: string; color: string } => { + let strength = 0; + if (pwd.length >= 8) strength++; + if (pwd.length >= 12) strength++; + if (/[a-z]/.test(pwd)) strength++; + if (/[A-Z]/.test(pwd)) strength++; + if (/[0-9]/.test(pwd)) strength++; + if (/[^a-zA-Z0-9]/.test(pwd)) strength++; + + if (strength <= 2) return { strength, label: 'Zwak', color: 'red' }; + if (strength <= 4) return { strength, label: 'Gemiddeld', color: 'yellow' }; + return { strength, label: 'Sterk', color: 'green' }; + }; + + const passwordStrength = password ? getPasswordStrength(password) : null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (password !== confirmPassword) { + setError('Wachtwoorden komen niet overeen'); + return; + } + + if (password.length < 8) { + setError('Wachtwoord moet minimaal 8 tekens lang zijn'); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch(`${API_BASE}/api/auth/accept-invitation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, password }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to accept invitation'); + } + + setSuccess(true); + setTimeout(() => { + navigate('/login'); + }, 2000); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
+
+

Uitnodiging valideren...

+
+
+ ); + } + + if (!token || error) { + return ( +
+
+
+
+
+ + + +
+

Ongeldige uitnodiging

+

+ {error || 'De uitnodiging is ongeldig of verlopen.'} +

+ + Terug naar inloggen + +
+
+
+
+ ); + } + + if (success) { + return ( +
+
+
+
+
+ + + +
+

Account geactiveerd

+

+ Je account is succesvol geactiveerd. Je wordt doorgestuurd naar de login pagina... +

+
+
+
+
+ ); + } + + return ( + +

Welkom

+

+ Stel je wachtwoord in om je account te activeren +

+ + {invitationData && ( +
+

+ E-mail: {invitationData.user.email} +

+

+ Gebruikersnaam: {invitationData.user.username} +

+
+ )} + + {error && ( +
+ + + +

{error}

+
+ )} + +
+
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + placeholder="••••••••" + /> + {passwordStrength && ( +
+
+
+
+
+ + {passwordStrength.label} + +
+
+ )} +
+
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + placeholder="••••••••" + /> + {confirmPassword && password !== confirmPassword && ( +

Wachtwoorden komen niet overeen

+ )} +
+ + + + ); +} diff --git a/frontend/src/components/ApplicationInfo.tsx b/frontend/src/components/ApplicationInfo.tsx index 9737121..1f3e19f 100644 --- a/frontend/src/components/ApplicationInfo.tsx +++ b/frontend/src/components/ApplicationInfo.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef, type ReactNode } from 'react'; import { useParams, Link } from 'react-router-dom'; import { clsx } from 'clsx'; import { @@ -10,7 +10,8 @@ import { import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; import { EffortDisplay } from './EffortDisplay'; import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation'; -import type { ApplicationDetails } from '../types'; +import { useAuthStore } from '../stores/authStore'; +import type { ApplicationDetails, ReferenceValue } from '../types'; // Related objects configuration interface RelatedObjectConfig { @@ -141,6 +142,11 @@ export default function ApplicationInfo() { // Related objects state const [relatedObjects, setRelatedObjects] = useState>(new Map()); const [expandedSections, setExpandedSections] = useState>(new Set()); // Default collapsed + const [supplierExpanded, setSupplierExpanded] = useState(true); // Leverancier(s) block expanded by default + const [contactsExpanded, setContactsExpanded] = useState(true); // Contactpersonen block expanded by default + const [basisInfoExpanded, setBasisInfoExpanded] = useState(true); // Basis informatie block expanded by default + const [governanceExpanded, setGovernanceExpanded] = useState(true); // Governance & Management block expanded by default + const [classificationExpanded, setClassificationExpanded] = useState(false); // Classificatie block collapsed by default useEffect(() => { async function fetchData() { @@ -169,11 +175,12 @@ export default function ApplicationInfo() { // Set page title useEffect(() => { + const appName = useAuthStore.getState().config?.appName || 'CMDB Insight'; if (application) { - document.title = `${application.name} | Zuyderland CMDB`; + document.title = `${application.name} | ${appName}`; } return () => { - document.title = 'Zuyderland CMDB'; + document.title = appName; }; }, [application]); @@ -223,283 +230,647 @@ export default function ApplicationInfo() { }); }; + const handleBasisInfoToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setBasisInfoExpanded(prev => !prev); + }; + + const handleGovernanceToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setGovernanceExpanded(prev => !prev); + }; + if (loading) { return ( -
-
+
+
+
+ + + + +
+

Applicatie laden...

+

Gegevens worden opgehaald uit de CMDB

+
); } if (error || !application) { return ( -
- {error || 'Application not found'} +
+
+
+
+
+
+ + + +
+
+
+

Fout bij laden

+

{error || 'Application not found'}

+ + + + + Terug naar overzicht + +
+
+
+
); } return ( -
- {/* Back navigation */} -
- - - - - Terug naar overzicht - -
- - {/* Header with application name and quick actions */} -
-
-
-
-

{application.name}

- -
-
- {application.key} - {application.applicationType && ( - - {application.applicationType.name} - - )} - {application.hostingType && ( - - {application.hostingType.name} - - )} -
-
- - {/* Quick action buttons */} -
- {jiraHost && application.key && ( - - - - - Open in Jira - - )} +
+
+
+ {/* Back navigation */} +
- - + + - Bewerken + Terug naar overzicht
-
- - {/* Description */} - {application.description && ( -
-

{application.description}

-
- )} -
- {/* Main info grid */} -
- {/* Left column - Basic info */} -
-
-

Basis informatie

-
-
- - - - {application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && ( -
- - - Document openen - - - - -
- )} -
-
- - {/* Right column - Business info */} -
-
-

Business informatie

-
-
-
- - -
- - - - {application.dataCompletenessPercentage !== undefined && ( -
- -
-
-
-
= 80 - ? 'bg-green-500' - : application.dataCompletenessPercentage >= 60 - ? 'bg-yellow-500' - : 'bg-red-500' - }`} - style={{ width: `${application.dataCompletenessPercentage}%` }} - /> + {/* Header with application name and quick actions */} +
+ {/* Header gradient */} +
+
+
+
+
+

{application.name}

+ {application.hostingType && ( + + {application.hostingType.name} + {application.hostingType.objectId && jiraHost && ( + + e.stopPropagation()} + > + + + + )} + + )} +
+ {/* Quick action buttons */} +
+ {application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && ( + + + + + + + + )} + {application.confluenceSpace && application.confluenceSpace.trim() !== '' && ( + + + + + + + + )} + {jiraHost && application.key && ( + + + + + + )}
- - {application.dataCompletenessPercentage.toFixed(1)}% - +
+ + {/* Status badge on the right */} +
+ +
+
+
+ + {/* Reference warning - only show if reference is truly empty */} + {(() => { + const refValue = application.reference; + + // Helper function to check if a string is truly empty (handles all edge cases) + const isStringEmpty = (str: unknown): boolean => { + if (str === null || str === undefined) { + return true; + } + if (typeof str !== 'string') { + // If it's not a string, we can't determine if it's empty, so assume it's not empty + return false; + } + // Check for empty string + if (str === '') { + return true; + } + // Trim and check - this handles spaces, tabs, newlines, etc. + const trimmed = str.trim(); + if (trimmed === '') { + return true; + } + // Check for whitespace-only strings using regex (handles Unicode whitespace too) + if (/^\s*$/.test(str)) { + return true; + } + // Check for strings that only contain zero-width characters + if (trimmed.replace(/[\u200B-\u200D\uFEFF]/g, '') === '') { + return true; + } + // Has meaningful content + return false; + }; + + const isEmpty = isStringEmpty(refValue); + + return isEmpty; + })() && ( +
+
+ + + +
+

Deze Application Component is (nog) niet toegevoegd in Enterprise Architect

+
+
+
+ )} + + {/* Description */} +
+ {application.description && ( +
+ + + +

{application.description}

+
+ )} + {/* Application Functions pills/tags below description */} + {application.applicationFunctions && application.applicationFunctions.length > 0 && ( +
+ {application.applicationFunctions.map((func, index) => ( +
+ {index === 0 && ( + + + + )} + {func.name} + {func.objectId && jiraHost && ( + + e.stopPropagation()} + > + + + + )} +
+ ))} +
+ )} +
+
+ + {/* Basis informatie and Governance & Management side by side */} +
+ {/* Basis informatie */} +
+ + {basisInfoExpanded && ( +
+
+ + +
+ +
+ +
+
+
+ +
+ {application.businessImpactAnalyse?.name ? ( + <> + + {application.businessImpactAnalyse.name} + + {/* Info icon with description tooltip - shown when description exists */} + {(application.businessImpactAnalyse?.description || application.businessImpactAnalyse?.indicators) && ( + + + + )} + {/* External link to Jira Assets - shown when objectId exists */} + {application.businessImpactAnalyse.objectId && jiraHost && ( + + e.stopPropagation()} + > + + + + )} + + ) : ( + <> + Niet ingevuld + {/* Info icon with description tooltip - shown when description exists */} + {(application.businessImpactAnalyse?.description || application.businessImpactAnalyse?.indicators) && ( + + + + )} + + )} +
+ {application.businessImpactAnalyse?.indicators && ( +

{application.businessImpactAnalyse.indicators}

+ )} +
+ {application.dataCompletenessPercentage !== undefined && ( +
+ +
+
+
+
= 80 + ? 'bg-gradient-to-r from-green-500 to-green-600' + : application.dataCompletenessPercentage >= 60 + ? 'bg-gradient-to-r from-yellow-500 to-yellow-600' + : 'bg-gradient-to-r from-red-500 to-red-600' + }`} + style={{ width: `${application.dataCompletenessPercentage}%` }} + /> +
+
+ = 80 + ? 'text-green-700' + : application.dataCompletenessPercentage >= 60 + ? 'text-yellow-700' + : 'text-red-700' + }`}> + {application.dataCompletenessPercentage.toFixed(1)}% + +
+
+ )} +
+
+ )} +
+ + {/* Governance & Management */} +
+ + {governanceExpanded && ( +
+
+ + + + + { + const teamName = application.applicationTeam?.name; + const subteamName = application.applicationSubteam?.name; + if (teamName) { + return subteamName ? `${teamName} (${subteamName})` : teamName; + } + return subteamName || undefined; + })()} + referenceValue={application.applicationTeam || application.applicationSubteam} + jiraHost={jiraHost} + /> +
+
+ )} +
+
+ + {/* Contacts & Leverancier(s) blocks side by side */} +
+ {/* Contacts */} +
+ + {contactsExpanded && ( +
+
+ + + + { + const mainValue = application.technicalApplicationManagement; + const primary = application.technicalApplicationManagementPrimary?.trim(); + const secondary = application.technicalApplicationManagementSecondary?.trim(); + const parts = []; + if (primary) parts.push(primary); + if (secondary) parts.push(secondary); + if (mainValue) { + return parts.length > 0 ? `${mainValue} (${parts.join(', ')})` : mainValue; + } + return parts.length > 0 ? `(${parts.join(', ')})` : undefined; + })()} + /> +
+
+ )} +
+ + {/* Leverancier(s) block */} +
+ + {supplierExpanded && ( +
+
+ + + + +
+
+ )} +
+
+ + {/* Classification section */} +
+ + {classificationExpanded && ( +
+
+
+ + + +
+ + {/* FTE - Benodigde inspanning applicatiemanagement */} +
+
+
+ + + +
+ +
+
+ +
+

+ Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25) +

+
)}
-
-
- {/* Management section */} -
- {/* Governance */} -
-
-

Governance & Management

-
-
- - - - - -
-
- - {/* Contacts */} -
-
-

Contactpersonen

-
-
- - - { - const primary = application.technicalApplicationManagementPrimary?.trim(); - const secondary = application.technicalApplicationManagementSecondary?.trim(); - const parts = []; - if (primary) parts.push(primary); - if (secondary) parts.push(secondary); - return parts.length > 0 ? parts.join(', ') : undefined; - })()} - /> -
-
-
- - {/* Classification section */} -
-
-

Classificatie

-
-
-
- - - -
- - {/* FTE - Benodigde inspanning applicatiemanagement */} -
- -
- + {/* Related Objects Sections */} +
+
+
+ + + +
+

Gerelateerde objecten

-

- Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25) -

-
-
-
- - {/* Application Functions */} - {application.applicationFunctions && application.applicationFunctions.length > 0 && ( -
-
-

- Applicatiefuncties ({application.applicationFunctions.length}) -

-
-
-
- {application.applicationFunctions.map((func, index) => ( - - {func.name} - - ))} -
-
-
- )} - - {/* Related Objects Sections */} -
-

Gerelateerde objecten

- - {RELATED_OBJECTS_CONFIG.map((config) => { + + {RELATED_OBJECTS_CONFIG.map((config) => { const data = relatedObjects.get(config.objectType); const isExpanded = expandedSections.has(config.objectType); const colors = COLOR_SCHEMES[config.colorScheme]; @@ -508,14 +879,14 @@ export default function ApplicationInfo() { const isLoading = data?.loading ?? true; return ( -
+
{/* Header - clickable to expand/collapse */}