Compare commits

..

12 Commits

Author SHA1 Message Date
1fa424efb9 Add authentication, user management, and database migration features
- Implement OAuth 2.0 and PAT authentication methods
- Add user management, roles, and profile functionality
- Add database migrations and admin user scripts
- Update services for authentication and user settings
- Add protected routes and permission hooks
- Update documentation for authentication and database access
2026-01-15 03:20:50 +01:00
f3637b85e1 Add comprehensive deployment advice and App Service deployment guide
- Add DEPLOYMENT-ADVICE.md with detailed analysis and recommendations
- Add AZURE-APP-SERVICE-DEPLOYMENT.md with step-by-step instructions
- Include NEN 7510 compliance considerations
- Include VPN/private network options for future
- Include monitoring and Elastic stack integration guidance
2026-01-14 18:09:42 +01:00
408c9f4727 Add quick deployment guide and update docker-compose ACR name
- Add QUICK-DEPLOYMENT-GUIDE.md with step-by-step instructions
- Update docker-compose.prod.acr.yml to use zdlas ACR name
- Include App Service and VM deployment options
2026-01-14 17:57:02 +01:00
df3f6f6899 Update docker-compose.prod.acr.yml with correct ACR name and add deployment next steps guide
- Update ACR name from zuyderlandcmdbacr to zdlas
- Add comprehensive deployment next steps guide
- Include deployment options: App Service, ACI, VM, AKS
2026-01-14 17:56:40 +01:00
de7b529ffb Fix PowerShell script variable usage in pipeline
- Use PowerShell variables instead of Azure DevOps variables in same script
- Fix backendImage and frontendImage output
2026-01-14 17:01:39 +01:00
68518f0193 Fix NodeJS.Timeout type errors in frontend
- Change NodeJS.Timeout to ReturnType<typeof setTimeout> for browser compatibility
- Fix timeout ref types in GovernanceModelBadge and TeamDashboard
- All TypeScript compilation errors now resolved
2026-01-14 16:59:16 +01:00
aba16f68de Fix all frontend TypeScript compilation errors
- Add _jiraUpdatedAt to ApplicationDetails type
- Fix bia type in ComplexityDynamicsBubbleChart (null to empty string)
- Fix source type in GovernanceModelHelper (explicit union type)
- Add vite-env.d.ts for import.meta.env types
- Add node.d.ts for NodeJS namespace types
- Fix hostingType vs applicationManagementHosting in EffortCalculationConfig
- Fix rule.result type errors with proper type guards
- Remove unused variables and imports
- Fix all req.query and req.params type errors
2026-01-14 16:57:01 +01:00
f51e9b8574 Fix all req.params and req.query type errors
- Add getParamString helper function for req.params
- Replace all req.params destructuring with getParamString
- Fix remaining req.query.* direct usage errors
- All TypeScript compilation errors now resolved
2026-01-14 16:50:33 +01:00
81d477ec8c Fix TypeScript compilation errors
- Add searchReference to ApplicationListItem type
- Fix result variable in toApplicationDetails function
- Add query helper functions for req.query parameter handling
- Fix req.query.* type errors in routes (applications, cache, classifications, objects)
- Fix CompletenessCategoryConfig missing id property
- Fix number | null type errors in dataService
- Add utils/queryHelpers.ts for reusable query parameter helpers
2026-01-14 16:36:22 +01:00
fb7dd23027 Fix Dockerfile: Use npm install instead of npm ci (package-lock.json missing) 2026-01-14 16:28:18 +01:00
55c8fee3b8 Add Azure Container Registry setup and documentation
- Configure ACR name: zdlas in azure-pipelines.yml
- Add Azure Container Registry documentation and guides
- Add scripts for ACR creation and image building
- Add docker-compose config for ACR deployment
- Remove temporary Excel lock file
2026-01-14 12:25:25 +01:00
96ed8a9ecf Configure ACR name: zdlas 2026-01-14 12:10:08 +01:00
106 changed files with 21226 additions and 2215 deletions

View File

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

View File

@@ -193,9 +193,9 @@ JIRA_ATTR_APPLICATION_CLUSTER=<attr_id>
JIRA_ATTR_APPLICATION_TYPE=<attr_id>
# AI Classification
ANTHROPIC_API_KEY=<claude_api_key>
OPENAI_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

61
azure-pipelines.yml Normal file
View File

@@ -0,0 +1,61 @@
# Azure DevOps Pipeline - Build and Push Docker Images
# Dit bestand kan gebruikt worden in Azure DevOps Pipelines
trigger:
branches:
include:
- main
tags:
include:
- 'v*'
pool:
vmImage: 'ubuntu-latest'
variables:
# Azure Container Registry naam - pas aan naar jouw ACR
acrName: 'zdlas'
repositoryName: 'zuyderland-cmdb-gui'
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' # Service connection naam in Azure DevOps
imageTag: '$(Build.BuildId)'
stages:
- stage: Build
displayName: 'Build and Push Docker Images'
jobs:
- job: BuildImages
displayName: 'Build Docker Images'
steps:
- task: Docker@2
displayName: 'Build and Push Backend Image'
inputs:
command: buildAndPush
repository: '$(repositoryName)/backend'
dockerfile: 'backend/Dockerfile.prod'
containerRegistry: '$(dockerRegistryServiceConnection)'
tags: |
$(imageTag)
latest
- task: Docker@2
displayName: 'Build and Push Frontend Image'
inputs:
command: buildAndPush
repository: '$(repositoryName)/frontend'
dockerfile: 'frontend/Dockerfile.prod'
containerRegistry: '$(dockerRegistryServiceConnection)'
tags: |
$(imageTag)
latest
- task: PowerShell@2
displayName: 'Output Image URLs'
inputs:
targetType: 'inline'
script: |
$backendImage = "$(acrName).azurecr.io/$(repositoryName)/backend:$(imageTag)"
$frontendImage = "$(acrName).azurecr.io/$(repositoryName)/frontend:$(imageTag)"
Write-Host "##vso[task.setvariable variable=backendImage]$backendImage"
Write-Host "##vso[task.setvariable variable=frontendImage]$frontendImage"
Write-Host "Backend Image: $backendImage"
Write-Host "Frontend Image: $frontendImage"

View File

@@ -2,9 +2,9 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
# Install dependencies (including dev dependencies for build)
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
RUN npm install && npm cache clean --force
# Copy source
COPY . .
@@ -19,7 +19,7 @@ WORKDIR /app
# Install only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
RUN npm install --omit=dev && npm cache clean --force
# Copy built files
COPY --from=builder /app/dist ./dist

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

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

View File

@@ -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<any>('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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -6,13 +6,18 @@ import { logger } from '../services/logger.js';
import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
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;
@@ -31,7 +36,7 @@ router.post('/search', async (req: Request, res: Response) => {
// Get team dashboard data
router.get('/team-dashboard', async (req: Request, res: Response) => {
try {
const excludedStatusesParam = req.query.excludedStatuses as string | undefined;
const excludedStatusesParam = getQueryString(req, 'excludedStatuses');
let excludedStatuses: ApplicationStatus[] = [];
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
@@ -56,7 +61,7 @@ router.get('/team-dashboard', async (req: Request, res: Response) => {
// Get team portfolio health metrics
router.get('/team-portfolio-health', async (req: Request, res: Response) => {
try {
const excludedStatusesParam = req.query.excludedStatuses as string | undefined;
const excludedStatusesParam = getQueryString(req, 'excludedStatuses');
let excludedStatuses: ApplicationStatus[] = [];
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
@@ -95,7 +100,7 @@ router.get('/business-importance-comparison', async (req: Request, res: Response
// Test BIA data loading (for debugging)
router.get('/bia-test', async (req: Request, res: Response) => {
try {
if (req.query.clear === 'true') {
if (getQueryString(req, 'clear') === 'true') {
clearBIACache();
}
const biaData = loadBIAData();
@@ -133,7 +138,7 @@ router.get('/bia-debug', async (req: Request, res: Response) => {
// Test each sample app
for (const app of [...sampleApps, ...testApps]) {
const matchResult = findBIAMatch(app.name, app.searchReference);
const matchResult = findBIAMatch(app.name, app.searchReference ?? null);
// Find all potential matches in Excel data for detailed analysis
const normalizedAppName = app.name.toLowerCase().trim();
@@ -246,7 +251,7 @@ router.get('/bia-comparison', async (req: Request, res: Response) => {
for (const app of applications) {
// Find BIA match in Excel
const matchResult = findBIAMatch(app.name, app.searchReference);
const matchResult = findBIAMatch(app.name, app.searchReference ?? null);
// Log first few matches for debugging
if (comparisonItems.length < 5) {
@@ -322,8 +327,8 @@ router.get('/bia-comparison', async (req: Request, res: Response) => {
// - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection)
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const mode = req.query.mode as string | undefined;
const id = getParamString(req, 'id');
const mode = getQueryString(req, 'mode');
// Don't treat special routes as application IDs
if (id === 'team-dashboard' || id === 'team-portfolio-health' || id === 'business-importance-comparison' || id === 'bia-comparison' || id === 'bia-test' || id === 'calculate-effort' || id === 'search') {
@@ -355,10 +360,23 @@ 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 {
const { id } = req.params;
// 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?: {
applicationFunctions?: ReferenceValue[];
@@ -467,10 +485,23 @@ 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 {
const { id } = req.params;
// 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;
const application = await dataService.getApplicationById(id);
@@ -551,7 +582,7 @@ router.post('/calculate-effort', async (req: Request, res: Response) => {
// Get application classification history
router.get('/:id/history', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const id = getParamString(req, 'id');
const history = await databaseService.getClassificationsByApplicationId(id);
res.json(history);
} catch (error) {
@@ -563,7 +594,8 @@ router.get('/:id/history', async (req: Request, res: Response) => {
// Get related objects for an application (from cache)
router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
try {
const { id, objectType } = req.params;
const id = getParamString(req, 'id');
const objectType = getParamString(req, 'objectType');
// Map object type string to CMDBObjectTypeName
const typeMap: Record<string, CMDBObjectTypeName> = {
@@ -617,8 +649,9 @@ router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
}
// Get requested attributes from query string
const requestedAttrs = req.query.attributes
? String(req.query.attributes).split(',').map(a => a.trim())
const attributesParam = getQueryString(req, 'attributes');
const requestedAttrs = attributesParam
? attributesParam.split(',').map(a => a.trim())
: [];
// Format response - must match RelatedObjectsResponse type expected by frontend

View File

@@ -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 });
}
const user = authService.getUser(sessionId);
if (!user) {
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: 'oauth',
authMethod,
user: userData,
});
} catch (error) {
logger.error('Error getting session:', error);
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' });
}
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);
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;

View File

@@ -8,11 +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 {
@@ -76,7 +82,7 @@ router.post('/sync', async (req: Request, res: Response) => {
// Trigger sync for a specific object type
router.post('/sync/:objectType', async (req: Request, res: Response) => {
try {
const { objectType } = req.params;
const objectType = getParamString(req, 'objectType');
// Validate object type
if (!OBJECT_TYPES[objectType]) {
@@ -93,18 +99,19 @@ router.post('/sync/:objectType', async (req: Request, res: Response) => {
res.json({
status: 'completed',
objectType,
objectType: objectType,
stats: result,
});
} catch (error) {
const objectType = getParamString(req, 'objectType');
const errorMessage = error instanceof Error ? error.message : 'Failed to sync object type';
logger.error(`Failed to sync object type ${req.params.objectType}`, error);
logger.error(`Failed to sync object type ${objectType}`, error);
// Return 409 (Conflict) if sync is already in progress, otherwise 500
const statusCode = errorMessage.includes('already in progress') ? 409 : 500;
res.status(statusCode).json({
error: errorMessage,
objectType: req.params.objectType,
objectType: objectType,
});
}
});
@@ -112,7 +119,7 @@ router.post('/sync/:objectType', async (req: Request, res: Response) => {
// Clear cache for a specific type
router.delete('/clear/:objectType', async (req: Request, res: Response) => {
try {
const { objectType } = req.params;
const objectType = getParamString(req, 'objectType');
if (!OBJECT_TYPES[objectType]) {
res.status(400).json({
@@ -132,7 +139,8 @@ router.delete('/clear/:objectType', async (req: Request, res: Response) => {
deletedCount: deleted,
});
} catch (error) {
logger.error(`Failed to clear cache for ${req.params.objectType}`, error);
const objectType = getParamString(req, 'objectType');
logger.error(`Failed to clear cache for ${objectType}`, error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});

View File

@@ -4,20 +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) => {
try {
const { id } = req.params;
// Get provider from query parameter or request body, default to config
const provider = (req.query.provider as AIProvider) || (req.body.provider as AIProvider) || config.defaultAIProvider;
// All routes require authentication
router.use(requireAuth);
if (!aiService.isConfigured(provider)) {
// 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, 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);
// 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;
}
@@ -28,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) {
@@ -52,7 +89,7 @@ router.get('/taxonomy', (req: Request, res: Response) => {
// Get function by code
router.get('/function/:code', (req: Request, res: Response) => {
try {
const { code } = req.params;
const code = getParamString(req, 'code');
const func = aiService.getFunctionByCode(code);
if (!func) {
@@ -70,7 +107,7 @@ router.get('/function/:code', (req: Request, res: Response) => {
// Get classification history
router.get('/history', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 50;
const limit = getQueryNumber(req, 'limit', 50);
const history = await databaseService.getClassificationHistory(limit);
res.json(history);
} catch (error) {
@@ -91,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',
@@ -111,7 +152,7 @@ router.get('/ai-status', (req: Request, res: Response) => {
// Get the AI prompt for an application (for debugging/copy-paste)
router.get('/prompt/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const id = getParamString(req, 'id');
const application = await dataService.getApplicationById(id);
if (!application) {
@@ -127,10 +168,10 @@ 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 } = req.params;
const id = getParamString(req, 'id');
const { message, conversationId, provider: requestProvider } = req.body;
if (!message || typeof message !== 'string' || message.trim().length === 0) {
@@ -138,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;
if (!aiService.isConfigured(provider)) {
// 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);
// 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;
}
@@ -154,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) {
@@ -167,7 +241,7 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
// Get conversation history
router.get('/chat/conversation/:conversationId', (req: Request, res: Response) => {
try {
const { conversationId } = req.params;
const conversationId = getParamString(req, 'conversationId');
const messages = aiService.getConversationHistory(conversationId);
if (messages.length === 0) {
@@ -185,7 +259,7 @@ router.get('/chat/conversation/:conversationId', (req: Request, res: Response) =
// Clear a conversation
router.delete('/chat/conversation/:conversationId', (req: Request, res: Response) => {
try {
const { conversationId } = req.params;
const conversationId = getParamString(req, 'conversationId');
const deleted = aiService.clearConversation(conversationId);
if (!deleted) {

View File

@@ -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');
/**
@@ -142,37 +147,39 @@ router.get('/data-completeness', async (req: Request, res: Response) => {
description: 'Configuration for Data Completeness Score fields',
lastUpdated: new Date().toISOString(),
},
categories: {
general: {
categories: [
{
id: 'general',
name: 'General',
description: 'General application information fields',
fields: [
{ name: 'Organisation', fieldPath: 'organisation', enabled: true },
{ name: 'ApplicationFunction', fieldPath: 'applicationFunctions', enabled: true },
{ name: 'Status', fieldPath: 'status', enabled: true },
{ name: 'Business Impact Analyse', fieldPath: 'businessImpactAnalyse', enabled: true },
{ name: 'Application Component Hosting Type', fieldPath: 'hostingType', enabled: true },
{ name: 'Supplier Product', fieldPath: 'supplierProduct', enabled: true },
{ name: 'Business Owner', fieldPath: 'businessOwner', enabled: true },
{ name: 'System Owner', fieldPath: 'systemOwner', enabled: true },
{ name: 'Functional Application Management', fieldPath: 'functionalApplicationManagement', enabled: true },
{ name: 'Technical Application Management', fieldPath: 'technicalApplicationManagement', enabled: true },
{ id: 'organisation', name: 'Organisation', fieldPath: 'organisation', enabled: true },
{ id: 'applicationFunctions', name: 'ApplicationFunction', fieldPath: 'applicationFunctions', enabled: true },
{ id: 'status', name: 'Status', fieldPath: 'status', enabled: true },
{ id: 'businessImpactAnalyse', name: 'Business Impact Analyse', fieldPath: 'businessImpactAnalyse', enabled: true },
{ id: 'hostingType', name: 'Application Component Hosting Type', fieldPath: 'hostingType', enabled: true },
{ id: 'supplierProduct', name: 'Supplier Product', fieldPath: 'supplierProduct', enabled: true },
{ id: 'businessOwner', name: 'Business Owner', fieldPath: 'businessOwner', enabled: true },
{ id: 'systemOwner', name: 'System Owner', fieldPath: 'systemOwner', enabled: true },
{ id: 'functionalApplicationManagement', name: 'Functional Application Management', fieldPath: 'functionalApplicationManagement', enabled: true },
{ id: 'technicalApplicationManagement', name: 'Technical Application Management', fieldPath: 'technicalApplicationManagement', enabled: true },
],
},
applicationManagement: {
{
id: 'applicationManagement',
name: 'Application Management',
description: 'Application management classification fields',
fields: [
{ name: 'ICT Governance Model', fieldPath: 'governanceModel', enabled: true },
{ name: 'Application Management - Application Type', fieldPath: 'applicationType', enabled: true },
{ name: 'Application Management - Hosting', fieldPath: 'applicationManagementHosting', enabled: true },
{ name: 'Application Management - TAM', fieldPath: 'applicationManagementTAM', enabled: true },
{ name: 'Application Management - Dynamics Factor', fieldPath: 'dynamicsFactor', enabled: true },
{ name: 'Application Management - Complexity Factor', fieldPath: 'complexityFactor', enabled: true },
{ name: 'Application Management - Number of Users', fieldPath: 'numberOfUsers', enabled: true },
{ id: 'governanceModel', name: 'ICT Governance Model', fieldPath: 'governanceModel', enabled: true },
{ id: 'applicationType', name: 'Application Management - Application Type', fieldPath: 'applicationType', enabled: true },
{ id: 'applicationManagementHosting', name: 'Application Management - Hosting', fieldPath: 'applicationManagementHosting', enabled: true },
{ id: 'applicationManagementTAM', name: 'Application Management - TAM', fieldPath: 'applicationManagementTAM', enabled: true },
{ id: 'dynamicsFactor', name: 'Application Management - Dynamics Factor', fieldPath: 'dynamicsFactor', enabled: true },
{ id: 'complexityFactor', name: 'Application Management - Complexity Factor', fieldPath: 'complexityFactor', enabled: true },
{ id: 'numberOfUsers', name: 'Application Management - Number of Users', fieldPath: 'numberOfUsers', enabled: true },
],
},
},
],
};
res.json(defaultConfig);
}

View File

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

View File

@@ -7,11 +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]) => ({
@@ -31,10 +37,10 @@ router.get('/', (req: Request, res: Response) => {
// Get all objects of a type
router.get('/:type', async (req: Request, res: Response) => {
try {
const { type } = req.params;
const limit = parseInt(req.query.limit as string) || 1000;
const offset = parseInt(req.query.offset as string) || 0;
const search = req.query.search as string | undefined;
const type = getParamString(req, 'type');
const limit = getQueryNumber(req, 'limit', 1000);
const offset = getQueryNumber(req, 'offset', 0);
const search = getQueryString(req, 'search');
// Validate type
if (!OBJECT_TYPES[type]) {
@@ -70,8 +76,9 @@ router.get('/:type', async (req: Request, res: Response) => {
// Get a specific object by ID
router.get('/:type/:id', async (req: Request, res: Response) => {
try {
const { type, id } = req.params;
const forceRefresh = req.query.refresh === 'true';
const type = getParamString(req, 'type');
const id = getParamString(req, 'id');
const forceRefresh = getQueryString(req, 'refresh') === 'true';
// Validate type
if (!OBJECT_TYPES[type]) {
@@ -101,8 +108,10 @@ router.get('/:type/:id', async (req: Request, res: Response) => {
// Get related objects
router.get('/:type/:id/related/:relationType', async (req: Request, res: Response) => {
try {
const { type, id, relationType } = req.params;
const attribute = req.query.attribute as string | undefined;
const type = getParamString(req, 'type');
const id = getParamString(req, 'id');
const relationType = getParamString(req, 'relationType');
const attribute = getQueryString(req, 'attribute');
// Validate types
if (!OBJECT_TYPES[type]) {
@@ -138,8 +147,10 @@ router.get('/:type/:id/related/:relationType', async (req: Request, res: Respons
// Get objects referencing this object (inbound references)
router.get('/:type/:id/referenced-by/:sourceType', async (req: Request, res: Response) => {
try {
const { type, id, sourceType } = req.params;
const attribute = req.query.attribute as string | undefined;
const type = getParamString(req, 'type');
const id = getParamString(req, 'id');
const sourceType = getParamString(req, 'sourceType');
const attribute = getQueryString(req, 'attribute');
// Validate types
if (!OBJECT_TYPES[type]) {

View File

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

View File

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

196
backend/src/routes/roles.ts Normal file
View File

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

View File

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

View File

@@ -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<string, { id: number; name: string; iconUrl: string }>();
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);
}
const objectType = objectTypeMap.get(typeName)!;
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);
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,
};
// Clear token after request
jiraAssetsService.clearRequestToken();
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' });

View File

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

309
backend/src/routes/users.ts Normal file
View File

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

View File

@@ -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<string, UserSession>();
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<string> {
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<DatabaseSession | null> {
const db = getAuthDatabase();
try {
const session = await db.queryOne<DatabaseSession>(
'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<SessionUser | null> {
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,
};
sessionStore.set(sessionId, session);
logger.info(`Created session for user: ${user.displayName}`);
return { sessionId, user };
// Try to find local user by email
let localUser: User | null = null;
if (jiraUser.emailAddress) {
localUser = await userService.getUserByEmail(jiraUser.emailAddress);
}
// Fetch current user info from Jira
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
);
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
*/
async fetchUserInfo(accessToken: string): Promise<JiraUser> {
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);
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 null;
}
return session;
/**
* Get access token for a session
*/
async getAccessToken(sessionId: string): Promise<string | null> {
const session = await this.getSessionFromDb(sessionId);
return session?.access_token || null;
}
// Get access token for a session
getAccessToken(sessionId: string): string | null {
const session = this.getSession(sessionId);
return session?.accessToken || 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<boolean> {
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) {
/**
* Logout / destroy session
*/
async logout(sessionId: string): Promise<boolean> {
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();

View File

@@ -337,8 +337,9 @@ interface TavilySearchResponse {
}
// Perform web search using Tavily API
async function performWebSearch(query: string): Promise<string | null> {
if (!config.enableWebSearch || !config.tavilyApiKey) {
async function performWebSearch(query: string, tavilyApiKey?: string): Promise<string | null> {
// 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<string | null> {
'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<AISuggestion> {
// 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<AISuggestion> {
// 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<Map<string, AISuggestion>> {
const results = new Map<string, AISuggestion>();
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<ChatResponse> {
// 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 => ({

View File

@@ -79,6 +79,13 @@ class CMDBService {
): Promise<T | null> {
// 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<T>(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<string, unknown>
): Promise<UpdateResult> {
// 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);

View File

@@ -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<T extends CMDBObject>(
/**
* 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<ReferenceValue | null> {
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<ApplicationDetails> {
// 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<Applicat
key: app.applicationManagementDynamicsFactor.objectKey,
name: app.applicationManagementDynamicsFactor.label,
factor: factorObj?.factor ?? undefined,
description: factorObj?.description ?? undefined, // Include description from cache
};
}
@@ -226,6 +284,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
key: app.applicationManagementComplexityFactor.objectKey,
name: app.applicationManagementComplexityFactor.label,
factor: factorObj?.factor ?? undefined,
description: factorObj?.description ?? undefined, // Include description from cache
};
}
@@ -237,6 +296,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
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)
};
}
@@ -286,22 +346,27 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
// Override
overrideFTE: app.applicationManagementOverrideFTE ?? null,
requiredEffortApplicationManagement: null,
// Enterprise Architect reference
reference: app.reference || null,
// Confluence Space (URL string)
confluenceSpace: confluenceSpaceValue,
};
// Calculate data completeness percentage
// Convert ApplicationListItem-like structure to format expected by completeness calculator
// Convert ApplicationDetails-like structure to format expected by completeness calculator
const appForCompleteness = {
...result,
organisation: organisation?.name || null,
applicationFunctions: result.applicationFunctions,
status: result.status,
applicationFunctions: applicationFunctions,
status: (app.status || 'In Production') as ApplicationStatus,
businessImpactAnalyse: businessImpactAnalyse,
hostingType: hostingType,
supplierProduct: result.supplierProduct,
businessOwner: result.businessOwner,
systemOwner: result.systemOwner,
functionalApplicationManagement: result.functionalApplicationManagement,
technicalApplicationManagement: result.technicalApplicationManagement,
supplierProduct: extractLabel(app.supplierProduct),
businessOwner: extractLabel(app.businessOwner),
systemOwner: extractLabel(app.systemOwner),
functionalApplicationManagement: app.functionalApplicationManagement || null,
technicalApplicationManagement: extractLabel(app.technicalApplicationManagement),
governanceModel: governanceModel,
applicationType: applicationType,
applicationManagementHosting: applicationManagementHosting,
@@ -314,8 +379,55 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
return {
...result,
id: app.id,
key: app.objectKey,
name: app.label,
description: app.description || null,
status: (app.status || 'In Production') as ApplicationStatus,
searchReference: app.searchReference || null,
// Organization info
organisation: organisation?.name || null,
businessOwner: extractLabel(app.businessOwner),
systemOwner: extractLabel(app.systemOwner),
functionalApplicationManagement: app.functionalApplicationManagement || null,
technicalApplicationManagement: extractLabel(app.technicalApplicationManagement),
technicalApplicationManagementPrimary: extractDisplayValue(app.technicalApplicationManagementPrimary),
technicalApplicationManagementSecondary: extractDisplayValue(app.technicalApplicationManagementSecondary),
// Technical info
medischeTechniek: app.medischeTechniek || false,
technischeArchitectuur: app.technischeArchitectuurTA || null,
supplierProduct: extractLabel(app.supplierProduct),
// Classification
applicationFunctions,
businessImportance: businessImportance?.name || null,
businessImpactAnalyse,
hostingType,
// Application Management
governanceModel,
applicationType,
applicationSubteam,
applicationTeam,
dynamicsFactor,
complexityFactor,
numberOfUsers,
applicationManagementHosting,
applicationManagementTAM,
platform,
// Override
overrideFTE: app.applicationManagementOverrideFTE ?? null,
requiredEffortApplicationManagement: null,
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
// Enterprise Architect reference
reference: app.reference || null,
// Confluence Space (URL string)
confluenceSpace: confluenceSpaceValue,
};
}
@@ -357,11 +469,18 @@ function clearFactorCaches(): void {
* This avoids the overhead of toApplicationDetails while providing enough data for effort calculation
* Note: ensureFactorCaches() must be called before using this function
*/
function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetails {
const governanceModel = toReferenceValue(app.ictGovernanceModel);
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
async function toMinimalDetailsForEffort(app: ApplicationComponent): Promise<ApplicationDetails> {
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;
@@ -394,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)
};
}
@@ -434,29 +554,45 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
/**
* Convert ApplicationComponent to ApplicationListItem (lighter weight, for lists)
*/
function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
async function toApplicationListItem(app: ApplicationComponent): Promise<ApplicationListItem> {
// 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 = {
id: app.id,
key: app.objectKey,
name: app.label,
searchReference: app.searchReference || null,
status: app.status as ApplicationStatus | null,
applicationFunctions,
governanceModel,
@@ -477,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,
@@ -494,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);
@@ -677,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,
@@ -1180,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) {
@@ -1209,7 +1350,7 @@ export const dataService = {
// Get complexity factor value
if (app.applicationManagementComplexityFactor && typeof app.applicationManagementComplexityFactor === 'object') {
const factorObj = complexityFactorCache?.get(app.applicationManagementComplexityFactor.objectId);
if (factorObj?.factor !== undefined) {
if (factorObj?.factor !== undefined && factorObj.factor !== null) {
metrics.complexityValues.push(factorObj.factor);
}
}
@@ -1217,14 +1358,14 @@ export const dataService = {
// Get dynamics factor value
if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') {
const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId);
if (factorObj?.factor !== undefined) {
if (factorObj?.factor !== undefined && factorObj.factor !== null) {
metrics.dynamicsValues.push(factorObj.factor);
}
}
// 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);
@@ -1233,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);
@@ -1286,6 +1427,10 @@ export const dataService = {
async testConnection(): Promise<boolean> {
if (!useJiraAssets) return true;
// Only test connection if token is configured
if (!jiraAssetsClient.hasToken()) {
return false;
}
return jiraAssetsClient.testConnection();
},
@@ -1372,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
@@ -1395,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;

View File

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

View File

@@ -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<void>;
down?: (db: DatabaseAdapter) => Promise<void>;
}
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, number> = {};
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<void> {
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;
}

View File

@@ -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<void> {
// 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<number> {

View File

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

View File

@@ -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<boolean> {
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<boolean> {
const frontendUrl = config.frontendUrl;
const invitationUrl = `${frontendUrl}/accept-invitation?token=${token}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #2563eb; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9fafb; }
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welkom bij CMDB Editor</h1>
</div>
<div class="content">
<p>Beste ${displayName || 'gebruiker'},</p>
<p>Je bent uitgenodigd om een account aan te maken voor de CMDB Editor applicatie.</p>
<p>Klik op de onderstaande knop om je account te activeren en een wachtwoord in te stellen:</p>
<p style="text-align: center;">
<a href="${invitationUrl}" class="button">Account activeren</a>
</p>
<p>Of kopieer en plak deze link in je browser:</p>
<p style="word-break: break-all; color: #2563eb;">${invitationUrl}</p>
<p><strong>Deze link is 7 dagen geldig.</strong></p>
</div>
<div class="footer">
<p>Zuyderland Medisch Centrum - CMDB Editor</p>
</div>
</div>
</body>
</html>
`;
return this.sendEmail({
to: email,
subject: 'Uitnodiging voor CMDB Editor',
html,
});
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(
email: string,
token: string,
displayName?: string
): Promise<boolean> {
const frontendUrl = config.frontendUrl;
const resetUrl = `${frontendUrl}/reset-password?token=${token}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #dc2626; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9fafb; }
.button { display: inline-block; padding: 12px 24px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
.warning { background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Wachtwoord resetten</h1>
</div>
<div class="content">
<p>Beste ${displayName || 'gebruiker'},</p>
<p>Je hebt een verzoek gedaan om je wachtwoord te resetten.</p>
<p>Klik op de onderstaande knop om een nieuw wachtwoord in te stellen:</p>
<p style="text-align: center;">
<a href="${resetUrl}" class="button">Wachtwoord resetten</a>
</p>
<p>Of kopieer en plak deze link in je browser:</p>
<p style="word-break: break-all; color: #2563eb;">${resetUrl}</p>
<div class="warning">
<p><strong>Let op:</strong> Als je dit verzoek niet hebt gedaan, negeer dan deze email. Je wachtwoord blijft ongewijzigd.</p>
</div>
<p><strong>Deze link is 1 uur geldig.</strong></p>
</div>
<div class="footer">
<p>Zuyderland Medisch Centrum - CMDB Editor</p>
</div>
</div>
</body>
</html>
`;
return this.sendEmail({
to: email,
subject: 'Wachtwoord resetten - CMDB Editor',
html,
});
}
/**
* Send email verification email
*/
async sendEmailVerificationEmail(
email: string,
token: string,
displayName?: string
): Promise<boolean> {
const frontendUrl = config.frontendUrl;
const verifyUrl = `${frontendUrl}/verify-email?token=${token}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #059669; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9fafb; }
.button { display: inline-block; padding: 12px 24px; background-color: #059669; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>E-mailadres verifiëren</h1>
</div>
<div class="content">
<p>Beste ${displayName || 'gebruiker'},</p>
<p>Bedankt voor het aanmaken van je account. Verifieer je e-mailadres door op de onderstaande knop te klikken:</p>
<p style="text-align: center;">
<a href="${verifyUrl}" class="button">E-mailadres verifiëren</a>
</p>
<p>Of kopieer en plak deze link in je browser:</p>
<p style="word-break: break-all; color: #2563eb;">${verifyUrl}</p>
</div>
<div class="footer">
<p>Zuyderland Medisch Centrum - CMDB Editor</p>
</div>
</div>
</body>
</html>
`;
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[^>]*>.*?<\/style>/gis, '')
.replace(/<script[^>]*>.*?<\/script>/gis, '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Check if email service is configured
*/
isConfigured(): boolean {
return this.isConfigured;
}
}
export const emailService = new EmailService();

View File

@@ -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<Buffer> {
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<string> {
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<string> {
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();

View File

@@ -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<string, ReferenceValue> | null = null;
// Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue)
private referenceObjectCache: Map<string, ReferenceValue> = new Map();
// Pending requests cache: prevents duplicate API calls for the same object (key: objectId -> Promise<ReferenceValue>)
private pendingReferenceRequests: Map<string, Promise<ReferenceValue | null>> = 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<string, string> {
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<T>(
endpoint: string,
options: RequestInit = {}
options: RequestInit = {},
retryCount: number = 0
): Promise<T> {
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<T>(endpoint, options, retryCount + 1);
}
throw error;
}
return response.json() as Promise<T>;
} 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<T>(endpoint, options, retryCount + 1);
}
logger.error(`Jira API request failed: ${endpoint}`, error);
throw error;
}
@@ -285,20 +318,118 @@ class JiraAssetsService {
attrSchema?: Map<number, string>
): string | null {
const attr = this.getAttributeByName(obj, attributeName, attrSchema);
// 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})`);
}
// 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)
private getAttributeValueById(
@@ -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<number, string>): 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<string, unknown>;
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<string, unknown>;
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,22 +699,132 @@ 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<ReferenceValue | null> => {
// 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<JiraAssetsObject>(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<number, string> | 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;
}
// 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<ReferenceValue | null> => {
if (!value.value) {
return null;
}
try {
// Try to fetch the referenced object by its key or ID
// First try as object key (most common)
@@ -455,16 +836,29 @@ class JiraAssetsService {
try {
refObj = await this.request<JiraAssetsObject>(`/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}`);
// Both failed, continue
}
}
if (refObj) {
// Fetch attribute schema for the referenced object type to get description
let refObjSchema: Map<number, string> | 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);
@@ -473,8 +867,20 @@ class JiraAssetsService {
return refValue;
}
} 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);
logger.warn(`getReferenceValueWithSchema: Fallback fetch failed for ${value.value}`, 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<number, string>): Promise<ApplicationDetails> {
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<ReferenceValue | null> {
// 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<JiraAssetsObject>(url);
if (!refObj) {
logger.warn(`fetchEnrichedReferenceValue: No object returned for ${objectKey}`);
return null;
}
// Fetch attribute schema for the referenced object type
let refObjSchema: Map<number, string> | 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<boolean> {
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',

View File

@@ -49,7 +49,8 @@ class JiraAssetsClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
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<string, string> {
/**
* 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<string, string> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
/**
* 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<T>(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise<T> {
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<boolean> {
// 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<JiraAssetsObject | null> {
try {
return await this.request<JiraAssetsObject>(`/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<JiraAssetsObject>(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<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`);
response = await this.request<JiraAssetsSearchResponse>(`/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<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`);
response = await this.request<JiraAssetsSearchResponse>(`/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<boolean> {
// 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': {

View File

@@ -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<Role[]> {
const db = getAuthDatabase();
try {
return await db.query<Role>(
'SELECT * FROM roles ORDER BY name'
);
} finally {
await db.close();
}
}
/**
* Get role by ID
*/
async getRoleById(id: number): Promise<Role | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<Role>(
'SELECT * FROM roles WHERE id = ?',
[id]
);
} finally {
await db.close();
}
}
/**
* Get role by name
*/
async getRoleByName(name: string): Promise<Role | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<Role>(
'SELECT * FROM roles WHERE name = ?',
[name]
);
} finally {
await db.close();
}
}
/**
* Create a new role
*/
async createRole(input: CreateRoleInput): Promise<Role> {
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<Role> {
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<Role>(
'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<boolean> {
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<Permission[]> {
const db = getAuthDatabase();
try {
return await db.query<Permission>(
'SELECT * FROM permissions ORDER BY resource, name'
);
} finally {
await db.close();
}
}
/**
* Get permission by ID
*/
async getPermissionById(id: number): Promise<Permission | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<Permission>(
'SELECT * FROM permissions WHERE id = ?',
[id]
);
} finally {
await db.close();
}
}
/**
* Get permission by name
*/
async getPermissionByName(name: string): Promise<Permission | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<Permission>(
'SELECT * FROM permissions WHERE name = ?',
[name]
);
} finally {
await db.close();
}
}
/**
* Get permissions for a role
*/
async getRolePermissions(roleId: number): Promise<Permission[]> {
const db = getAuthDatabase();
try {
return await db.query<Permission>(
`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<boolean> {
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<boolean> {
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<Permission[]> {
const db = getAuthDatabase();
try {
return await db.query<Permission>(
`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<boolean> {
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<boolean> {
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<Role[]> {
const db = getAuthDatabase();
try {
return await db.query<Role>(
`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();

View File

@@ -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<void> {
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<SyncResult> {
// 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 };
}

View File

@@ -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<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Verify a password
*/
async verifyPassword(password: string, hash: string): Promise<boolean> {
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<User> {
const db = getAuthDatabase();
const now = new Date().toISOString();
try {
// Check if email or username already exists
const existingEmail = await db.queryOne<User>(
'SELECT id FROM users WHERE email = ?',
[input.email]
);
if (existingEmail) {
throw new Error('Email already exists');
}
const existingUsername = await db.queryOne<User>(
'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<User>(
'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<User | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<User>(
'SELECT * FROM users WHERE id = ?',
[id]
);
} finally {
await db.close();
}
}
/**
* Get user by email
*/
async getUserByEmail(email: string): Promise<User | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<User>(
'SELECT * FROM users WHERE email = ?',
[email]
);
} finally {
await db.close();
}
}
/**
* Get user by username
*/
async getUserByUsername(username: string): Promise<User | null> {
const db = getAuthDatabase();
try {
return await db.queryOne<User>(
'SELECT * FROM users WHERE username = ?',
[username]
);
} finally {
await db.close();
}
}
/**
* Get all users
*/
async getAllUsers(): Promise<User[]> {
const db = getAuthDatabase();
try {
return await db.query<User>(
'SELECT * FROM users ORDER BY created_at DESC'
);
} finally {
await db.close();
}
}
/**
* Update user
*/
async updateUser(id: number, input: UpdateUserInput): Promise<User> {
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<User>(
'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<User>(
'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<boolean> {
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<void> {
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<string | null> {
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<boolean> {
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<boolean> {
const db = getAuthDatabase();
try {
const user = await db.queryOne<User>(
'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<void> {
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<boolean> {
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<User | null> {
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<User | null> {
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<void> {
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<Array<{ id: number; name: string; description: string | null }>> {
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<boolean> {
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<boolean> {
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();

View File

@@ -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<UserSettings | null> {
const db = getAuthDatabase();
try {
const settings = await db.queryOne<UserSettings>(
'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<UserSettings> {
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<UserSettings> {
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<boolean> {
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<boolean> {
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<string | null> {
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<boolean> {
const settings = await this.getUserSettings(userId);
return settings?.web_search_enabled || false;
}
/**
* Get Tavily API key for user
*/
async getTavilyApiKey(userId: number): Promise<string | null> {
const settings = await this.getUserSettings(userId);
return settings?.tavily_api_key || null;
}
}
export const userSettingsService = new UserSettingsService();

View File

@@ -34,6 +34,7 @@ export interface ApplicationListItem {
id: string;
key: string;
name: string;
searchReference?: string | null; // Search reference for matching
status: ApplicationStatus | null;
applicationFunctions: ReferenceValue[]; // Multiple functions supported
governanceModel: ReferenceValue | null;
@@ -88,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

View File

@@ -0,0 +1,43 @@
/**
* Helper functions for Express request query and params
*/
import { Request } from 'express';
/**
* Get a query parameter as a string, handling both string and string[] types
*/
export function getQueryString(req: Request, key: string): string | undefined {
const value = req.query[key];
if (value === undefined) return undefined;
if (Array.isArray(value)) return value[0] as string;
return value as string;
}
/**
* Get a query parameter as a number, handling both string and string[] types
*/
export function getQueryNumber(req: Request, key: string, defaultValue?: number): number {
const value = getQueryString(req, key);
if (value === undefined) return defaultValue ?? 0;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? (defaultValue ?? 0) : parsed;
}
/**
* Get a query parameter as a boolean
*/
export function getQueryBoolean(req: Request, key: string, defaultValue = false): boolean {
const value = getQueryString(req, key);
if (value === undefined) return defaultValue;
return value === 'true' || value === '1';
}
/**
* Get a route parameter as a string, handling both string and string[] types
*/
export function getParamString(req: Request, key: string): string {
const value = req.params[key];
if (Array.isArray(value)) return value[0] as string;
return value as string;
}

View File

@@ -0,0 +1,58 @@
version: '3.8'
services:
backend:
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest
environment:
- NODE_ENV=production
- PORT=3001
env_file:
- .env.production
volumes:
- backend_data:/app/data
restart: unless-stopped
networks:
- internal
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
depends_on:
- backend
restart: unless-stopped
networks:
- internal
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx_cache:/var/cache/nginx
depends_on:
- frontend
- backend
restart: unless-stopped
networks:
- internal
volumes:
backend_data:
nginx_cache:
networks:
internal:
driver: bridge

View File

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

View File

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

207
docs/AZURE-ACR-DNL-SCOPE.md Normal file
View File

@@ -0,0 +1,207 @@
# Azure Container Registry - Domain Name Label Scope
## Wat is Domain Name Label Scope?
**Domain Name Label (DNL) Scope** is een security feature van Azure Container Registry die voorkomt dat iemand anders dezelfde DNS naam kan gebruiken als je registry wordt verwijderd (subdomain takeover prevention).
## Opties
### 1. **Unsecure** (Aanbevolen voor simpele setup) ⭐
**DNS Format:** `registryname.azurecr.io`
**Voorbeeld:**
- Registry naam: `zuyderlandcmdbacr`
- DNS naam: `zuyderlandcmdbacr.azurecr.io`
**Voordelen:**
- ✅ Eenvoudig en voorspelbaar
- ✅ Geen hash in de naam
- ✅ Makkelijk te onthouden en configureren
**Nadelen:**
- ❌ Minder security (maar meestal voldoende voor interne tools)
**Wanneer gebruiken:**
- ✅ Simpele setup
- ✅ Interne/corporate omgeving
- ✅ Je wilt een voorspelbare DNS naam
---
### 2. **Resource Group Reuse** (Aanbevolen voor security) 🔒
**DNS Format:** `registryname-hash.azurecr.io`
**Voorbeeld:**
- Registry naam: `zuyderlandcmdbacr`
- DNS naam: `zuyderlandcmdbacr-abc123.azurecr.io` (met unieke hash)
**Voordelen:**
- ✅ Extra security layer
- ✅ Consistent binnen resource group
- ✅ Voorkomt subdomain takeover
**Nadelen:**
- ❌ Hash in de naam (minder voorspelbaar)
- ❌ Moet alle configuraties aanpassen met volledige DNS naam
**Wanneer gebruiken:**
- ✅ Productie omgevingen
- ✅ Security is belangrijk
- ✅ Je wilt extra bescherming
---
### 3. **Subscription Reuse**
**DNS Format:** `registryname-hash.azurecr.io` (hash gebaseerd op subscription)
**Wanneer gebruiken:**
- Als je meerdere resource groups hebt binnen dezelfde subscription
- Je wilt consistentie binnen de subscription
---
### 4. **Tenant Reuse**
**DNS Format:** `registryname-hash.azurecr.io` (hash gebaseerd op tenant)
**Wanneer gebruiken:**
- Als je meerdere subscriptions hebt binnen dezelfde tenant
- Je wilt consistentie binnen de tenant
---
### 5. **No Reuse**
**DNS Format:** `registryname-uniquehash.azurecr.io` (altijd unieke hash)
**Wanneer gebruiken:**
- Maximale security vereist
- Je wilt absoluut geen risico op DNS conflicts
---
## 🎯 Aanbeveling voor Jouw Situatie
**Voor Zuyderland CMDB GUI (20 gebruikers, corporate omgeving):**
### Optie A: **"Unsecure"** (Aanbevolen) ⭐
**Waarom:**
- ✅ Eenvoudigste setup
- ✅ Voorspelbare DNS naam
- ✅ Geen configuratie wijzigingen nodig
- ✅ Voldoende voor interne corporate tool
**DNS naam wordt:** `zuyderlandcmdbacr.azurecr.io`
**Configuratie:**
```yaml
# azure-pipelines.yml
acrName: 'zuyderlandcmdbacr' # Simpel, zonder hash
```
---
### Optie B: **"Resource Group Reuse"** (Als je extra security wilt) 🔒
**Waarom:**
- ✅ Extra security layer
- ✅ Voorkomt subdomain takeover
- ✅ Consistent binnen resource group
**DNS naam wordt:** `zuyderlandcmdbacr-abc123.azurecr.io` (met hash)
**⚠️ Belangrijk:** Je moet dan alle configuraties aanpassen!
**Configuratie wijzigingen nodig:**
```yaml
# azure-pipelines.yml
acrName: 'zuyderlandcmdbacr-abc123' # Met hash!
```
```yaml
# docker-compose.prod.acr.yml
image: zuyderlandcmdbacr-abc123.azurecr.io/zuyderland-cmdb-gui/backend:latest
```
```bash
# scripts/build-and-push-azure.sh
REGISTRY="zuyderlandcmdbacr-abc123.azurecr.io" # Met hash!
```
---
## ⚠️ Belangrijke Waarschuwingen
### 1. **Permanente Keuze**
De DNL Scope keuze is **permanent** en kan **niet meer worden gewijzigd** na aanmaken van de registry!
### 2. **Geen Streepjes in Registry Naam**
Als je een DNL Scope met hash gebruikt, mag je **geen streepjes (`-`)** gebruiken in de registry naam, omdat de hash zelf al een streepje gebruikt als scheidingsteken.
**Goed:** `zuyderlandcmdbacr`
**Fout:** `zuyderland-cmdb-acr` (streepje conflict met hash)
### 3. **Configuratie Aanpassingen**
Als je een hash gebruikt, moet je **alle configuraties aanpassen** met de volledige DNS naam (inclusief hash).
---
## 📋 Checklist
### Als je "Unsecure" kiest:
- [ ] Registry naam zonder streepjes (bijv. `zuyderlandcmdbacr`)
- [ ] DNS naam wordt: `zuyderlandcmdbacr.azurecr.io`
- [ ] Geen configuratie wijzigingen nodig
- [ ] Gebruik `acrName: 'zuyderlandcmdbacr'` in pipeline
### Als je "Resource Group Reuse" kiest:
- [ ] Registry naam zonder streepjes (bijv. `zuyderlandcmdbacr`)
- [ ] Noteer de volledige DNS naam na aanmaken (met hash)
- [ ] Pas `azure-pipelines.yml` aan met volledige DNS naam
- [ ] Pas `docker-compose.prod.acr.yml` aan met volledige DNS naam
- [ ] Pas `scripts/build-and-push-azure.sh` aan met volledige DNS naam
---
## 🔍 DNS Naam Vinden
Na het aanmaken van de ACR, vind je de DNS naam:
**Via Azure Portal:**
1. Ga naar je Container Registry
2. Klik op **"Overview"**
3. De **"Login server"** is je DNS naam
**Via Azure CLI:**
```bash
az acr show --name zuyderlandcmdbacr --query loginServer -o tsv
```
**Output voorbeelden:**
- Unsecure: `zuyderlandcmdbacr.azurecr.io`
- Met hash: `zuyderlandcmdbacr-abc123.azurecr.io`
---
## 💡 Mijn Aanbeveling
**Voor jouw situatie (corporate tool, 20 gebruikers):**
Kies **"Unsecure"** omdat:
1. ✅ Eenvoudigste setup
2. ✅ Geen configuratie wijzigingen nodig
3. ✅ Voldoende security voor interne tool
4. ✅ Voorspelbare DNS naam
Als je later meer security nodig hebt, kun je altijd een nieuwe registry aanmaken met een andere scope (maar dan moet je wel alles migreren).
---
## 📚 Meer Informatie
- [Azure Container Registry DNL Scope Documentation](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-get-started-portal)
- [Subdomain Takeover Prevention](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-security)

View File

@@ -0,0 +1,205 @@
# Azure Container Registry - Role Assignment Permissions Mode
## 🎯 Aanbeveling voor Jouw Situatie
**Voor Zuyderland CMDB GUI (20 gebruikers, corporate tool, productie):**
### ✅ **RBAC Registry Permissions** (Aanbevolen) ⭐
**Waarom:**
- ✅ Eenvoudiger te beheren
- ✅ Voldoende voor jouw use case
- ✅ Minder complexiteit
- ✅ Standaard keuze voor de meeste scenario's
---
## 📊 Opties Vergelijking
### Optie 1: **RBAC Registry Permissions** ⭐ **AANBEVOLEN**
**Hoe het werkt:**
- Permissions worden ingesteld op **registry niveau**
- Alle repositories binnen de registry delen dezelfde permissions
- Gebruikers hebben toegang tot alle repositories of geen
**Voordelen:**
-**Eenvoudig** - Minder complexiteit
-**Makkelijk te beheren** - Eén set permissions voor de hele registry
-**Voldoende voor de meeste scenario's** - Perfect voor jouw situatie
-**Standaard keuze** - Meest gebruikte optie
**Nadelen:**
- ❌ Minder flexibel - Kan niet per repository permissions instellen
- ❌ Alle repositories hebben dezelfde toegang
**Wanneer gebruiken:**
-**Jouw situatie** - 20 gebruikers, corporate tool
- ✅ Kleine tot middelgrote teams
- ✅ Alle repositories hebben dezelfde toegangsvereisten
- ✅ Eenvoudige permission structuur gewenst
**Voorbeeld:**
- Alle developers hebben toegang tot alle repositories
- Alle CI/CD pipelines hebben toegang tot alle repositories
- Geen per-repository verschillen nodig
---
### Optie 2: **RBAC Registry + ABAC Repository Permissions**
**Hoe het werkt:**
- Permissions op **registry niveau** (RBAC)
- **Extra** permissions op **repository niveau** (ABAC - Attribute-Based Access Control)
- Kan per repository verschillende permissions instellen
**Voordelen:**
-**Flexibeler** - Per repository permissions mogelijk
-**Granular control** - Verschillende teams kunnen verschillende repositories hebben
-**Enterprise features** - Voor complexe organisaties
**Nadelen:**
-**Complexer** - Meer configuratie nodig
-**Moeilijker te beheren** - Meerdere permission levels
-**Meer overhead** - Meer tijd nodig voor setup en onderhoud
**Wanneer gebruiken:**
- ✅ Grote organisaties met meerdere teams
- ✅ Verschillende repositories hebben verschillende toegangsvereisten
- ✅ Compliance requirements die granular control vereisen
- ✅ Multi-tenant scenarios
**Voorbeeld:**
- Team A heeft alleen toegang tot repository A
- Team B heeft alleen toegang tot repository B
- CI/CD pipeline heeft toegang tot alle repositories
- Externe partners hebben alleen toegang tot specifieke repositories
---
## 🔍 Jouw Situatie Analyse
**Jouw setup:**
- 2 repositories: `zuyderland-cmdb-gui/backend` en `zuyderland-cmdb-gui/frontend`
- 20 gebruikers (klein team)
- Corporate tool (interne gebruikers)
- Productie omgeving
**Permission vereisten:**
- ✅ Alle teamleden hebben toegang tot beide repositories
- ✅ CI/CD pipeline heeft toegang tot beide repositories
- ✅ Geen per-repository verschillen nodig
- ✅ Eenvoudige beheer gewenst
**Conclusie:****RBAC Registry Permissions is perfect!**
---
## 📋 Checklist: Welke Keuze?
### Kies **RBAC Registry Permissions** als:
- [x] Je <50 gebruikers hebt ✅
- [x] Alle repositories dezelfde toegang hebben ✅
- [x] Je eenvoudige beheer wilt ✅
- [x] Je geen per-repository verschillen nodig hebt ✅
- [x] Je een klein tot middelgroot team hebt ✅
**→ Jouw situatie: ✅ Kies RBAC Registry Permissions!**
### Kies **RBAC Registry + ABAC Repository Permissions** als:
- [ ] Je >100 gebruikers hebt
- [ ] Verschillende repositories verschillende toegang nodig hebben
- [ ] Je granular control nodig hebt
- [ ] Je multi-tenant scenario hebt
- [ ] Je compliance requirements hebt die granular control vereisen
---
## 🔄 Kan Ik Later Wisselen?
**⚠️ Belangrijk:**
- Deze keuze is **permanent** en kan **niet meer worden gewijzigd** na aanmaken van de registry!
- Als je later ABAC nodig hebt, moet je een nieuwe registry aanmaken
**Aanbeveling:**
- Start met **RBAC Registry Permissions** (eenvoudigst)
- Als je later granular control nodig hebt, overweeg dan een nieuwe registry met ABAC
- Voor jouw situatie is RBAC Registry Permissions voldoende
---
## 💡 Permission Rollen (RBAC Registry Permissions)
Met RBAC Registry Permissions kun je deze rollen toewijzen:
### **AcrPull** (Lezen)
- Images pullen
- Voor: Developers, CI/CD pipelines
### **AcrPush** (Schrijven)
- Images pushen
- Voor: CI/CD pipelines, build servers
### **AcrDelete** (Verwijderen)
- Images verwijderen
- Voor: Administrators, cleanup scripts
### **Owner** (Volledig beheer)
- Alles + registry beheer
- Voor: Administrators
**Voor jouw situatie:**
- **Developers**: `AcrPull` (images pullen)
- **CI/CD Pipeline**: `AcrPush` (images pushen)
- **Administrators**: `Owner` (volledig beheer)
---
## 🎯 Mijn Aanbeveling
**Voor Zuyderland CMDB GUI:**
### ✅ **Kies RBAC Registry Permissions** ⭐
**Waarom:**
1.**Eenvoudig** - Minder complexiteit, makkelijker te beheren
2.**Voldoende** - Alle repositories hebben dezelfde toegang (wat je nodig hebt)
3.**Standaard** - Meest gebruikte optie, goed gedocumenteerd
4.**Perfect voor jouw situatie** - 20 gebruikers, 2 repositories, corporate tool
**Je hebt niet nodig:**
- ❌ Per-repository permissions (alle repositories hebben dezelfde toegang)
- ❌ Complexe permission structuur (klein team)
- ❌ Multi-tenant scenarios (corporate tool)
**Setup:**
1. Kies **RBAC Registry Permissions**
2. Wijs rollen toe aan gebruikers/groepen:
- Developers → `AcrPull`
- CI/CD → `AcrPush`
- Admins → `Owner`
**Klaar!**
---
## 📚 Meer Informatie
- [Azure Container Registry RBAC](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-roles)
- [ACR Permissions Best Practices](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-best-practices)
- [ABAC Repository Permissions](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-repository-scoped-permissions)
---
## 🎯 Conclusie
**Kies: RBAC Registry Permissions**
Dit is de beste keuze voor:
- ✅ 20 gebruikers
- ✅ Corporate tool
- ✅ 2 repositories (backend + frontend)
- ✅ Eenvoudige beheer gewenst
- ✅ Alle repositories hebben dezelfde toegang
Je kunt altijd later een nieuwe registry aanmaken met ABAC als je granular control nodig hebt, maar voor jouw situatie is dat niet nodig.

246
docs/AZURE-ACR-PRICING.md Normal file
View File

@@ -0,0 +1,246 @@
# Azure Container Registry - Pricing Plan Keuze
## 🎯 Aanbeveling voor Jouw Situatie
**Voor Zuyderland CMDB GUI (20 gebruikers, corporate tool, productie):**
### ✅ **Basic SKU** (Aanbevolen) ⭐
**Waarom:**
- ✅ Voldoende storage (10GB) voor meerdere versies
- ✅ Goedkoop (~€5/maand)
- ✅ Alle features die je nodig hebt
- ✅ Perfect voor kleine tot middelgrote teams
---
## 📊 SKU Vergelijking
### Basic SKU (~€5/maand) ⭐ **AANBEVOLEN**
**Inclusief:**
-**10GB storage** - Ruim voldoende voor backend + frontend images met meerdere versies
-**1GB/day webhook throughput** - Voldoende voor CI/CD
-**Unlimited pulls** - Geen extra kosten voor image pulls
-**Admin user enabled** - Voor development/productie
-**RBAC support** - Role-based access control
-**Content trust** - Image signing support
**Limitaties:**
- ❌ Geen geo-replicatie
- ❌ Geen security scanning (vulnerability scanning)
- ❌ Geen content trust storage
**Wanneer gebruiken:**
-**Jouw situatie** - 20 gebruikers, corporate tool
- ✅ Development en productie omgevingen
- ✅ Kleine tot middelgrote teams
- ✅ Budget-conscious deployments
**Voorbeeld kosten:**
- 2 images (backend + frontend)
- ~10 versies per image
- ~500MB per image = ~10GB totaal
- **Kosten: ~€5/maand** (alleen storage, geen extra pull kosten)
---
### Standard SKU (~€20/maand)
**Inclusief (alles van Basic +):**
-**100GB storage** - Voor grote deployments
-**10GB/day webhook throughput** - Voor hoge CI/CD volumes
-**Geo-replicatie** - Images repliceren naar meerdere regio's
-**Content trust storage** - Voor image signing
**Extra features:**
-**Better performance** - Snellere pulls voor geo-replicated images
-**Disaster recovery** - Images beschikbaar in meerdere regio's
**Wanneer gebruiken:**
- ✅ Grote deployments (>50GB images)
- ✅ Multi-region deployments nodig
- ✅ Hoge CI/CD volumes (>1GB/day)
- ✅ Disaster recovery requirements
**Voor jouw situatie:****Niet nodig** - Basic is voldoende
---
### Premium SKU (~€50/maand)
**Inclusief (alles van Standard +):**
-**500GB storage** - Voor zeer grote deployments
-**50GB/day webhook throughput** - Voor enterprise CI/CD
-**Security scanning** - Automatische vulnerability scanning
-**Advanced security features** - Firewall rules, private endpoints
-**Dedicated throughput** - Garantie op performance
**Extra features:**
-**Image vulnerability scanning** - Automatisch scannen op security issues
-**Private endpoints** - Volledig private connectivity
-**Firewall rules** - Network-level security
**Wanneer gebruiken:**
- ✅ Enterprise deployments
- ✅ Security compliance requirements (ISO 27001, etc.)
- ✅ Zeer grote deployments (>100GB)
- ✅ Multi-tenant scenarios
**Voor jouw situatie:****Niet nodig** - Overkill voor 20 gebruikers
---
## 💰 Kosten Breakdown
### Basic SKU (Aanbevolen) ⭐
**Maandelijkse kosten:**
- **Storage**: €0.167 per GB/maand
- **10GB storage**: ~€1.67/maand
- **Base fee**: ~€3-4/maand
- **Totaal**: ~€5/maand
**Voorbeeld voor jouw situatie:**
- Backend image: ~200MB
- Frontend image: ~50MB
- 10 versies per image: ~2.5GB
- **Ruim binnen 10GB limit** ✅
**Jaarlijkse kosten:** ~€60/jaar
---
### Standard SKU
**Maandelijkse kosten:**
- **Storage**: €0.167 per GB/maand (eerste 100GB)
- **100GB storage**: ~€16.70/maand
- **Base fee**: ~€3-4/maand
- **Totaal**: ~€20/maand
**Jaarlijkse kosten:** ~€240/jaar
**Voor jouw situatie:****Te duur** - Je gebruikt maar ~2.5GB
---
### Premium SKU
**Maandelijkse kosten:**
- **Storage**: €0.167 per GB/maand (eerste 500GB)
- **500GB storage**: ~€83.50/maand
- **Base fee**: ~€16.50/maand
- **Totaal**: ~€50-100/maand (afhankelijk van storage)
**Jaarlijkse kosten:** ~€600-1200/jaar
**Voor jouw situatie:****Veel te duur** - Niet nodig
---
## 📈 Wanneer Upgrade naar Standard/Premium?
### Upgrade naar Standard als:
- ✅ Je >50GB images hebt
- ✅ Je multi-region deployment nodig hebt
- ✅ Je >1GB/day webhook throughput nodig hebt
- ✅ Je disaster recovery nodig hebt
### Upgrade naar Premium als:
- ✅ Je security scanning nodig hebt (compliance)
- ✅ Je >100GB images hebt
- ✅ Je private endpoints nodig hebt
- ✅ Je enterprise security features nodig hebt
**Voor jouw situatie:** Start met **Basic**, upgrade later als nodig.
---
## 🔄 Upgrade/Downgrade
**Goed nieuws:**
- ✅ Je kunt altijd upgraden (Basic → Standard → Premium)
- ✅ Je kunt downgraden (Premium → Standard → Basic)
- ⚠️ **Let op**: Bij downgrade verlies je mogelijk data als je over de storage limit gaat
**Aanbeveling:**
- Start met **Basic**
- Monitor storage gebruik
- Upgrade alleen als je echt de extra features nodig hebt
---
## 📋 Checklist: Welke SKU?
### Kies Basic als:
- [x] Je <50GB images hebt ✅
- [x] Je <20 gebruikers hebt ✅
- [x] Je geen geo-replicatie nodig hebt ✅
- [x] Je geen security scanning nodig hebt ✅
- [x] Je budget-conscious bent ✅
**→ Jouw situatie: ✅ Kies Basic!**
### Kies Standard als:
- [ ] Je >50GB images hebt
- [ ] Je multi-region deployment nodig hebt
- [ ] Je disaster recovery nodig hebt
- [ ] Je >1GB/day webhook throughput nodig hebt
### Kies Premium als:
- [ ] Je security scanning nodig hebt (compliance)
- [ ] Je >100GB images hebt
- [ ] Je private endpoints nodig hebt
- [ ] Je enterprise security features nodig hebt
---
## 💡 Mijn Aanbeveling
**Voor Zuyderland CMDB GUI:**
### ✅ **Kies Basic SKU** ⭐
**Waarom:**
1.**Voldoende storage** - 10GB is ruim voldoende voor jouw 2 images met meerdere versies
2.**Kosteneffectief** - ~€5/maand vs €20-50/maand
3.**Alle features die je nodig hebt** - RBAC, content trust, unlimited pulls
4.**Eenvoudig** - Geen complexe configuratie nodig
5.**Upgrade mogelijk** - Je kunt altijd later upgraden als nodig
**Geschatte storage gebruik:**
- Backend: ~200MB × 10 versies = ~2GB
- Frontend: ~50MB × 10 versies = ~0.5GB
- **Totaal: ~2.5GB** (ruim binnen 10GB limit)
**Kosten:**
- **Maandelijks**: ~€5
- **Jaarlijks**: ~€60
- **Kosteneffectief** voor jouw use case
---
## 🎯 Conclusie
**Kies: Basic SKU**
Dit is de beste keuze voor:
- ✅ 20 gebruikers
- ✅ Corporate tool
- ✅ Productie omgeving
- ✅ Budget-conscious
- ✅ Eenvoudige setup
Je kunt altijd later upgraden naar Standard of Premium als je:
- Meer storage nodig hebt
- Geo-replicatie nodig hebt
- Security scanning nodig hebt
---
## 📚 Meer Informatie
- [Azure Container Registry Pricing](https://azure.microsoft.com/en-us/pricing/details/container-registry/)
- [ACR SKU Comparison](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-skus)
- [ACR Storage Limits](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-skus#sku-features-and-limits)

View File

@@ -0,0 +1,287 @@
# Azure Container Registry - Quick Start Guide
Snelstart guide om zelf Azure Container Registry aan te maken en te configureren voor productie.
## 🚀 Stap 1: Azure Container Registry Aanmaken
### Optie A: Met Script (Aanbevolen)
```bash
# Run het script
./scripts/create-acr.sh
# Of met custom parameters:
./scripts/create-acr.sh rg-cmdb-gui zuyderlandcmdbacr westeurope Basic
```
Het script doet automatisch:
- ✅ Checkt of je ingelogd bent bij Azure
- ✅ Maakt resource group aan (als nodig)
- ✅ Controleert of ACR naam beschikbaar is
- ✅ Maakt ACR aan met Basic SKU
- ✅ Toont credentials
- ✅ Test Docker login
### Optie B: Handmatig via Azure CLI
```bash
# Login bij Azure
az login
# Resource group aanmaken
az group create --name rg-cmdb-gui --location westeurope
# Check of naam beschikbaar is
az acr check-name --name zuyderlandcmdbacr
# ACR aanmaken (Basic SKU - ~€5/maand)
az acr create \
--resource-group rg-cmdb-gui \
--name zuyderlandcmdbacr \
--sku Basic \
--admin-enabled true
# Credentials ophalen
az acr credential show --name zuyderlandcmdbacr
```
### Optie C: Via Azure Portal
1. Ga naar [Azure Portal](https://portal.azure.com)
2. Klik **"Create a resource"**
3. Zoek **"Container Registry"**
4. Klik **"Create"**
5. Vul in:
- **Resource group**: `rg-cmdb-gui` (of maak nieuwe)
- **Registry name**: `zuyderlandcmdbacr` (moet uniek zijn, alleen kleine letters en cijfers, **geen streepjes**)
- **Location**: `West Europe`
- **SKU**: `Basic` ⭐ (aanbevolen - ~€5/maand)
- **Domain name label scope**:
- **"Unsecure"** ⭐ (aanbevolen) - DNS naam wordt: `zuyderlandcmdbacr.azurecr.io`
- **"Resource Group Reuse"** (voor extra security) - DNS naam wordt: `zuyderlandcmdbacr-abc123.azurecr.io` (met hash)
- ⚠️ **Let op**: Als je een hash gebruikt, moet je alle configuraties aanpassen met de volledige DNS naam!
- **Role assignment permissions mode**:
- **"RBAC Registry Permissions"** ⭐ (aanbevolen - eenvoudigst)
- **"RBAC Registry + ABAC Repository Permissions"** (alleen als je per-repository permissions nodig hebt)
6. Klik **"Review + create"** → **"Create"**
**💡 Aanbeveling:** Kies **"Unsecure"** voor de eenvoudigste setup. Zie `docs/AZURE-ACR-DNL-SCOPE.md` voor details.
**Noteer je ACR naam!** Je hebt deze nodig voor de volgende stappen.
---
## 🔧 Stap 2: Pipeline Variabelen Aanpassen
Pas `azure-pipelines.yml` aan met jouw ACR naam:
```yaml
variables:
# Pas deze aan naar jouw ACR naam
acrName: 'zuyderlandcmdbacr' # ← Jouw ACR naam hier
repositoryName: 'zuyderland-cmdb-gui'
# Service connection naam (maak je in volgende stap)
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection'
imageTag: '$(Build.BuildId)'
```
**Commit en push:**
```bash
git add azure-pipelines.yml
git commit -m "Configure ACR in pipeline"
git push origin main
```
---
## 🔗 Stap 3: Service Connection Aanmaken in Azure DevOps
Deze connection geeft Azure DevOps toegang tot je ACR.
1. **Ga naar je Azure DevOps project**
2. Klik op **⚙️ Project Settings** (onderaan links)
3. Ga naar **Service connections** (onder Pipelines)
4. Klik op **"New service connection"**
5. Kies **"Docker Registry"**
6. Kies **"Azure Container Registry"**
7. Vul in:
- **Azure subscription**: Selecteer je Azure subscription
- **Azure container registry**: Selecteer je ACR (bijv. `zuyderlandcmdbacr`)
- **Service connection name**: `zuyderland-cmdb-acr-connection`
- **Description**: Optioneel (bijv. "ACR for CMDB GUI")
8. Klik **"Save"**
**✅ Noteer de service connection naam!** Deze moet overeenkomen met `dockerRegistryServiceConnection` in `azure-pipelines.yml`.
---
## 🎯 Stap 4: Pipeline Aanmaken en Run
1. **Ga naar je Azure DevOps project**
2. Klik op **Pipelines** (links in het menu)
3. Klik op **"New pipeline"** of **"Create Pipeline"**
4. Kies **"Azure Repos Git"** (of waar je code staat)
5. Selecteer je repository: **"Zuyderland CMDB GUI"**
6. Kies **"Existing Azure Pipelines YAML file"**
7. Selecteer:
- **Branch**: `main`
- **Path**: `/azure-pipelines.yml`
8. Klik **"Continue"**
9. **Review** de pipeline configuratie
10. Klik **"Run"** om de pipeline te starten
---
## ✅ Stap 5: Verifiëren
### In Azure Portal:
1. Ga naar je **Container Registry** (`zuyderlandcmdbacr`)
2. Klik op **"Repositories"**
3. Je zou moeten zien:
- `zuyderland-cmdb-gui/backend`
- `zuyderland-cmdb-gui/frontend`
4. Klik op een repository om de tags te zien (bijv. `latest`, `123`)
### Via Azure CLI:
```bash
# Lijst repositories
az acr repository list --name zuyderlandcmdbacr
# Lijst tags voor backend
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
# Lijst tags voor frontend
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend
```
---
## 🐳 Stap 6: Images Lokaal Testen (Optioneel)
```bash
# Login bij ACR
az acr login --name zuyderlandcmdbacr
# Pull images
docker pull zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest
docker pull zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest
# Test run (met docker-compose)
docker-compose -f docker-compose.prod.acr.yml pull
docker-compose -f docker-compose.prod.acr.yml up -d
```
---
## 📋 Checklist
- [ ] Azure Container Registry aangemaakt
- [ ] ACR naam genoteerd
- [ ] `azure-pipelines.yml` variabelen aangepast
- [ ] Service Connection aangemaakt in Azure DevOps
- [ ] Pipeline aangemaakt en gerund
- [ ] Images succesvol gebouwd en gepusht
- [ ] Images geverifieerd in Azure Portal
---
## 🔄 Automatische Triggers
De pipeline triggert automatisch bij:
1. **Push naar `main` branch** → Bouwt `latest` tag
2. **Git tags die beginnen met `v*`** → Bouwt versie tag (bijv. `v1.0.0`)
**Voorbeeld:**
```bash
# Tag aanmaken en pushen
git tag v1.0.0
git push origin v1.0.0
# → Pipeline triggert automatisch en bouwt versie 1.0.0
```
---
## 🚨 Troubleshooting
### Pipeline Fails: "Service connection not found"
**Oplossing:**
- Controleer of de service connection naam in `azure-pipelines.yml` overeenkomt met de naam in Azure DevOps
- Ga naar Project Settings → Service connections en verifieer de naam
### Pipeline Fails: "ACR not found"
**Oplossing:**
- Controleer of de `acrName` variabele correct is in `azure-pipelines.yml`
- Verifieer dat de ACR bestaat: `az acr list`
### Pipeline Fails: "Permission denied"
**Oplossing:**
- Controleer of de service connection de juiste permissions heeft
- Verifieer dat je Azure subscription toegang heeft tot de ACR
- Probeer de service connection opnieuw aan te maken
### ACR Naam Niet Beschikbaar
**Oplossing:**
- ACR namen moeten uniek zijn wereldwijd
- Probeer een andere naam:
- `zuyderlandcmdbacr1`
- `zuyderlandcmdbprod`
- `cmdbzuyderlandacr`
- `zuyderlandcmdbgui`
---
## 💰 Kosten & SKU Keuze
**Aanbeveling: Basic SKU** ⭐ (~€5/maand)
**Basic SKU** (Aanbevolen voor jouw situatie):
- ✅ 10GB storage - Ruim voldoende voor backend + frontend met meerdere versies
- ✅ 1GB/day webhook throughput - Voldoende voor CI/CD
- ✅ Unlimited pulls - Geen extra kosten
- ✅ RBAC support - Role-based access control
-**Kosten: ~€5/maand**
**Standard SKU** (~€20/maand):
- 100GB storage
- 10GB/day webhook throughput
- Geo-replicatie
- **Niet nodig voor jouw situatie**
**Premium SKU** (~€50/maand):
- 500GB storage
- Security scanning
- Private endpoints
- **Overkill voor 20 gebruikers**
**Voor jouw situatie (20 gebruikers): Basic is perfect!**
📚 Zie `docs/AZURE-ACR-PRICING.md` voor volledige vergelijking.
---
## 📚 Meer Informatie
- **Volledige ACR Guide**: `docs/AZURE-CONTAINER-REGISTRY.md`
- **Azure DevOps Setup**: `docs/AZURE-DEVOPS-SETUP.md`
- **Deployment Guide**: `docs/PRODUCTION-DEPLOYMENT.md`
---
## 🎯 Volgende Stappen
Nu je images in ACR staan, kun je ze deployen naar:
1. **Azure Container Instances (ACI)** - Eenvoudig, snel
2. **Azure App Service (Container)** - Managed service
3. **Azure Kubernetes Service (AKS)** - Voor complexere setups
4. **VM met Docker Compose** - Volledige controle
Zie `docs/AZURE-DEPLOYMENT-SUMMARY.md` voor deployment opties.

View File

@@ -0,0 +1,279 @@
# Azure App Service Deployment - Stap-voor-Stap Guide 🚀
Complete deployment guide voor Zuyderland CMDB GUI naar Azure App Service.
## 📋 Prerequisites
- Azure CLI geïnstalleerd en geconfigureerd (`az login`)
- Docker images in ACR: `zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest` en `frontend:latest`
- Azure DevOps pipeline werkt (images worden automatisch gebouwd)
---
## 🎯 Quick Start (15 minuten)
### Stap 1: Resource Group
```bash
az group create \
--name rg-cmdb-gui-prod \
--location westeurope
```
### Stap 2: App Service Plan
```bash
az appservice plan create \
--name plan-cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--sku B1 \
--is-linux
```
### Stap 3: Web Apps
```bash
# Backend
az webapp create \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--plan plan-cmdb-gui-prod \
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
# Frontend
az webapp create \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--plan plan-cmdb-gui-prod \
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
```
### Stap 4: ACR Authentication
```bash
# Enable Managed Identity
az webapp identity assign --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
az webapp identity assign --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
# Get Principal IDs
BACKEND_PRINCIPAL_ID=$(az webapp identity show --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod --query principalId -o tsv)
FRONTEND_PRINCIPAL_ID=$(az webapp identity show --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod --query principalId -o tsv)
# Get ACR Resource ID (vervang <acr-resource-group> met jouw ACR resource group)
ACR_ID=$(az acr show --name zdlas --query id -o tsv)
# Grant AcrPull permissions
az role assignment create --assignee $BACKEND_PRINCIPAL_ID --role AcrPull --scope $ACR_ID
az role assignment create --assignee $FRONTEND_PRINCIPAL_ID --role AcrPull --scope $ACR_ID
# Configure container settings
az webapp config container set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \
--docker-registry-server-url https://zdlas.azurecr.io
az webapp config container set \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \
--docker-registry-server-url https://zdlas.azurecr.io
```
### Stap 5: Environment Variabelen
```bash
# Backend (vervang met jouw waarden)
az webapp config appsettings set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
NODE_ENV=production \
PORT=3001 \
JIRA_BASE_URL=https://jira.zuyderland.nl \
JIRA_SCHEMA_ID=your-schema-id \
JIRA_PAT=your-pat-token \
SESSION_SECRET=$(openssl rand -hex 32) \
FRONTEND_URL=https://cmdb-frontend-prod.azurewebsites.net
# Frontend
az webapp config appsettings set \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
VITE_API_URL=https://cmdb-backend-prod.azurewebsites.net/api
```
### Stap 6: Start Apps
```bash
az webapp start --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
az webapp start --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
```
### Stap 7: Test
```bash
# Health check
curl https://cmdb-backend-prod.azurewebsites.net/api/health
# Frontend
curl https://cmdb-frontend-prod.azurewebsites.net
```
**🎉 Je applicatie is nu live!**
- Frontend: `https://cmdb-frontend-prod.azurewebsites.net`
- Backend API: `https://cmdb-backend-prod.azurewebsites.net/api`
---
## 🔐 Azure Key Vault Setup (Aanbevolen)
Voor productie: gebruik Azure Key Vault voor secrets.
### Stap 1: Key Vault Aanmaken
```bash
az keyvault create \
--name kv-cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--location westeurope \
--sku standard
```
### Stap 2: Secrets Toevoegen
```bash
az keyvault secret set --vault-name kv-cmdb-gui-prod --name JiraPat --value "your-token"
az keyvault secret set --vault-name kv-cmdb-gui-prod --name SessionSecret --value "$(openssl rand -hex 32)"
az keyvault secret set --vault-name kv-cmdb-gui-prod --name JiraSchemaId --value "your-schema-id"
```
### Stap 3: Grant Access
```bash
az keyvault set-policy \
--name kv-cmdb-gui-prod \
--object-id $BACKEND_PRINCIPAL_ID \
--secret-permissions get list
```
### Stap 4: Configure App Settings met Key Vault References
```bash
az webapp config appsettings set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
JIRA_PAT="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraPat/)" \
SESSION_SECRET="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/SessionSecret/)" \
JIRA_SCHEMA_ID="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraSchemaId/)"
```
---
## 📊 Monitoring Setup
### Application Insights
```bash
# Create Application Insights
az monitor app-insights component create \
--app cmdb-gui-prod \
--location westeurope \
--resource-group rg-cmdb-gui-prod \
--application-type web
# Get Instrumentation Key
INSTRUMENTATION_KEY=$(az monitor app-insights component show \
--app cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--query instrumentationKey -o tsv)
# Configure App Settings
az webapp config appsettings set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
APPINSIGHTS_INSTRUMENTATIONKEY=$INSTRUMENTATION_KEY
```
---
## 🔄 Updates Deployen
### Optie 1: Manual (Eenvoudig)
```bash
# Restart Web Apps (pull nieuwe latest image)
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
az webapp restart --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
```
### Optie 2: Deployment Slots (Zero-Downtime)
```bash
# Create staging slot
az webapp deployment slot create \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--slot staging
# Deploy to staging
az webapp deployment slot swap \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--slot staging \
--target-slot production
```
---
## 🛠️ Troubleshooting
### Check Logs
```bash
# Live logs
az webapp log tail --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
# Download logs
az webapp log download --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod --log-file logs.zip
```
### Check Status
```bash
az webapp show --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod --query state
```
### Restart App
```bash
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
```
---
## 📚 Meer Informatie
- **Deployment Advies**: `docs/DEPLOYMENT-ADVICE.md`
- **Quick Deployment Guide**: `docs/QUICK-DEPLOYMENT-GUIDE.md`
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
---
## ✅ Checklist
- [ ] Resource Group aangemaakt
- [ ] App Service Plan aangemaakt
- [ ] Web Apps aangemaakt
- [ ] ACR authentication geconfigureerd
- [ ] Environment variabelen ingesteld
- [ ] Key Vault geconfigureerd (optioneel)
- [ ] Application Insights ingeschakeld
- [ ] Health checks werken
- [ ] Team geïnformeerd
**Veel succes! 🚀**

View File

@@ -0,0 +1,270 @@
# Azure CLI - Quick Start Guide
## 📍 Waar voer je deze commando's uit?
Je voert deze commando's uit in de **Terminal** (command line) op je computer.
---
## 🖥️ Terminal Openen
### Op macOS (jouw situatie):
1. **Open Terminal:**
- Druk op `Cmd + Space` (Spotlight)
- Typ "Terminal"
- Druk Enter
- Of: Applications → Utilities → Terminal
2. **Of gebruik iTerm2** (als je die hebt geïnstalleerd)
### Op Windows:
- **PowerShell** of **Command Prompt**
- Druk `Win + R`, typ `powershell`, Enter
### Op Linux:
- Open je terminal emulator (bijv. GNOME Terminal, Konsole)
---
## ✅ Stap 1: Check of Azure CLI Geïnstalleerd is
**Voer dit commando uit in de terminal:**
```bash
az --version
```
**Als je een versie ziet** (bijv. `azure-cli 2.50.0`): ✅ Azure CLI is geïnstalleerd, ga door naar Stap 2.
**Als je een foutmelding krijgt** (bijv. `command not found`): ❌ Azure CLI is niet geïnstalleerd, zie installatie hieronder.
---
## 📥 Stap 2: Azure CLI Installeren (als nodig)
### Op macOS:
**Optie A: Met Homebrew (Aanbevolen)**
```bash
# Installeer Homebrew (als je die nog niet hebt)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Installeer Azure CLI
brew install azure-cli
```
**Optie B: Met Installer**
1. Download: https://aka.ms/installazureclimac
2. Open het `.pkg` bestand
3. Volg de installatie wizard
**Optie C: Met pip (Python)**
```bash
pip3 install azure-cli
```
### Op Windows:
1. Download: https://aka.ms/installazurecliwindows
2. Run de `.msi` installer
3. Volg de installatie wizard
### Op Linux:
```bash
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
```
**Na installatie, check opnieuw:**
```bash
az --version
```
---
## 🔐 Stap 3: Login bij Azure
**Voer dit commando uit:**
```bash
az login
```
**Wat gebeurt er:**
1. Je browser opent automatisch
2. Log in met je Azure account (hetzelfde account dat je gebruikt voor Azure Portal)
3. Na succesvol inloggen, sluit je de browser
4. De terminal toont je subscriptions
**Als browser niet opent automatisch:**
- Je krijgt een code en URL in de terminal
- Kopieer de code
- Open de URL in je browser
- Voer de code in
**Verwachte output:**
```
[
{
"cloudName": "AzureCloud",
"id": "12345678-1234-1234-1234-123456789012",
"name": "Subscription Name",
"state": "Enabled",
...
}
]
```
**✅ Je bent nu ingelogd!**
---
## 🔍 Stap 4: Haal ACR Credentials Op
**Voer dit commando uit:**
```bash
az acr credential show --name zdlas
```
**Verwachte output:**
```json
{
"passwords": [
{
"name": "password",
"value": "abc123xyz..."
},
{
"name": "password2",
"value": "def456uvw..."
}
],
"username": "zdlas"
}
```
**Noteer:**
- **Username**: `zdlas` (of wat er staat)
- **Password**: Gebruik `passwords[0].value` (de eerste password)
**⚠️ Belangrijk:** Deze credentials zijn gevoelig! Deel ze niet en gebruik ze alleen voor de service connection.
---
## 📋 Complete Stappen in Terminal
**Hier is de complete reeks commando's:**
```bash
# 1. Check of Azure CLI geïnstalleerd is
az --version
# 2. Login bij Azure (opent browser)
az login
# 3. Haal ACR credentials op
az acr credential show --name zdlas
```
**Kopieer de output** en gebruik de `username` en `passwords[0].value` in Azure DevOps.
---
## 🔄 Alternatief: Via Azure Portal (Zonder Azure CLI)
**Als je Azure CLI niet wilt installeren, kun je credentials ook via Azure Portal ophalen:**
1. **Ga naar Azure Portal**: https://portal.azure.com
2. **Ga naar je Container Registry**: Zoek naar `zdlas`
3. **Klik op "Access keys"** (links in het menu)
4. **Je ziet:**
- **Login server**: `zdlas.azurecr.io`
- **Username**: `zdlas` (of admin username)
- **Password**: Klik op "Show" naast password om het te zien
- **Password2**: Alternatieve password
5. **Kopieer de username en password**
**✅ Dit is hetzelfde als `az acr credential show`!**
---
## 🎯 Voor Jouw Situatie (Service Connection)
**Gebruik deze credentials in Azure DevOps:**
1. **In de service connection wizard:**
- Kies "Docker Registry" → "Others"
- **Docker Registry**: `zdlas.azurecr.io`
- **Docker ID**: `zdlas` (of de username uit de output)
- **Docker Password**: `passwords[0].value` (uit de output)
- **Service connection name**: `zuyderland-cmdb-acr-connection`
2. **Save**
---
## 🚨 Troubleshooting
### "az: command not found"
**Oplossing:** Azure CLI is niet geïnstalleerd
- Installeer Azure CLI (zie Stap 2 hierboven)
- Of gebruik Azure Portal alternatief (zie hierboven)
### "az login" opent geen browser
**Oplossing:**
- Kopieer de code en URL uit de terminal
- Open de URL handmatig in je browser
- Voer de code in
### "Subscription not found" of "Access denied"
**Oplossing:**
- Check of je ingelogd bent met het juiste Azure account
- Check of je toegang hebt tot de subscription waar de ACR staat
- Probeer: `az account list` om je subscriptions te zien
- Selecteer de juiste subscription: `az account set --subscription "Subscription Name"`
### "ACR not found"
**Oplossing:**
- Check of de ACR naam correct is: `zdlas`
- Check of je toegang hebt tot de ACR
- Probeer: `az acr list` om alle ACR's te zien
---
## 💡 Tips
1. **Azure CLI blijft ingelogd** - Je hoeft niet elke keer `az login` te doen
2. **Check je subscription** - Als je meerdere subscriptions hebt: `az account show`
3. **Wissel subscription** - `az account set --subscription "Subscription Name"`
4. **Logout** - `az logout` (als je klaar bent)
---
## 📚 Meer Informatie
- [Azure CLI Installatie](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
- [Azure CLI Login](https://learn.microsoft.com/en-us/cli/azure/authenticate-azure-cli)
- [ACR Credentials](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication)
---
## 🎯 Quick Reference
**Terminal openen:**
- macOS: `Cmd + Space` → "Terminal"
**Azure CLI commando's:**
```bash
az --version # Check installatie
az login # Login bij Azure
az acr credential show --name zdlas # Haal credentials op
```
**Azure Portal alternatief:**
- Portal → Container Registry → Access keys
**Klaar!** 🚀

View File

@@ -0,0 +1,451 @@
# Azure Container Registry - Docker Images Build & Push Guide
Deze guide beschrijft hoe je Docker images bouwt en naar Azure Container Registry (ACR) pusht voor de Zuyderland CMDB GUI applicatie.
## 📋 Inhoudsopgave
1. [Azure Container Registry Setup](#azure-container-registry-setup)
2. [Lokale Build & Push](#lokale-build--push)
3. [Azure DevOps Pipeline](#azure-devops-pipeline)
4. [Docker Compose Configuration](#docker-compose-configuration)
5. [Best Practices](#best-practices)
---
## 🔧 Azure Container Registry Setup
### 1. Azure Container Registry Aanmaken
Als je nog geen ACR hebt, maak er een aan via Azure Portal of Azure CLI:
```bash
# Resource group (als nog niet bestaat)
az group create --name rg-cmdb-gui --location westeurope
# Azure Container Registry aanmaken
az acr create \
--resource-group rg-cmdb-gui \
--name zuyderlandcmdbacr \
--sku Basic \
--admin-enabled true
```
**ACR SKU Opties:**
- **Basic**: Geschikt voor development/test (~€5/maand)
- **Standard**: Voor productie met geo-replicatie (~€20/maand)
- **Premium**: Voor enterprise met security features (~€50/maand)
### 2. Registry URL
Na aanmaken is je registry beschikbaar op:
```
<acr-name>.azurecr.io
```
Bijvoorbeeld: `zuyderlandcmdbacr.azurecr.io`
### 3. Authentication
ACR ondersteunt meerdere authenticatiemethoden:
**A) Admin Credentials (Eenvoudig, voor development)**
```bash
# Admin credentials ophalen
az acr credential show --name zuyderlandcmdbacr
# Login met Docker
az acr login --name zuyderlandcmdbacr
# OF
docker login zuyderlandcmdbacr.azurecr.io -u <admin-username> -p <admin-password>
```
**B) Azure Service Principal (Aanbevolen voor CI/CD)**
```bash
# Service Principal aanmaken
az ad sp create-for-rbac --name "zuyderland-cmdb-acr-sp" --role acrpull --scopes /subscriptions/<subscription-id>/resourceGroups/rg-cmdb-gui/providers/Microsoft.ContainerRegistry/registries/zuyderlandcmdbacr
# Gebruik de output credentials in CI/CD
```
**C) Managed Identity (Best voor Azure services)**
- Gebruik Managed Identity voor Azure DevOps, App Service, etc.
- Configureer via Azure Portal → ACR → Access Control (IAM)
---
## 🐳 Lokale Build & Push
### Optie 1: Met Script (Aanbevolen)
Gebruik het `build-and-push-azure.sh` script:
```bash
# Maak script uitvoerbaar
chmod +x scripts/build-and-push-azure.sh
# Build en push (gebruikt 'latest' als versie)
./scripts/build-and-push-azure.sh
# Build en push met specifieke versie
./scripts/build-and-push-azure.sh 1.0.0
```
**Environment Variables:**
```bash
export ACR_NAME="zuyderlandcmdbacr"
export REPO_NAME="zuyderland-cmdb-gui"
./scripts/build-and-push-azure.sh 1.0.0
```
### Optie 2: Handmatig met Docker Commands
```bash
# Login
az acr login --name zuyderlandcmdbacr
# Set variabelen
ACR_NAME="zuyderlandcmdbacr"
REGISTRY="${ACR_NAME}.azurecr.io"
REPO_NAME="zuyderland-cmdb-gui"
VERSION="1.0.0"
# Build backend
docker build -t ${REGISTRY}/${REPO_NAME}/backend:${VERSION} \
-t ${REGISTRY}/${REPO_NAME}/backend:latest \
-f backend/Dockerfile.prod ./backend
# Build frontend
docker build -t ${REGISTRY}/${REPO_NAME}/frontend:${VERSION} \
-t ${REGISTRY}/${REPO_NAME}/frontend:latest \
-f frontend/Dockerfile.prod ./frontend
# Push images
docker push ${REGISTRY}/${REPO_NAME}/backend:${VERSION}
docker push ${REGISTRY}/${REPO_NAME}/backend:latest
docker push ${REGISTRY}/${REPO_NAME}/frontend:${VERSION}
docker push ${REGISTRY}/${REPO_NAME}/frontend:latest
```
---
## 🚀 Azure DevOps Pipeline
### 1. Service Connection Aanmaken
In Azure DevOps:
1. **Project Settings****Service connections****New service connection**
2. Kies **Docker Registry**
3. Kies **Azure Container Registry**
4. Selecteer je Azure subscription en ACR
5. Geef een naam: `zuyderland-cmdb-acr-connection`
### 2. Pipeline Configuratie
Het project bevat al een `azure-pipelines.yml` bestand. Configureer deze in Azure DevOps:
1. **Pipelines****New pipeline**
2. Kies je repository (Azure Repos)
3. Kies **Existing Azure Pipelines YAML file**
4. Selecteer `azure-pipelines.yml`
5. Review en run
### 3. Pipeline Variabelen Aanpassen
Pas de variabelen in `azure-pipelines.yml` aan naar jouw instellingen:
```yaml
variables:
acrName: 'zuyderlandcmdbacr' # Jouw ACR naam
repositoryName: 'zuyderland-cmdb-gui'
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection'
```
### 4. Automatische Triggers
De pipeline triggert automatisch bij:
- Push naar `main` branch
- Tags die beginnen met `v*` (bijv. `v1.0.0`)
**Handmatig Triggeren:**
```bash
# Tag aanmaken en pushen
git tag v1.0.0
git push origin v1.0.0
```
---
## 📦 Docker Compose Configuration
### Productie Docker Compose met ACR
Maak `docker-compose.prod.acr.yml`:
```yaml
version: '3.8'
services:
backend:
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest
environment:
- NODE_ENV=production
- PORT=3001
env_file:
- .env.production
volumes:
- backend_data:/app/data
restart: unless-stopped
networks:
- internal
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest
depends_on:
- backend
restart: unless-stopped
networks:
- internal
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx_cache:/var/cache/nginx
depends_on:
- frontend
- backend
restart: unless-stopped
networks:
- internal
volumes:
backend_data:
nginx_cache:
networks:
internal:
driver: bridge
```
### Gebruik Specifieke Versies
Voor productie deployments, gebruik specifieke versies:
```yaml
backend:
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:v1.0.0
frontend:
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:v1.0.0
```
### Pull en Deploy
```bash
# Login (als nodig)
az acr login --name zuyderlandcmdbacr
# Pull images
docker-compose -f docker-compose.prod.acr.yml pull
# Deploy
docker-compose -f docker-compose.prod.acr.yml up -d
# Status checken
docker-compose -f docker-compose.prod.acr.yml ps
# Logs bekijken
docker-compose -f docker-compose.prod.acr.yml logs -f
```
---
## 🎯 Best Practices
### 1. Versioning
- **Gebruik semantic versioning**: `v1.0.0`, `v1.0.1`, etc.
- **Tag altijd als `latest`**: Voor development/CI/CD
- **Productie**: Gebruik specifieke versies, nooit `latest`
```bash
# Tag met versie
git tag v1.0.0
git push origin v1.0.0
# Build met versie
./scripts/build-and-push-azure.sh 1.0.0
```
### 2. Security
- **Admin credentials uitschakelen** in productie (gebruik Service Principal)
- **Enable Content Trust** voor image signing (optioneel)
- **Scan images** voor vulnerabilities (Azure Security Center)
```bash
# Admin uitschakelen
az acr update --name zuyderlandcmdbacr --admin-enabled false
```
### 3. Image Cleanup
ACR heeft een retention policy voor oude images:
```bash
# Retention policy instellen (bijv. laatste 10 tags behouden)
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend --orderby time_desc --top 10
# Oude tags verwijderen (handmatig of via policy)
az acr repository delete --name zuyderlandcmdbacr --image zuyderland-cmdb-gui/backend:old-tag
```
### 4. Multi-Stage Builds
De `Dockerfile.prod` bestanden gebruiken al multi-stage builds voor kleinere images.
### 5. Build Cache
Voor snellere builds, gebruik build cache:
```bash
# Build met cache
docker build --cache-from zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \
-t zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:new-tag \
-f backend/Dockerfile.prod ./backend
```
---
## 🔍 Troubleshooting
### Authentication Issues
```bash
# Check Azure login
az account show
# Re-login
az login
az acr login --name zuyderlandcmdbacr
# Check Docker login
cat ~/.docker/config.json
```
### Build Errors
```bash
# Build met verbose output
docker build --progress=plain -t test-image -f backend/Dockerfile.prod ./backend
# Check lokale images
docker images | grep zuyderland-cmdb-gui
```
### Push Errors
```bash
# Check ACR connectivity
az acr check-health --name zuyderlandcmdbacr
# Check repository exists
az acr repository list --name zuyderlandcmdbacr
# View repository tags
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
```
### Azure DevOps Pipeline Errors
- Check **Service Connection** permissions
- Verify **ACR naam** in pipeline variables
- Check **Dockerfile paths** zijn correct
- Review pipeline logs in Azure DevOps
---
## 📝 Usage Examples
### Eenvoudige Workflow
```bash
# 1. Code aanpassen en committen
git add .
git commit -m "Update feature"
git push origin main
# 2. Build en push naar ACR
./scripts/build-and-push-azure.sh
# 3. Deploy (op productie server)
az acr login --name zuyderlandcmdbacr
docker-compose -f docker-compose.prod.acr.yml pull
docker-compose -f docker-compose.prod.acr.yml up -d
```
### Versioned Release
```bash
# 1. Tag release
git tag v1.0.0
git push origin v1.0.0
# 2. Build en push met versie
./scripts/build-and-push-azure.sh 1.0.0
# 3. Update docker-compose met versie
# Edit docker-compose.prod.acr.yml: image: ...backend:v1.0.0
# 4. Deploy
docker-compose -f docker-compose.prod.acr.yml pull
docker-compose -f docker-compose.prod.acr.yml up -d
```
### Azure DevOps Automated
1. Push code naar `main` → Pipeline triggert automatisch
2. Pipeline bouwt images en pusht naar ACR
3. Deploy handmatig of via release pipeline
---
## 📚 Additional Resources
- [Azure Container Registry Documentation](https://docs.microsoft.com/en-us/azure/container-registry/)
- [Azure DevOps Docker Task](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/build/docker)
- [ACR Best Practices](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-best-practices)
- [Docker Compose Production Guide](./PRODUCTION-DEPLOYMENT.md)
---
## 🔄 Vergelijking: Gitea vs Azure Container Registry
| Feature | Gitea Registry | Azure Container Registry |
|---------|---------------|-------------------------|
| **Kosten** | Gratis (met Gitea) | €5-50/maand (afhankelijk van SKU) |
| **Security** | Basic | Enterprise-grade (RBAC, scanning) |
| **CI/CD** | Gitea Actions | Azure DevOps, GitHub Actions |
| **Geo-replicatie** | Nee | Ja (Standard/Premium) |
| **Image Scanning** | Nee | Ja (Azure Security Center) |
| **Integratie** | Gitea ecosystem | Azure ecosystem (App Service, AKS, etc.) |
**Aanbeveling:**
- **Development/Test**: Gitea Registry (gratis, eenvoudig)
- **Productie**: Azure Container Registry (security, enterprise features)

310
docs/AZURE-DEVOPS-SETUP.md Normal file
View File

@@ -0,0 +1,310 @@
# Azure DevOps Setup - Stap voor Stap Guide
Nu je code in Azure DevOps staat, volg deze stappen om Docker images automatisch te bouwen en naar Azure Container Registry te pushen.
## 📋 Stappenplan
### Stap 1: Azure Container Registry Aanmaken
Als je nog geen Azure Container Registry hebt:
**Optie A: Met Script (Aanbevolen) 🚀**
```bash
# Run het script (interactief)
./scripts/create-acr.sh
# Of met custom parameters:
./scripts/create-acr.sh rg-cmdb-gui zuyderlandcmdbacr westeurope Basic
```
Het script doet automatisch:
- ✅ Checkt of je ingelogd bent bij Azure
- ✅ Maakt resource group aan (als nodig)
- ✅ Controleert of ACR naam beschikbaar is
- ✅ Maakt ACR aan met Basic SKU
- ✅ Toont credentials
- ✅ Test Docker login
**Optie B: Via Azure CLI (Handmatig)**
```bash
# Login bij Azure
az login
# Resource group aanmaken (als nog niet bestaat)
az group create --name rg-cmdb-gui --location westeurope
# Container Registry aanmaken
az acr create \
--resource-group rg-cmdb-gui \
--name zuyderlandcmdbacr \
--sku Basic \
--admin-enabled true
```
**Optie C: Via Azure Portal**
1. Ga naar [Azure Portal](https://portal.azure.com)
2. Klik op **"Create a resource"**
3. Zoek naar **"Container Registry"**
4. Klik **"Create"**
5. Vul in:
- **Resource group**: Kies bestaande of maak nieuwe (bijv. `rg-cmdb-gui`)
- **Registry name**: Bijv. `zuyderlandcmdbacr` (moet uniek zijn, alleen kleine letters en cijfers)
- **Location**: `West Europe` (of gewenste regio)
- **SKU**: `Basic` (voor development/test) of `Standard` (voor productie)
6. Klik **"Review + create"** → **"Create"**
**Noteer je ACR naam!** Je hebt deze nodig in de volgende stappen.
**📚 Zie `docs/AZURE-ACR-QUICKSTART.md` voor een complete quick-start guide.**
---
### Stap 2: Service Connection Aanmaken in Azure DevOps
Deze connection geeft Azure DevOps toegang tot je Azure Container Registry.
1. **Ga naar je Azure DevOps project**
2. Klik op **⚙️ Project Settings** (onderaan links)
3. Ga naar **Service connections** (onder Pipelines)
4. Klik op **"New service connection"**
5. Kies **"Docker Registry"**
6. Kies **"Azure Container Registry"**
7. Vul in:
- **Azure subscription**: Selecteer je Azure subscription
- **Azure container registry**: Selecteer je ACR (bijv. `zuyderlandcmdbacr`)
- **Service connection name**: Bijv. `zuyderland-cmdb-acr-connection`
- **Description**: Optioneel
8. Klik **"Save"**
**✅ Noteer de service connection naam!** Je hebt deze nodig voor de pipeline.
---
### Stap 3: Pipeline Variabelen Aanpassen
Pas de `azure-pipelines.yml` aan naar jouw instellingen:
1. **Open** `azure-pipelines.yml` in je repository
2. **Pas de variabelen aan** (regel 15-20):
```yaml
variables:
# Pas deze aan naar jouw ACR naam
acrName: 'zuyderlandcmdbacr' # ← Jouw ACR naam hier
repositoryName: 'zuyderland-cmdb-gui'
# Pas deze aan naar de service connection naam die je net hebt gemaakt
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' # ← Jouw service connection naam
imageTag: '$(Build.BuildId)'
```
3. **Commit en push** de wijzigingen:
```bash
git add azure-pipelines.yml
git commit -m "Configure Azure DevOps pipeline"
git push origin main
```
---
### Stap 4: Pipeline Aanmaken in Azure DevOps
1. **Ga naar je Azure DevOps project**
2. Klik op **Pipelines** (links in het menu)
3. Klik op **"New pipeline"** of **"Create Pipeline"**
4. Kies **"Azure Repos Git"** (of waar je code staat)
5. Selecteer je repository: **"Zuyderland CMDB GUI"** (of jouw repo naam)
6. Kies **"Existing Azure Pipelines YAML file"**
7. Selecteer:
- **Branch**: `main`
- **Path**: `/azure-pipelines.yml`
8. Klik **"Continue"**
9. **Review** de pipeline configuratie
10. Klik **"Run"** om de pipeline te starten
---
### Stap 5: Pipeline Uitvoeren
De pipeline start automatisch en zal:
1. ✅ Code uitchecken
2. ✅ Backend Docker image bouwen
3. ✅ Frontend Docker image bouwen
4. ✅ Images naar Azure Container Registry pushen
**Je kunt de voortgang volgen:**
- Klik op de running pipeline
- Bekijk de logs per stap
- Bij success zie je de image URLs
**Verwachte output:**
```
Backend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:123
Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123
```
---
### Stap 6: Images Verifiëren
**In Azure Portal:**
1. Ga naar je **Container Registry** (`zuyderlandcmdbacr`)
2. Klik op **"Repositories"**
3. Je zou moeten zien:
- `zuyderland-cmdb-gui/backend`
- `zuyderland-cmdb-gui/frontend`
4. Klik op een repository om de tags te zien (bijv. `latest`, `123`)
**Via Azure CLI:**
```bash
# Lijst repositories
az acr repository list --name zuyderlandcmdbacr
# Lijst tags voor backend
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
# Lijst tags voor frontend
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend
```
---
## 🚀 Automatische Triggers
De pipeline triggert automatisch bij:
1. **Push naar `main` branch** → Bouwt `latest` tag
2. **Git tags die beginnen met `v*`** → Bouwt versie tag (bijv. `v1.0.0`)
**Voorbeeld:**
```bash
# Tag aanmaken en pushen
git tag v1.0.0
git push origin v1.0.0
# → Pipeline triggert automatisch en bouwt versie 1.0.0
```
---
## 🔧 Troubleshooting
### Pipeline Fails: "Service connection not found"
**Oplossing:**
- Controleer of de service connection naam in `azure-pipelines.yml` overeenkomt met de naam in Azure DevOps
- Ga naar Project Settings → Service connections en verifieer de naam
### Pipeline Fails: "ACR not found"
**Oplossing:**
- Controleer of de `acrName` variabele correct is in `azure-pipelines.yml`
- Verifieer dat de ACR bestaat: `az acr list`
### Pipeline Fails: "Permission denied"
**Oplossing:**
- Controleer of de service connection de juiste permissions heeft
- Verifieer dat je Azure subscription toegang heeft tot de ACR
- Probeer de service connection opnieuw aan te maken
### Images worden niet gepusht
**Oplossing:**
- Check de pipeline logs voor specifieke errors
- Verifieer dat de Docker build succesvol is
- Controleer of de ACR admin-enabled is (voor development)
---
## 📦 Volgende Stappen: Deployment
Nu je images in Azure Container Registry staan, kun je ze deployen:
### Optie 1: Azure App Service
```bash
# Web App aanmaken en configureren
az webapp create --name cmdb-backend --resource-group rg-cmdb-gui --plan plan-cmdb-gui
az webapp config container set --name cmdb-backend --resource-group rg-cmdb-gui \
--docker-custom-image-name zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \
--docker-registry-server-url https://zuyderlandcmdbacr.azurecr.io
```
### Optie 2: Docker Compose op VM
```bash
# Login bij ACR
az acr login --name zuyderlandcmdbacr
# Pull images
docker-compose -f docker-compose.prod.acr.yml pull
# Deploy
docker-compose -f docker-compose.prod.acr.yml up -d
```
### Optie 3: Azure Container Instances (ACI)
```bash
az container create \
--resource-group rg-cmdb-gui \
--name cmdb-backend \
--image zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \
--registry-login-server zuyderlandcmdbacr.azurecr.io \
--registry-username <acr-username> \
--registry-password <acr-password>
```
---
## 📝 Checklist
- [ ] Azure Container Registry aangemaakt
- [ ] Service Connection geconfigureerd in Azure DevOps
- [ ] `azure-pipelines.yml` variabelen aangepast
- [ ] Pipeline aangemaakt en gerund
- [ ] Images succesvol gebouwd en gepusht
- [ ] Images geverifieerd in Azure Portal
- [ ] Automatische triggers getest (push naar main)
---
## 🎯 Quick Reference
**ACR Login:**
```bash
az acr login --name zuyderlandcmdbacr
```
**Images Lijsten:**
```bash
az acr repository list --name zuyderlandcmdbacr
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
```
**Pipeline Handmatig Triggeren:**
- Ga naar Pipelines → Selecteer pipeline → "Run pipeline"
**Pipeline Logs Bekijken:**
- Ga naar Pipelines → Selecteer pipeline → Klik op de run → Bekijk logs per stap
---
## 📚 Meer Informatie
- [Azure Container Registry Docs](https://docs.microsoft.com/en-us/azure/container-registry/)
- [Azure DevOps Pipelines Docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/)
- [Docker Task Reference](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/build/docker)
---
## 💡 Tips
1. **Gebruik versie tags voor productie**: Tag releases met `v1.0.0`, `v1.0.1`, etc.
2. **Monitor pipeline costs**: Azure DevOps geeft gratis build minuten per maand
3. **Enable retention policies**: Configureer ACR om oude images automatisch te verwijderen
4. **Use build caching**: Voor snellere builds bij volgende runs
5. **Set up notifications**: Configureer email/Slack notificaties voor pipeline status

View File

@@ -0,0 +1,250 @@
# Azure DevOps Pipeline - Repository Not Found
## 🔴 Probleem: "No matching repositories were found"
Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline, probeer deze oplossingen:
---
## ✅ Oplossing 1: Check Repository Naam
**Probleem:** De repository naam komt mogelijk niet overeen.
**Oplossing:**
1. **Ga naar Repos** (links in het menu)
2. **Check de exacte repository naam**
- Kijk naar de repository die je hebt gepusht
- Noteer de exacte naam (inclusief hoofdletters/spaties)
3. **In de pipeline wizard:**
- Zoek naar de repository met de exacte naam
- Of probeer verschillende variaties:
- `Zuyderland CMDB GUI`
- `zuyderland-cmdb-gui`
- `ZuyderlandCMDBGUI`
**Jouw repository naam zou moeten zijn:** `Zuyderland CMDB GUI` (met spaties)
---
## ✅ Oplossing 2: Check Repository Type
**In de pipeline wizard, probeer verschillende repository types:**
1. **Azure Repos Git** (als je code in Azure DevOps staat)
- Dit is waarschijnlijk wat je nodig hebt
- Check of je repository hier staat
2. **GitHub** (als je code in GitHub staat)
- Niet van toepassing voor jou
3. **Other Git** (als je code ergens anders staat)
- Niet van toepassing voor jou
**Voor jouw situatie:** Kies **"Azure Repos Git"**
---
## ✅ Oplossing 3: Check Repository Toegang
**Probleem:** Je hebt mogelijk geen toegang tot de repository.
**Oplossing:**
1. **Ga naar Repos** (links in het menu)
2. **Check of je de repository ziet**
- Als je de repository niet ziet, heb je mogelijk geen toegang
3. **Check permissions:**
- Project Settings → Repositories → Security
- Check of je account toegang heeft
---
## ✅ Oplossing 4: Check Project
**Probleem:** Je bent mogelijk in het verkeerde project.
**Oplossing:**
1. **Check het project naam** (bovenaan links)
- Moet zijn: **"JiraAssetsCMDB"**
2. **Als je in een ander project bent:**
- Klik op het project dropdown (bovenaan links)
- Selecteer **"JiraAssetsCMDB"**
3. **Probeer opnieuw** de pipeline aan te maken
---
## ✅ Oplossing 5: Refresh/Herlaad
**Soms helpt een simpele refresh:**
1. **Refresh de browser pagina** (F5 of Cmd+R)
2. **Sluit en open opnieuw** de pipeline wizard
3. **Probeer opnieuw**
---
## ✅ Oplossing 6: Check of Repository Bestaat
**Probleem:** De repository bestaat mogelijk niet in Azure DevOps.
**Oplossing:**
1. **Ga naar Repos** (links in het menu)
2. **Check of je repository zichtbaar is**
- Je zou moeten zien: `Zuyderland CMDB GUI` (of jouw repo naam)
3. **Als de repository niet bestaat:**
- Je moet eerst de code pushen naar Azure DevOps
- Of de repository aanmaken in Azure DevOps
**Check of je code al in Azure DevOps staat:**
- Ga naar Repos → Files
- Je zou je code moeten zien (bijv. `azure-pipelines.yml`, `backend/`, `frontend/`, etc.)
---
## ✅ Oplossing 7: Maak Repository Aan (Als Die Niet Bestaat)
**Als de repository nog niet bestaat in Azure DevOps:**
### Optie A: Push Code naar Bestaande Repository
**Als de repository al bestaat maar leeg is:**
1. **Check de repository URL:**
```
git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI
```
2. **Push je code:**
```bash
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
git push azure main
```
3. **Check in Azure DevOps:**
- Ga naar Repos → Files
- Je zou je code moeten zien
### Optie B: Maak Nieuwe Repository Aan
**Als de repository helemaal niet bestaat:**
1. **Ga naar Repos** (links in het menu)
2. **Klik op "New repository"** of het "+" icoon
3. **Vul in:**
- **Repository name**: `Zuyderland CMDB GUI`
- **Type**: Git
4. **Create**
5. **Push je code:**
```bash
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
git remote add azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI
git push azure main
```
---
## ✅ Oplossing 8: Gebruik "Other Git" Als Workaround
**Als niets werkt, gebruik "Other Git" als tijdelijke oplossing:**
1. **In de pipeline wizard:**
- Kies **"Other Git"** (in plaats van "Azure Repos Git")
2. **Vul in:**
- **Repository URL**: `git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI`
- Of HTTPS: `https://ZuyderlandMedischCentrum@dev.azure.com/ZuyderlandMedischCentrum/JiraAssetsCMDB/_git/Zuyderland%20CMDB%20GUI`
3. **Branch**: `main`
4. **Continue**
**⚠️ Let op:** Dit werkt, maar "Azure Repos Git" is de voorkeursoptie.
---
## 🔍 Diagnose Stappen
**Om te diagnosticeren wat het probleem is:**
### 1. Check of Repository Bestaat
1. Ga naar **Repos** (links in het menu)
2. Check of je `Zuyderland CMDB GUI` ziet
3. Klik erop en check of je code ziet
### 2. Check Repository URL
**In Terminal:**
```bash
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
git remote -v
```
**Je zou moeten zien:**
```
azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI (fetch)
azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI (push)
```
### 3. Check of Code Gepusht is
**In Terminal:**
```bash
git log azure/main --oneline -5
```
**Als je commits ziet:** ✅ Code is gepusht
**Als je een fout krijgt:** ❌ Code is niet gepusht
### 4. Push Code (Als Niet Gepusht)
```bash
git push azure main
```
---
## 💡 Aanbevolen Aanpak
**Probeer in deze volgorde:**
1. ✅ **Check Repos** - Ga naar Repos en check of je repository bestaat
2. ✅ **Check project naam** - Zorg dat je in "JiraAssetsCMDB" project bent
3. ✅ **Refresh pagina** - Soms helpt een simpele refresh
4. ✅ **Push code** - Als repository leeg is, push je code
5. ✅ **Gebruik "Other Git"** - Als workaround
---
## 🎯 Quick Fix (Meest Waarschijnlijk)
**Het probleem is waarschijnlijk dat de repository leeg is of niet bestaat:**
1. **Check in Azure DevOps:**
- Ga naar **Repos** → **Files**
- Check of je code ziet (bijv. `azure-pipelines.yml`)
2. **Als repository leeg is:**
```bash
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
git push azure main
```
3. **Probeer opnieuw** de pipeline aan te maken
---
## 📚 Meer Informatie
- [Azure DevOps Repositories](https://learn.microsoft.com/en-us/azure/devops/repos/)
- [Create Pipeline from Repository](https://learn.microsoft.com/en-us/azure/devops/pipelines/create-first-pipeline)
---
## 🆘 Nog Steeds Problemen?
Als niets werkt:
1. **Check of je in het juiste project bent** (JiraAssetsCMDB)
2. **Check of de repository bestaat** (Repos → Files)
3. **Push je code** naar Azure DevOps
4. **Gebruik "Other Git"** als workaround
**De "Other Git" optie werkt altijd**, ook als de repository niet wordt gevonden in de dropdown.

View File

@@ -0,0 +1,283 @@
# Azure Container Registry - Moet ik dit aanvragen?
## 🤔 Korte Antwoord
**Het hangt af van je deployment strategie:**
1. **Azure App Service (zonder containers)** → ❌ **Geen ACR nodig**
- Direct deployment van code
- Eenvoudiger en goedkoper
- **Aanbevolen voor jouw situatie** (20 gebruikers)
2. **Container-based deployment** → ✅ **ACR nodig** (of alternatief)
- Azure Container Instances (ACI)
- Azure Kubernetes Service (AKS)
- VM met Docker Compose
---
## 📊 Deployment Opties Vergelijking
### Optie 1: Azure App Service (Zonder Containers) ⭐ **AANBEVOLEN**
**Wat je nodig hebt:**
- ✅ Azure App Service Plan (B1) - €15-25/maand
- ✅ Azure Key Vault - €1-2/maand
- ✅ Database (PostgreSQL of SQLite) - €1-30/maand
-**Geen Container Registry nodig!**
**Hoe het werkt:**
- Azure DevOps bouwt je code direct
- Deployt naar App Service via ZIP deploy of Git
- Geen Docker images nodig
**Voordelen:**
- ✅ Eenvoudiger setup
- ✅ Goedkoper (geen ACR kosten)
- ✅ Snellere deployments
- ✅ Managed service (minder onderhoud)
**Nadelen:**
- ❌ Minder flexibel dan containers
- ❌ Platform-specifiek (Azure only)
**Voor jouw situatie:****Dit is de beste optie!**
---
### Optie 2: Container Registry (Als je containers wilt gebruiken)
**Je hebt 3 keuzes voor een registry:**
#### A) Azure Container Registry (ACR) 💰
**Kosten:**
- Basic: ~€5/maand
- Standard: ~€20/maand
- Premium: ~€50/maand
**Voordelen:**
- ✅ Integratie met Azure services
- ✅ Security scanning (Premium)
- ✅ Geo-replicatie (Standard/Premium)
- ✅ RBAC integratie
**Nadelen:**
- ❌ Extra kosten
- ❌ Moet aangevraagd worden bij IT
**Wanneer aanvragen:**
- Als je containers gebruikt in productie
- Als je Azure-native deployment wilt
- Als je security scanning nodig hebt
---
#### B) Gitea Container Registry (Gratis) 🆓
**Kosten:** Gratis (als je al Gitea hebt)
**Voordelen:**
- ✅ Geen extra kosten
- ✅ Al beschikbaar (als Gitea dit ondersteunt)
- ✅ Eenvoudig te gebruiken
**Nadelen:**
- ❌ Minder features dan ACR
- ❌ Geen security scanning
- ❌ Geen geo-replicatie
**Je hebt al:**
- ✅ Script: `scripts/build-and-push.sh`
- ✅ Config: `docker-compose.prod.registry.yml`
- ✅ Documentatie: `docs/GITEA-DOCKER-REGISTRY.md`
**Wanneer gebruiken:**
- ✅ Development/test omgevingen
- ✅ Als je al Gitea hebt met registry enabled
- ✅ Kleine projecten zonder enterprise requirements
---
#### C) Docker Hub (Gratis/Paid)
**Kosten:**
- Free: 1 private repo, unlimited public
- Pro: $5/maand voor unlimited private repos
**Voordelen:**
- ✅ Eenvoudig te gebruiken
- ✅ Gratis voor public images
- ✅ Wereldwijd beschikbaar
**Nadelen:**
- ❌ Rate limits op free tier
- ❌ Minder integratie met Azure
- ❌ Security concerns (voor private data)
**Wanneer gebruiken:**
- Development/test
- Public images
- Als je geen Azure-native oplossing nodig hebt
---
## 🎯 Aanbeveling voor Jouw Situatie
### Scenario 1: Eenvoudige Productie Deployment (Aanbevolen) ⭐
**Gebruik: Azure App Service zonder containers**
**Waarom:**
- ✅ Geen ACR nodig
- ✅ Eenvoudiger en goedkoper
- ✅ Voldoende voor 20 gebruikers
- ✅ Minder complexiteit
**Stappen:**
1. **Niet nodig:** ACR aanvragen
2. **Wel nodig:** Azure App Service Plan aanvragen
3. **Pipeline aanpassen:** Gebruik Azure App Service deployment task in plaats van Docker
**Pipeline aanpassing:**
```yaml
# In plaats van Docker build/push:
- task: AzureWebApp@1
inputs:
azureSubscription: 'your-subscription'
appName: 'cmdb-backend'
package: '$(System.DefaultWorkingDirectory)'
```
---
### Scenario 2: Container-based Deployment
**Als je toch containers wilt gebruiken:**
**Optie A: Gebruik Gitea Registry (als beschikbaar)**
- ✅ Geen aanvraag nodig
- ✅ Gratis
- ✅ Al geconfigureerd in je project
**Optie B: Vraag ACR aan bij IT**
- 📧 Stuur een request naar IT/Infrastructure team
- 📋 Vermeld: "Azure Container Registry - Basic tier voor CMDB GUI project"
- 💰 Budget: ~€5-20/maand (afhankelijk van tier)
**Request Template:**
```
Onderwerp: Azure Container Registry aanvraag - CMDB GUI Project
Beste IT Team,
Voor het Zuyderland CMDB GUI project hebben we een Azure Container Registry nodig
voor het hosten van Docker images.
Details:
- Project: Zuyderland CMDB GUI
- Registry naam: zuyderlandcmdbacr (of zoals jullie naming convention)
- SKU: Basic (voor development/productie)
- Resource Group: rg-cmdb-gui
- Location: West Europe
- Doel: Hosten van backend en frontend Docker images voor productie deployment
Geschatte kosten: €5-20/maand (Basic tier)
Alvast bedankt!
```
---
## 📋 Beslissingsmatrix
| Situatie | Registry Nodig? | Welke? | Kosten |
|----------|----------------|--------|--------|
| **Azure App Service (code deploy)** | ❌ Nee | - | €0 |
| **Gitea Registry beschikbaar** | ✅ Ja | Gitea | €0 |
| **Containers + Azure native** | ✅ Ja | ACR Basic | €5/maand |
| **Containers + Security scanning** | ✅ Ja | ACR Standard | €20/maand |
| **Development/test only** | ✅ Ja | Docker Hub (free) | €0 |
---
## 🚀 Quick Start: Wat Moet Je Nu Doen?
### Als je Azure App Service gebruikt (Aanbevolen):
1.**Geen ACR nodig** - Skip deze stap
2. ✅ Vraag **Azure App Service Plan** aan bij IT
3. ✅ Configureer pipeline voor App Service deployment
4. ✅ Gebruik bestaande `azure-pipelines.yml` maar pas aan voor App Service
### Als je containers gebruikt:
1.**Check eerst:** Is Gitea Container Registry beschikbaar?
- Zo ja → Gebruik Gitea (gratis, al geconfigureerd)
- Zo nee → Vraag ACR Basic aan bij IT
2. ✅ Als je ACR aanvraagt:
- Stuur request naar IT team
- Gebruik request template hierboven
- Wacht op goedkeuring
3. ✅ Configureer pipeline:
- Pas `azure-pipelines.yml` aan met ACR naam
- Maak service connection in Azure DevOps
- Test pipeline
---
## 💡 Mijn Aanbeveling
**Voor jouw situatie (20 gebruikers, interne tool):**
1. **Start met Azure App Service** (zonder containers)
- Eenvoudiger
- Goedkoper
- Voldoende functionaliteit
2. **Als je later containers nodig hebt:**
- Gebruik eerst Gitea Registry (als beschikbaar)
- Vraag ACR aan als Gitea niet voldoet
3. **Vraag ACR alleen aan als:**
- Je security scanning nodig hebt
- Je geo-replicatie nodig hebt
- Je Azure-native container deployment wilt
---
## ❓ Vragen voor IT Team
Als je ACR wilt aanvragen, vraag dan:
1. **Hebben we al een ACR?** (misschien kunnen we die delen)
2. **Wat is de naming convention?** (voor registry naam)
3. **Welke SKU is aanbevolen?** (Basic/Standard/Premium)
4. **Welke resource group gebruiken we?** (best practices)
5. **Zijn er compliance requirements?** (security scanning, etc.)
6. **Heeft Gitea Container Registry?** (gratis alternatief)
---
## 📚 Meer Informatie
- **Azure App Service Deployment**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
- **Gitea Registry**: `docs/GITEA-DOCKER-REGISTRY.md`
- **Azure Container Registry**: `docs/AZURE-CONTAINER-REGISTRY.md`
- **Azure DevOps Setup**: `docs/AZURE-DEVOPS-SETUP.md`
---
## 🎯 Conclusie
**Kort antwoord:**
- **Azure App Service?** → ❌ Geen ACR nodig
- **Containers?** → ✅ ACR nodig (of Gitea/Docker Hub)
- **Aanbeveling:** Start met App Service, vraag ACR later aan als nodig
**Actie:**
1. Beslis: App Service of Containers?
2. Als Containers: Check Gitea Registry eerst
3. Als ACR nodig: Vraag aan bij IT met request template

View File

@@ -0,0 +1,231 @@
# Azure DevOps Service Connection - Authentication Type
## 🎯 Aanbeveling voor Jouw Situatie
**Voor Zuyderland CMDB GUI met Azure Container Registry:**
### ✅ **Service Principal** (Aanbevolen) ⭐
**Waarom:**
- ✅ Werkt altijd en is betrouwbaar
- ✅ Meest ondersteunde optie
- ✅ Eenvoudig te configureren
- ✅ Werkt perfect met Azure Container Registry
- ✅ Geen speciale vereisten
---
## 📊 Opties Vergelijking
### Optie 1: **Service Principal** ⭐ **AANBEVOLEN**
**Hoe het werkt:**
- Azure DevOps maakt automatisch een Service Principal aan in Azure AD
- De Service Principal krijgt toegang tot je Azure Container Registry
- Azure DevOps gebruikt deze credentials om in te loggen bij ACR
**Voordelen:**
-**Eenvoudig** - Azure DevOps doet alles automatisch
-**Betrouwbaar** - Werkt altijd, geen speciale configuratie nodig
-**Veilig** - Credentials worden veilig opgeslagen in Azure DevOps
-**Meest ondersteund** - Standaard optie voor de meeste scenario's
-**Werkt met alle Azure services** - Niet alleen ACR
**Nadelen:**
- ❌ Maakt een Service Principal aan in Azure AD (maar dit is normaal en veilig)
**Wanneer gebruiken:**
-**Jouw situatie** - Azure DevOps Services (cloud) met Azure Container Registry
- ✅ De meeste scenario's
- ✅ Als je eenvoudige, betrouwbare authenticatie wilt
- ✅ Standaard keuze voor nieuwe service connections
**Configuratie:**
- Azure DevOps doet alles automatisch
- Je hoeft alleen je Azure subscription en ACR te selecteren
- Azure DevOps maakt de Service Principal aan en geeft deze de juiste permissions
---
### Optie 2: **Managed Service Identity (MSI)**
**Hoe het werkt:**
- Gebruikt een Managed Identity van Azure DevOps zelf
- Geen credentials nodig - Azure beheert alles
- Werkt alleen als Azure DevOps een Managed Identity heeft
**Voordelen:**
- ✅ Geen credentials te beheren
- ✅ Automatisch geroteerd door Azure
- ✅ Modernere aanpak
**Nadelen:**
-**Werkt alleen met Azure DevOps Server (on-premises)** met Managed Identity
-**Werkt NIET met Azure DevOps Services (cloud)** - Dit is belangrijk!
- ❌ Vereist speciale configuratie
- ❌ Minder flexibel
**Wanneer gebruiken:**
- ✅ Azure DevOps Server (on-premises) met Managed Identity
- ✅ Als je geen credentials wilt beheren
-**NIET voor Azure DevOps Services (cloud)** - Dit werkt niet!
**Voor jouw situatie:****Niet geschikt** - Je gebruikt Azure DevOps Services (cloud), niet on-premises
---
### Optie 3: **Workload Identity Federation**
**Hoe het werkt:**
- Modernere manier zonder secrets
- Gebruikt federated identity (OIDC)
- Azure DevOps krijgt een token van Azure AD zonder credentials op te slaan
**Voordelen:**
- ✅ Geen secrets opgeslagen
- ✅ Modernere, veiligere aanpak
- ✅ Automatisch token management
**Nadelen:**
-**Nog niet volledig ondersteund** voor alle scenario's
- ❌ Kan complexer zijn om te configureren
- ❌ Vereist specifieke Azure AD configuratie
- ❌ Mogelijk niet beschikbaar in alle Azure DevOps organisaties
**Wanneer gebruiken:**
- ✅ Als je de modernste security features wilt
- ✅ Als je organisatie Workload Identity Federation ondersteunt
- ✅ Voor nieuwe projecten waar je geen legacy support nodig hebt
-**Niet aanbevolen als je eenvoudige setup wilt**
**Voor jouw situatie:** ⚠️ **Mogelijk beschikbaar, maar Service Principal is eenvoudiger**
---
## 🔍 Jouw Situatie Analyse
**Jouw setup:**
- ✅ Azure DevOps Services (cloud) - `dev.azure.com`
- ✅ Azure Container Registry - `zdlas.azurecr.io`
- ✅ Eenvoudige setup gewenst
- ✅ Betrouwbare authenticatie nodig
**Conclusie:****Service Principal is perfect!**
**Waarom niet de andere opties:**
-**Managed Service Identity**: Werkt niet met Azure DevOps Services (cloud)
- ⚠️ **Workload Identity Federation**: Mogelijk beschikbaar, maar complexer dan nodig
---
## 📋 Checklist: Welke Keuze?
### Kies **Service Principal** als:
- [x] Je Azure DevOps Services (cloud) gebruikt ✅
- [x] Je eenvoudige setup wilt ✅
- [x] Je betrouwbare authenticatie nodig hebt ✅
- [x] Je standaard, goed ondersteunde optie wilt ✅
- [x] Je Azure Container Registry gebruikt ✅
**→ Jouw situatie: ✅ Kies Service Principal!**
### Kies **Managed Service Identity** als:
- [ ] Je Azure DevOps Server (on-premises) gebruikt
- [ ] Je Managed Identity hebt geconfigureerd
- [ ] Je geen credentials wilt beheren
**→ Jouw situatie: ❌ Niet geschikt**
### Kies **Workload Identity Federation** als:
- [ ] Je de modernste security features wilt
- [ ] Je organisatie dit ondersteunt
- [ ] Je geen legacy support nodig hebt
- [ ] Je bereid bent om extra configuratie te doen
**→ Jouw situatie: ⚠️ Mogelijk, maar niet nodig**
---
## 🔧 Configuratie Stappen (Service Principal)
Wanneer je **Service Principal** kiest:
1. **Selecteer Azure Subscription**
- Kies je Azure subscription uit de dropdown
2. **Selecteer Azure Container Registry**
- Kies je ACR (`zdlas`) uit de dropdown
3. **Service Connection Name**
- Vul in: `zuyderland-cmdb-acr-connection`
- ⚠️ **Belangrijk**: Deze naam moet overeenkomen met `dockerRegistryServiceConnection` in `azure-pipelines.yml`!
4. **Security**
- Azure DevOps maakt automatisch een Service Principal aan
- De Service Principal krijgt automatisch de juiste permissions (AcrPush role)
- Credentials worden veilig opgeslagen in Azure DevOps
5. **Save**
- Klik "Save" of "Verify and save"
- Azure DevOps test automatisch de connection
**✅ Klaar!** Geen extra configuratie nodig.
---
## 🔄 Kan Ik Later Wisselen?
**Ja, maar:**
- Je kunt altijd een nieuwe service connection aanmaken met een ander authentication type
- Je moet dan wel de pipeline variabelen aanpassen
- Service Principal is meestal de beste keuze, dus wisselen is meestal niet nodig
---
## 💡 Mijn Aanbeveling
**Voor Zuyderland CMDB GUI:**
### ✅ **Kies Service Principal** ⭐
**Waarom:**
1.**Werkt perfect** - Standaard optie voor Azure DevOps Services
2.**Eenvoudig** - Azure DevOps doet alles automatisch
3.**Betrouwbaar** - Meest geteste en ondersteunde optie
4.**Veilig** - Credentials worden veilig beheerd door Azure DevOps
5.**Perfect voor jouw situatie** - Cloud Azure DevOps + Azure Container Registry
**Je hebt niet nodig:**
- ❌ Managed Service Identity (werkt niet met cloud Azure DevOps)
- ❌ Workload Identity Federation (complexer dan nodig)
**Setup:**
1. Kies **Service Principal**
2. Selecteer je subscription en ACR
3. Geef een naam: `zuyderland-cmdb-acr-connection`
4. Save
**Klaar!**
---
## 📚 Meer Informatie
- [Azure DevOps Service Connections](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints)
- [Service Principal Authentication](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure)
- [Managed Service Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/)
- [Workload Identity Federation](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#workload-identity-federation)
---
## 🎯 Conclusie
**Kies: Service Principal**
Dit is de beste keuze voor:
- ✅ Azure DevOps Services (cloud)
- ✅ Azure Container Registry
- ✅ Eenvoudige, betrouwbare setup
- ✅ Standaard, goed ondersteunde optie
Je kunt altijd later een andere optie proberen als je dat wilt, maar Service Principal is meestal de beste keuze.

View File

@@ -0,0 +1,237 @@
# Azure DevOps Service Connection - Troubleshooting
## 🔴 Probleem: "Loading Registries..." blijft hangen
Als de Azure Container Registry dropdown blijft laden zonder resultaten, probeer deze oplossingen:
---
## ✅ Oplossing 1: Check Subscription Toegang
**Probleem:** Je hebt mogelijk geen toegang tot de subscription waar de ACR staat.
**Oplossing:**
1. **Check in Azure Portal:**
- Ga naar je Container Registry (`zdlas`)
- Klik op **"Access control (IAM)"**
- Check of je de juiste rol hebt (bijv. Owner, Contributor, of AcrPush)
2. **Check Subscription:**
- Ga naar je Azure Subscription
- Klik op **"Access control (IAM)"**
- Check of je toegang hebt tot de subscription
3. **Probeer opnieuw:**
- Ga terug naar Azure DevOps
- Selecteer de juiste subscription
- Wacht even (kan 10-30 seconden duren)
---
## ✅ Oplossing 2: Refresh/Herlaad de Pagina
**Soms helpt een simpele refresh:**
1. **Refresh de browser pagina** (F5 of Cmd+R)
2. **Of sluit en open opnieuw** de service connection wizard
3. **Probeer opnieuw** de registry te selecteren
---
## ✅ Oplossing 3: Check Resource Group Locatie
**Probleem:** Soms laadt Azure DevOps alleen registries in bepaalde regio's.
**Oplossing:**
1. **Check waar je ACR staat:**
```bash
az acr show --name zdlas --query location -o tsv
```
2. **Check of de subscription toegang heeft tot die regio**
3. **Probeer handmatig de registry naam in te vullen** (zie Oplossing 4)
---
## ✅ Oplossing 4: Handmatig Registry Naam Invoeren
**Als de dropdown niet werkt, kun je handmatig de registry naam invoeren:**
1. **In de service connection wizard:**
- Laat de dropdown leeg (of selecteer "Enter value manually")
- Typ handmatig: `zdlas`
- Of de volledige naam: `zdlas.azurecr.io`
2. **Save en test**
**Let op:** Soms accepteert Azure DevOps alleen de registry naam zonder `.azurecr.io`
---
## ✅ Oplossing 5: Check Service Principal Permissions
**Probleem:** De Service Principal die Azure DevOps probeert aan te maken heeft mogelijk niet de juiste permissions.
**Oplossing:**
1. **Maak handmatig een Service Principal aan:**
```bash
# Login bij Azure
az login
# Maak Service Principal aan
az ad sp create-for-rbac --name "zuyderland-cmdb-acr-sp" \
--role acrpush \
--scopes /subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.ContainerRegistry/registries/zdlas
```
2. **Gebruik deze credentials in Azure DevOps:**
- Kies "Docker Registry" → "Others"
- Vul in:
- Registry URL: `zdlas.azurecr.io`
- Username: (van de Service Principal output)
- Password: (van de Service Principal output)
---
## ✅ Oplossing 6: Gebruik "Others" in plaats van "Azure Container Registry"
**Als de Azure Container Registry optie niet werkt, gebruik dan "Others":**
1. **In de service connection wizard:**
- Kies **"Docker Registry"**
- Kies **"Others"** (in plaats van "Azure Container Registry")
2. **Vul handmatig in:**
- **Docker Registry**: `zdlas.azurecr.io`
- **Docker ID**: (leeg laten of je ACR admin username)
- **Docker Password**: (je ACR admin password)
3. **Haal ACR credentials op:**
```bash
# Login bij Azure
az login
# Haal admin credentials op
az acr credential show --name zdlas
```
Gebruik de `username` en `passwords[0].value` uit de output.
4. **Save en test**
**⚠️ Let op:** Met "Others" moet je handmatig credentials beheren. Service Principal is veiliger, maar dit werkt als tijdelijke oplossing.
---
## ✅ Oplossing 7: Check Browser/Network
**Probleem:** Browser of network issues kunnen de dropdown blokkeren.
**Oplossingen:**
1. **Probeer een andere browser** (Chrome, Firefox, Edge)
2. **Disable browser extensions** (ad blockers, etc.)
3. **Check network connectivity** naar Azure
4. **Probeer incognito/private mode**
---
## ✅ Oplossing 8: Wacht Even en Probeer Later
**Soms is het een tijdelijk Azure issue:**
1. **Wacht 5-10 minuten**
2. **Probeer opnieuw**
3. **Check Azure Status**: https://status.azure.com/
---
## 🔍 Diagnose Stappen
**Om te diagnosticeren wat het probleem is:**
### 1. Check ACR Bestaat en is Toegankelijk
```bash
# Login bij Azure
az login
# Check of ACR bestaat
az acr show --name zdlas
# Check ACR credentials
az acr credential show --name zdlas
# Check ACR permissions
az acr show --name zdlas --query "networkRuleSet" -o table
```
### 2. Check Azure DevOps Subscription Toegang
1. Ga naar Azure Portal
2. Ga naar je Subscription
3. Check "Access control (IAM)"
4. Check of je account toegang heeft
### 3. Check Service Principal Permissions
```bash
# List Service Principals
az ad sp list --display-name "zuyderland-cmdb-acr-sp" -o table
# Check permissions op ACR
az role assignment list --scope /subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.ContainerRegistry/registries/zdlas
```
---
## 💡 Aanbevolen Aanpak
**Probeer in deze volgorde:**
1. ✅ **Refresh de pagina** (Oplossing 2)
2. ✅ **Check subscription toegang** (Oplossing 1)
3. ✅ **Handmatig registry naam invoeren** (Oplossing 4)
4. ✅ **Gebruik "Others" optie** (Oplossing 6) - als tijdelijke oplossing
5. ✅ **Maak handmatig Service Principal** (Oplossing 5) - voor permanente oplossing
---
## 🎯 Quick Fix (Aanbevolen)
**Als de dropdown niet werkt, gebruik deze workaround:**
1. **Kies "Docker Registry" → "Others"**
2. **Vul in:**
- Registry URL: `zdlas.azurecr.io`
- Username: (haal op met `az acr credential show --name zdlas`)
- Password: (haal op met `az acr credential show --name zdlas`)
3. **Service connection name**: `zuyderland-cmdb-acr-connection`
4. **Save**
**Dit werkt altijd, ook als de Azure Container Registry optie niet werkt.**
**Later kun je:**
- De service connection verwijderen
- Opnieuw aanmaken met "Azure Container Registry" optie (als die dan wel werkt)
- Of de "Others" optie behouden (werkt ook prima)
---
## 📚 Meer Informatie
- [Azure DevOps Service Connections Troubleshooting](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints)
- [ACR Access Control](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication)
- [Service Principal Permissions](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-roles)
---
## 🆘 Nog Steeds Problemen?
Als niets werkt:
1. **Check Azure DevOps logs** (als je toegang hebt)
2. **Contact Azure Support** (als je een support plan hebt)
3. **Gebruik "Others" optie** als workaround (werkt altijd)
**De "Others" optie is een volledig werkende oplossing**, alleen iets minder geautomatiseerd dan de Azure Container Registry optie.

142
docs/DATABASE-ACCESS.md Normal file
View File

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

635
docs/DEPLOYMENT-ADVICE.md Normal file
View File

@@ -0,0 +1,635 @@
# Deployment Advies - Zuyderland CMDB GUI 🎯
**Datum:** {{ vandaag }}
**Aanbeveling:** Azure App Service (Basic Tier)
**Geschatte kosten:** ~€20-25/maand
---
## 📊 Analyse van Jouw Situatie
### Jouw Requirements:
-**Managed service** (geen serverbeheer) - **Jouw voorkeur**
-**Interne productie** (niet bedrijfskritisch)
-**20 gebruikers** (kleine team)
-**Downtime acceptabel** (kan zelfs 's avonds/weekend uit)
-**Budget geen probleem** (~€20-25/maand is prima)
-**Monitoring via Elastic stack** (kan geïntegreerd worden)
-**NEN 7510 compliance** (vereist)
-**Updates:** Initieel dagelijks, daarna wekelijks/maandelijks
### Waarom Azure App Service Perfect Past:
1. **Managed Service**
- Geen serverbeheer, SSH, Linux configuratie
- Azure beheert alles (updates, security patches, scaling)
- Perfect voor jouw voorkeur: "liever niet als het niet hoeft"
2. **Eenvoudig & Snel**
- Setup in ~15 minuten
- Automatische SSL/TLS certificaten
- Integratie met Azure DevOps (je pipeline werkt al!)
3. **Kosten-Effectief**
- Basic B1 plan: ~€15-25/maand
- Voldoende voor 20 gebruikers
- Geen verborgen kosten
4. **Flexibel**
- Deployment slots voor testen (staging → productie)
- Eenvoudige rollback
- Integratie met Azure Key Vault voor secrets
5. **Monitoring & Compliance**
- Integratie met Azure Monitor → kan naar Elastic stack
- Logging en audit trails (NEN 7510 compliance)
- Health checks ingebouwd
---
## 🚀 Aanbevolen Architectuur
```
┌─────────────────────────────────────────┐
│ Azure App Service Plan (B1) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Frontend │ │ Backend │ │
│ │ Web App │ │ Web App │ │
│ │ (Container) │ │ (Container) │ │
│ └──────────────┘ └──────┬───────┘ │
└──────────────────────────┼─────────────┘
┌──────────────┼──────────────┐
│ │ │
┌───────▼──────┐ ┌─────▼──────┐ ┌────▼─────┐
│ Azure Key │ │ Azure │ │ Elastic │
│ Vault │ │ Monitor │ │ Stack │
│ (Secrets) │ │ (Logs) │ │ (Export) │
└──────────────┘ └────────────┘ └──────────┘
```
**Componenten:**
- **App Service Plan B1**: 1 vCPU, 1.75GB RAM (voldoende voor 20 gebruikers)
- **2 Web Apps**: Frontend + Backend (delen dezelfde plan = kostenbesparend)
- **Azure Key Vault**: Voor secrets (Jira credentials, session secrets)
- **Azure Monitor**: Logging → kan geëxporteerd worden naar Elastic stack
- **Azure Storage**: Voor SQLite database (als je SQLite blijft gebruiken)
**Kosten Breakdown:**
- App Service Plan B1: ~€15-20/maand
- Azure Key Vault: ~€1-2/maand
- Azure Storage (SQLite): ~€1-2/maand
- **Totaal: ~€17-24/maand**
---
## 📋 Stap-voor-Stap Deployment Plan
### Fase 1: Basis Setup (15 minuten)
#### Stap 1.1: Resource Group Aanmaken
```bash
az group create \
--name rg-cmdb-gui-prod \
--location westeurope
```
#### Stap 1.2: App Service Plan Aanmaken
```bash
az appservice plan create \
--name plan-cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--sku B1 \
--is-linux
```
**Waarom B1?**
- 1 vCPU, 1.75GB RAM
- Voldoende voor 20 gebruikers
- Goede prijs/prestatie verhouding
#### Stap 1.3: Web Apps Aanmaken
```bash
# Backend Web App
az webapp create \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--plan plan-cmdb-gui-prod \
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
# Frontend Web App
az webapp create \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--plan plan-cmdb-gui-prod \
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
```
---
### Fase 2: ACR Authentication (5 minuten)
#### Stap 2.1: Enable Managed Identity
```bash
# Backend
az webapp identity assign \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod
# Frontend
az webapp identity assign \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod
```
#### Stap 2.2: Grant ACR Pull Permissions
```bash
# Get managed identity principal ID
BACKEND_PRINCIPAL_ID=$(az webapp identity show \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--query principalId -o tsv)
FRONTEND_PRINCIPAL_ID=$(az webapp identity show \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--query principalId -o tsv)
# Get ACR resource ID
ACR_ID=$(az acr show \
--name zdlas \
--resource-group <acr-resource-group> \
--query id -o tsv)
# Grant AcrPull role
az role assignment create \
--assignee $BACKEND_PRINCIPAL_ID \
--role AcrPull \
--scope $ACR_ID
az role assignment create \
--assignee $FRONTEND_PRINCIPAL_ID \
--role AcrPull \
--scope $ACR_ID
```
#### Stap 2.3: Configure Container Settings
```bash
# Backend
az webapp config container set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \
--docker-registry-server-url https://zdlas.azurecr.io
# Frontend
az webapp config container set \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \
--docker-registry-server-url https://zdlas.azurecr.io
```
---
### Fase 3: Environment Variabelen (10 minuten)
#### Stap 3.1: Azure Key Vault Aanmaken
```bash
az keyvault create \
--name kv-cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--location westeurope \
--sku standard
```
#### Stap 3.2: Secrets Toevoegen aan Key Vault
```bash
# Jira Personal Access Token (of OAuth credentials)
az keyvault secret set \
--vault-name kv-cmdb-gui-prod \
--name JiraPat \
--value "your-jira-pat-token"
# Session Secret
az keyvault secret set \
--vault-name kv-cmdb-gui-prod \
--name SessionSecret \
--value "$(openssl rand -hex 32)"
# Jira Schema ID
az keyvault secret set \
--vault-name kv-cmdb-gui-prod \
--name JiraSchemaId \
--value "your-schema-id"
# Anthropic API Key (optioneel)
az keyvault secret set \
--vault-name kv-cmdb-gui-prod \
--name AnthropicApiKey \
--value "your-anthropic-key"
```
#### Stap 3.3: Grant Web Apps Access tot Key Vault
```bash
# Backend
az keyvault set-policy \
--name kv-cmdb-gui-prod \
--object-id $BACKEND_PRINCIPAL_ID \
--secret-permissions get list
# Frontend (als nodig)
az keyvault set-policy \
--name kv-cmdb-gui-prod \
--object-id $FRONTEND_PRINCIPAL_ID \
--secret-permissions get list
```
#### Stap 3.4: Configure App Settings met Key Vault References
```bash
# Backend App Settings
az webapp config appsettings set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
NODE_ENV=production \
PORT=3001 \
JIRA_BASE_URL=https://jira.zuyderland.nl \
JIRA_SCHEMA_ID="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraSchemaId/)" \
JIRA_PAT="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraPat/)" \
SESSION_SECRET="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/SessionSecret/)" \
ANTHROPIC_API_KEY="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/AnthropicApiKey/)" \
FRONTEND_URL=https://cmdb-frontend-prod.azurewebsites.net
# Frontend App Settings
az webapp config appsettings set \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
VITE_API_URL=https://cmdb-backend-prod.azurewebsites.net/api
```
---
### Fase 4: SSL/TLS & Domain (10 minuten)
#### Stap 4.1: Gratis SSL via App Service
App Service heeft automatisch SSL voor `*.azurewebsites.net`:
- Frontend: `https://cmdb-frontend-prod.azurewebsites.net`
- Backend: `https://cmdb-backend-prod.azurewebsites.net`
**Geen configuratie nodig!** SSL werkt automatisch.
#### Stap 4.2: Custom Domain (Optioneel - Later)
Als je later een custom domain wilt (bijv. `cmdb.zuyderland.nl`):
```bash
# Add custom domain
az webapp config hostname add \
--webapp-name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--hostname cmdb.zuyderland.nl
# Bind SSL certificate (App Service Certificate of Let's Encrypt)
az webapp config ssl bind \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui-prod \
--certificate-thumbprint <thumbprint> \
--ssl-type SNI
```
---
### Fase 5: Monitoring & Logging (15 minuten)
#### Stap 5.1: Enable Application Insights
```bash
# Create Application Insights
az monitor app-insights component create \
--app cmdb-gui-prod \
--location westeurope \
--resource-group rg-cmdb-gui-prod \
--application-type web
# Get Instrumentation Key
INSTRUMENTATION_KEY=$(az monitor app-insights component show \
--app cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--query instrumentationKey -o tsv)
# Configure App Settings
az webapp config appsettings set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--settings \
APPINSIGHTS_INSTRUMENTATIONKEY=$INSTRUMENTATION_KEY \
APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=$INSTRUMENTATION_KEY"
```
#### Stap 5.2: Export naar Elastic Stack (Later)
Azure Monitor kan geëxporteerd worden naar Elastic stack via:
- **Azure Monitor → Log Analytics Workspace → Export naar Elastic**
- Of gebruik **Azure Function** om logs te streamen naar Elastic
**Zie:** `docs/ELASTIC-STACK-INTEGRATION.md` (te maken als nodig)
---
### Fase 6: Test & Start (5 minuten)
#### Stap 6.1: Start Web Apps
```bash
az webapp start --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
az webapp start --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
```
#### Stap 6.2: Test Health Endpoints
```bash
# Backend health check
curl https://cmdb-backend-prod.azurewebsites.net/api/health
# Frontend
curl https://cmdb-frontend-prod.azurewebsites.net
```
#### Stap 6.3: Check Logs
```bash
# Backend logs
az webapp log tail --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
# Frontend logs
az webapp log tail --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
```
---
## 🔒 NEN 7510 Compliance
### Wat App Service Biedt:
1. **Encryption**
- ✅ Data at rest: Azure Storage encryption
- ✅ Data in transit: TLS 1.2+ (automatisch)
- ✅ Secrets: Azure Key Vault (encrypted)
2. **Access Control**
- ✅ Azure AD integratie (RBAC)
- ✅ Managed Identity (geen credentials in code)
- ✅ Network isolation (optioneel via VNet integration)
3. **Logging & Audit**
- ✅ Application Insights (alle API calls)
- ✅ Azure Monitor (resource logs)
- ✅ Activity Logs (wie deed wat)
- ✅ Export naar Elastic stack mogelijk
4. **Backup & Recovery**
- ✅ App Service backups (optioneel)
- ✅ Key Vault soft delete (recovery)
- ⚠️ **Opmerking**: Jouw data wordt gesynchroniseerd vanuit Jira, dus backup is minder kritisch
### Compliance Checklist:
- [ ] Secrets in Azure Key Vault (niet in code)
- [ ] HTTPS only (automatisch via App Service)
- [ ] Logging ingeschakeld (Application Insights)
- [ ] Access control (Azure AD RBAC)
- [ ] Audit trail (Activity Logs)
- [ ] Encryption at rest (Azure Storage)
- [ ] Encryption in transit (TLS 1.2+)
**Zie:** `docs/NEN-7510-COMPLIANCE.md` (te maken als nodig)
---
## 🔐 VPN/Private Network Opties (Voor Later)
### Optie 1: App Service VNet Integration
**Wat het doet:**
- Web App verbindt met Azure Virtual Network
- Toegang tot resources in VNet (bijv. database, andere services)
- **Niet**: Maakt de app niet privé (app blijft publiek bereikbaar)
**Wanneer gebruiken:**
- Als je een database in VNet hebt
- Als je andere Azure services in VNet moet bereiken
**Setup:**
```bash
# Create VNet (als nog niet bestaat)
az network vnet create \
--name vnet-cmdb-gui \
--resource-group rg-cmdb-gui-prod \
--address-prefix 10.0.0.0/16
# Create subnet
az network vnet subnet create \
--name subnet-app-service \
--resource-group rg-cmdb-gui-prod \
--vnet-name vnet-cmdb-gui \
--address-prefix 10.0.1.0/24
# Integrate Web App with VNet
az webapp vnet-integration add \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--vnet vnet-cmdb-gui \
--subnet subnet-app-service
```
### Optie 2: Private Endpoint (Maakt App Privé)
**Wat het doet:**
- Maakt de Web App alleen bereikbaar via private IP
- Vereist VPN/ExpressRoute om toegang te krijgen
- **Kosten:** ~€7-10/maand per Private Endpoint
**Wanneer gebruiken:**
- Als je de app alleen via VPN wilt bereiken
- Als je geen publieke toegang wilt
**Setup:**
```bash
# Create Private Endpoint
az network private-endpoint create \
--name pe-cmdb-backend \
--resource-group rg-cmdb-gui-prod \
--vnet-name vnet-cmdb-gui \
--subnet subnet-private-endpoint \
--private-connection-resource-id /subscriptions/<sub-id>/resourceGroups/rg-cmdb-gui-prod/providers/Microsoft.Web/sites/cmdb-backend-prod \
--group-id sites \
--connection-name pe-connection-backend
```
### Optie 3: App Service Environment (ASE) - Enterprise
**Wat het doet:**
- Volledig geïsoleerde App Service omgeving
- Alleen bereikbaar via VNet
- **Kosten:** ~€1000+/maand (te duur voor jouw use case)
**Niet aanbevolen** voor jouw situatie (te duur, overkill).
---
## 🔄 Updates Deployen
### Automatische Deployment (Via Pipeline)
Je Azure DevOps pipeline bouwt al automatisch images. Voor automatische deployment:
#### Optie A: Continuous Deployment (Aanbevolen)
```bash
# Enable continuous deployment
az webapp deployment container config \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--enable-cd true
# Configure deployment slot (staging)
az webapp deployment slot create \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--slot staging
# Swap staging → production (zero-downtime)
az webapp deployment slot swap \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--slot staging \
--target-slot production
```
#### Optie B: Manual Deployment (Eenvoudig)
```bash
# Pull nieuwe image en restart
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
az webapp restart --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
```
**Workflow:**
1. Push code naar `main` branch
2. Pipeline bouwt nieuwe images → `zdlas.azurecr.io/.../backend:88767`
3. Images worden getagged als `latest`
4. Restart Web Apps → pull nieuwe `latest` image
---
## 📊 Monitoring Setup
### Azure Monitor → Elastic Stack Export
**Optie 1: Log Analytics Workspace Export**
```bash
# Create Log Analytics Workspace
az monitor log-analytics workspace create \
--workspace-name law-cmdb-gui-prod \
--resource-group rg-cmdb-gui-prod \
--location westeurope
# Configure App Service to send logs
az webapp log config \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui-prod \
--application-logging filesystem \
--detailed-error-messages true \
--failed-request-tracing true \
--web-server-logging filesystem
# Export to Elastic (via Azure Function of Event Hub)
# Zie: docs/ELASTIC-STACK-INTEGRATION.md
```
**Optie 2: Application Insights → Elastic**
Application Insights kan geëxporteerd worden via:
- **Continuous Export** (deprecated, maar werkt nog)
- **Azure Function** die Application Insights API gebruikt
- **Log Analytics Workspace** → Export naar Elastic
---
## ✅ Deployment Checklist
### Pre-Deployment:
- [ ] Resource Group aangemaakt
- [ ] App Service Plan aangemaakt (B1)
- [ ] Web Apps aangemaakt (backend + frontend)
- [ ] ACR authentication geconfigureerd
- [ ] Key Vault aangemaakt
- [ ] Secrets toegevoegd aan Key Vault
- [ ] App Settings geconfigureerd (met Key Vault references)
- [ ] Application Insights ingeschakeld
### Post-Deployment:
- [ ] Health checks werken
- [ ] SSL/TLS werkt (automatisch)
- [ ] Logging werkt
- [ ] Monitoring ingesteld
- [ ] Team geïnformeerd over URLs
- [ ] Documentatie bijgewerkt
---
## 🎯 Volgende Stappen
1. **Start met Fase 1-3** (Basis setup + ACR + Environment variabelen)
2. **Test de applicatie** (Fase 6)
3. **Configureer monitoring** (Fase 5)
4. **Documenteer voor team** (URLs, credentials, etc.)
---
## 📚 Gerelateerde Documentatie
- **Quick Deployment Guide**: `docs/QUICK-DEPLOYMENT-GUIDE.md`
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
- **Azure Deployment Summary**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
---
## ❓ Vragen?
**Veelgestelde vragen:**
**Q: Moet ik PostgreSQL gebruiken of kan ik SQLite houden?**
A: SQLite is prima voor 20 gebruikers. Als je later groeit, kun je migreren naar PostgreSQL.
**Q: Hoe update ik de applicatie?**
A: Push naar `main` → Pipeline bouwt images → Restart Web Apps (of gebruik deployment slots voor zero-downtime).
**Q: Kan ik de app 's avonds/weekend uitzetten?**
A: Ja! `az webapp stop --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod` (bespaart kosten).
**Q: Hoe integreer ik met Elastic stack?**
A: Exporteer Azure Monitor logs via Log Analytics Workspace → Elastic (zie Fase 5).
---
## 🎉 Success!
Je hebt nu een compleet deployment plan voor Azure App Service!
**Start met Fase 1** en laat me weten als je hulp nodig hebt bij een specifieke stap.

View File

@@ -0,0 +1,324 @@
# Deployment Next Steps - Images Gereed! 🚀
Je Docker images zijn succesvol gebouwd en gepusht naar Azure Container Registry! Hier zijn de volgende stappen voor deployment.
## ✅ Wat is al klaar:
- ✅ Azure Container Registry (ACR): `zdlas.azurecr.io`
- ✅ Docker images gebouwd en gepusht:
- `zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest`
- `zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest`
- ✅ Azure DevOps Pipeline: Automatische builds bij push naar `main`
- ✅ Docker Compose configuratie: `docker-compose.prod.acr.yml`
---
## 🎯 Deployment Opties
Je hebt verschillende opties om de applicatie te deployen. Kies de optie die het beste past bij jouw situatie:
### Optie 1: Azure App Service (Aanbevolen voor productie) ⭐
**Voordelen:**
- Managed service (geen server management)
- Automatische scaling
- Ingebouwde SSL/TLS
- Deployment slots (zero-downtime updates)
- Integratie met Azure Key Vault
- Monitoring via Application Insights
**Geschikt voor:** Productie deployment, kleine tot middelgrote teams
**Kosten:** ~€15-25/maand (Basic B1 plan)
**Stappen:**
1. Maak App Service Plan aan
2. Maak 2 Web Apps aan (backend + frontend)
3. Configureer container deployment vanuit ACR
4. Stel environment variabelen in
5. Configureer SSL certificaat
**Zie:** `docs/AZURE-APP-SERVICE-DEPLOYMENT.md` (te maken)
---
### Optie 2: Azure Container Instances (ACI) - Eenvoudig
**Voordelen:**
- Snel op te zetten
- Pay-per-use pricing
- Geen server management
**Geschikt voor:** Test/development, kleine deployments
**Kosten:** ~€30-50/maand (2 containers)
**Stappen:**
1. Maak 2 Container Instances aan
2. Pull images vanuit ACR
3. Configureer environment variabelen
4. Stel networking in
**Zie:** `docs/AZURE-CONTAINER-INSTANCES-DEPLOYMENT.md` (te maken)
---
### Optie 3: VM met Docker Compose (Flexibel)
**Voordelen:**
- Volledige controle
- Eenvoudige deployment met Docker Compose
- Kan lokaal getest worden
**Geschikt voor:** Als je al een VM hebt, of volledige controle wilt
**Kosten:** ~€20-40/maand (Basic VM)
**Stappen:**
1. Maak Azure VM aan (Ubuntu)
2. Installeer Docker en Docker Compose
3. Login naar ACR
4. Gebruik `docker-compose.prod.acr.yml`
5. Configureer Nginx reverse proxy
**Zie:** `docs/VM-DOCKER-COMPOSE-DEPLOYMENT.md` (te maken)
---
### Optie 4: Azure Kubernetes Service (AKS) - Enterprise
**Voordelen:**
- Enterprise-grade scaling
- High availability
- Advanced networking
**Geschikt voor:** Grote deployments, enterprise requirements
**Kosten:** ~€50-100+/maand (minimaal 2 nodes)
**Niet aanbevolen voor:** Kleine teams (20 gebruikers) - overkill
---
## 🔍 Stap 1: Verifieer Images in ACR
**Controleer of de images succesvol zijn gepusht:**
```bash
# Login naar ACR
az acr login --name zdlas
# List repositories
az acr repository list --name zdlas --output table
# List tags voor backend
az acr repository show-tags --name zdlas --repository zuyderland-cmdb-gui/backend --output table
# List tags voor frontend
az acr repository show-tags --name zdlas --repository zuyderland-cmdb-gui/frontend --output table
```
**Verwachte output:**
```
REPOSITORY TAG CREATED
zuyderland-cmdb-gui/backend latest ...
zuyderland-cmdb-gui/backend 88764 ...
zuyderland-cmdb-gui/frontend latest ...
zuyderland-cmdb-gui/frontend 88764 ...
```
---
## 📋 Stap 2: Update Docker Compose voor ACR
**Update `docker-compose.prod.acr.yml` met de juiste ACR naam:**
```yaml
services:
backend:
image: zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
frontend:
image: zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
```
**Let op:** De huidige configuratie gebruikt `zuyderlandcmdbacr.azurecr.io` - pas dit aan naar `zdlas.azurecr.io` als dat je ACR naam is.
---
## 🔐 Stap 3: Bereid Environment Variabelen Voor
**Maak een `.env.production` bestand** (niet committen naar Git!):
```bash
# Backend Environment Variables
NODE_ENV=production
PORT=3001
# Jira Configuration
JIRA_BASE_URL=https://jira.zuyderland.nl
JIRA_SCHEMA_ID=your-schema-id
JIRA_PAT=your-personal-access-token
# OF
JIRA_OAUTH_CLIENT_ID=your-client-id
JIRA_OAUTH_CLIENT_SECRET=your-client-secret
# Session
SESSION_SECRET=your-secure-random-secret
# AI (Optioneel)
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key
# Database (als je PostgreSQL gebruikt)
DATABASE_URL=postgresql://user:password@host:5432/dbname
# Frontend API URL
VITE_API_URL=https://your-backend-url.com/api
```
**Gebruik Azure Key Vault voor secrets in productie!**
---
## 🚀 Stap 4: Kies Deployment Methode
### Quick Start: VM met Docker Compose
**Als je snel wilt starten:**
1. **Maak Azure VM aan:**
```bash
az vm create \
--resource-group rg-cmdb-gui \
--name vm-cmdb-gui \
--image Ubuntu2204 \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys
```
2. **SSH naar de VM:**
```bash
ssh azureuser@<vm-public-ip>
```
3. **Installeer Docker en Docker Compose:**
```bash
# Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
4. **Login naar ACR:**
```bash
az acr login --name zdlas
# OF
docker login zdlas.azurecr.io -u <acr-username> -p <acr-password>
```
5. **Clone repository en deploy:**
```bash
git clone <your-repo-url>
cd zuyderland-cmdb-gui
# Update docker-compose.prod.acr.yml met juiste ACR naam
# Maak .env.production aan
docker-compose -f docker-compose.prod.acr.yml up -d
```
---
## 📝 Stap 5: Configureer Nginx Reverse Proxy
**Update `nginx/nginx.conf`** voor productie:
- SSL/TLS certificaat configuratie
- Domain name
- Backend API proxy
- Frontend static files
**Zie:** `nginx/nginx.conf` voor configuratie template.
---
## 🔒 Stap 6: Security Checklist
- [ ] SSL/TLS certificaat geconfigureerd
- [ ] Environment variabelen in Key Vault (niet in code)
- [ ] Firewall rules geconfigureerd
- [ ] CORS correct geconfigureerd
- [ ] Rate limiting ingeschakeld
- [ ] Health checks geconfigureerd
- [ ] Monitoring/alerting ingesteld
---
## 📊 Stap 7: Monitoring Setup
**Azure Application Insights:**
- Application performance monitoring
- Error tracking
- Usage analytics
**Azure Monitor:**
- Container health
- Resource usage
- Alerts
---
## 🎯 Aanbevolen Volgorde
1. **Verifieer images in ACR** (Stap 1)
2. **Kies deployment optie** (Optie 1, 2, of 3)
3. **Bereid environment variabelen voor** (Stap 3)
4. **Deploy naar test environment**
5. **Test functionaliteit**
6. **Configureer SSL/TLS**
7. **Setup monitoring**
8. **Deploy naar productie**
---
## 📚 Gerelateerde Documentatie
- **Azure App Service Deployment**: `docs/AZURE-APP-SERVICE-DEPLOYMENT.md` (te maken)
- **Azure Container Instances**: `docs/AZURE-CONTAINER-INSTANCES-DEPLOYMENT.md` (te maken)
- **VM Docker Compose**: `docs/VM-DOCKER-COMPOSE-DEPLOYMENT.md` (te maken)
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
- **Azure Deployment Summary**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
---
## ❓ Vragen?
**Veelgestelde vragen:**
**Q: Welke deployment optie moet ik kiezen?**
A: Voor 20 gebruikers: **Azure App Service** (Optie 1) is het meest geschikt - managed service, eenvoudig, voldoende resources.
**Q: Moet ik PostgreSQL gebruiken of kan ik SQLite houden?**
A: SQLite is prima voor 20 gebruikers. PostgreSQL is beter voor groei of als je connection pooling nodig hebt.
**Q: Hoe configureer ik SSL?**
A: Azure App Service heeft ingebouwde SSL. Voor VM: gebruik Let's Encrypt met certbot.
**Q: Hoe update ik de applicatie?**
A: Push naar `main` branch → Pipeline bouwt nieuwe images → Pull nieuwe images in deployment → Restart containers.
---
## 🎉 Success!
Je hebt nu:
- ✅ Docker images in ACR
- ✅ Automatische CI/CD pipeline
- ✅ Deployment configuratie klaar
**Volgende stap:** Kies je deployment optie en volg de stappen!

View File

@@ -0,0 +1,320 @@
# Next Steps - ACR Created! 🎉
Je Azure Container Registry is aangemaakt! Volg deze stappen om Docker images automatisch te bouwen en te pushen.
## 📋 Checklist
- [ ] Stap 1: Verifieer ACR naam
- [ ] Stap 2: Update pipeline variabelen (als nodig)
- [ ] Stap 3: Service Connection aanmaken in Azure DevOps
- [ ] Stap 4: Pipeline aanmaken en runnen
- [ ] Stap 5: Verifieer images in ACR
---
## 🔍 Stap 1: Verifieer ACR Naam
**Vind je ACR naam:**
**Via Azure Portal:**
1. Ga naar je Container Registry
2. Klik op **"Overview"**
3. Noteer de **"Login server"** (bijv. `zuyderlandcmdbacr.azurecr.io`)
4. De naam vóór `.azurecr.io` is je ACR naam (bijv. `zuyderlandcmdbacr`)
**Via Azure CLI:**
```bash
az acr list --query "[].{Name:name, LoginServer:loginServer}" -o table
```
**Noteer je ACR naam!** (bijv. `zuyderlandcmdbacr`)
---
## 🔧 Stap 2: Update Pipeline Variabelen
**Check of `azure-pipelines.yml` de juiste ACR naam heeft:**
1. **Open** `azure-pipelines.yml`
2. **Controleer regel 17:**
```yaml
acrName: 'zuyderlandcmdbacr' # ← Pas aan naar jouw ACR naam
```
3. **Als je ACR naam anders is**, pas het aan:
```yaml
acrName: 'jouw-acr-naam-hier'
```
4. **Commit en push** (als je het hebt aangepast):
```bash
git add azure-pipelines.yml
git commit -m "Update ACR name in pipeline"
git push origin main
```
**✅ Als de naam al klopt, ga door naar Stap 3!**
---
## 🔗 Stap 3: Service Connection Aanmaken in Azure DevOps
Deze connection geeft Azure DevOps toegang tot je ACR.
### Stappen:
1. **Ga naar je Azure DevOps project**
- Open je project in Azure DevOps
2. **Ga naar Project Settings**
- Klik op **⚙️ Project Settings** (onderaan links in het menu)
3. **Open Service Connections**
- Scroll naar **"Pipelines"** sectie
- Klik op **"Service connections"**
4. **Maak nieuwe connection**
- Klik op **"New service connection"** (of **"Create service connection"**)
- Kies **"Docker Registry"**
- Klik **"Next"**
5. **Selecteer Azure Container Registry**
- Kies **"Azure Container Registry"**
- Klik **"Next"**
6. **Configureer connection**
- **Authentication type**: Kies **"Service Principal"** ⭐ (aanbevolen)
- Dit is de standaard en meest betrouwbare optie
- Azure DevOps maakt automatisch een Service Principal aan
- **Azure subscription**: Selecteer je Azure subscription
- **Azure container registry**: Selecteer je ACR uit de dropdown (bijv. `zdlas`)
- **Service connection name**: `zuyderland-cmdb-acr-connection`
- ⚠️ **Belangrijk**: Deze naam moet overeenkomen met `dockerRegistryServiceConnection` in `azure-pipelines.yml`!
- **Description**: Optioneel (bijv. "ACR for CMDB GUI production")
7. **Save**
- Klik **"Save"** (of **"Verify and save"**)
- Azure DevOps test automatisch de connection
- Azure DevOps maakt automatisch een Service Principal aan met de juiste permissions
**💡 Waarom Service Principal?**
- ✅ Werkt perfect met Azure DevOps Services (cloud)
- ✅ Eenvoudig - Azure DevOps doet alles automatisch
- ✅ Betrouwbaar - Meest ondersteunde optie
- ✅ Veilig - Credentials worden veilig beheerd
📚 Zie `docs/AZURE-SERVICE-CONNECTION-AUTH.md` voor details over alle authentication types.
**✅ Service connection is aangemaakt!**
**Troubleshooting:**
- **"Loading Registries..." blijft hangen?**
- ✅ Refresh de pagina (F5)
- ✅ Check of je de juiste subscription hebt geselecteerd
- ✅ Wacht 10-30 seconden (kan even duren)
- ✅ **Workaround**: Gebruik "Others" optie (zie hieronder)
- Als verificatie faalt, check of je toegang hebt tot de ACR in Azure Portal
**🔧 Workaround: Als dropdown niet werkt, gebruik "Others" optie:**
1. Kies **"Docker Registry"** → **"Others"** (in plaats van "Azure Container Registry")
2. Vul handmatig in:
- **Docker Registry**: `zdlas.azurecr.io`
- **Docker ID**: (haal op met `az acr credential show --name zdlas`)
- **Docker Password**: (haal op met `az acr credential show --name zdlas`)
3. **Service connection name**: `zuyderland-cmdb-acr-connection`
4. Save
**Haal credentials op:**
```bash
az login
az acr credential show --name zdlas
# Gebruik "username" en "passwords[0].value"
```
📚 Zie `docs/AZURE-SERVICE-CONNECTION-TROUBLESHOOTING.md` voor uitgebreide troubleshooting.
---
## 🎯 Stap 4: Pipeline Aanmaken en Run
### Stappen:
1. **Ga naar Pipelines**
- Klik op **"Pipelines"** (links in het menu)
2. **Create New Pipeline**
- Klik op **"New pipeline"** of **"Create Pipeline"**
3. **Selecteer Repository**
- Kies **"Azure Repos Git"** (of waar je code staat)
- Selecteer je repository: **"Zuyderland CMDB GUI"** (of jouw repo naam)
4. **Kies YAML File**
- Kies **"Existing Azure Pipelines YAML file"**
- Selecteer:
- **Branch**: `main` (of jouw default branch)
- **Path**: `/azure-pipelines.yml`
5. **Review Configuration**
- Azure DevOps toont de pipeline configuratie
- Controleer of alles klopt
6. **Run Pipeline**
- Klik **"Run"** om de pipeline te starten
- De pipeline start automatisch met het bouwen van de images
**✅ Pipeline is gestart!**
### Wat gebeurt er nu?
De pipeline zal:
1. ✅ Code uitchecken
2. ✅ Backend Docker image bouwen
3. ✅ Frontend Docker image bouwen
4. ✅ Images naar Azure Container Registry pushen
**Verwachte tijd:** ~5-10 minuten (afhankelijk van build tijd)
---
## ✅ Stap 5: Verifieer Images in ACR
### In Azure Portal:
1. **Ga naar je Container Registry**
- Open Azure Portal
- Ga naar je Container Registry (`zuyderlandcmdbacr`)
2. **Bekijk Repositories**
- Klik op **"Repositories"** (links in het menu)
- Je zou moeten zien:
- `zuyderland-cmdb-gui/backend`
- `zuyderland-cmdb-gui/frontend`
3. **Bekijk Tags**
- Klik op een repository (bijv. `zuyderland-cmdb-gui/backend`)
- Je zou tags moeten zien:
- `latest`
- `123` (of build ID nummer)
**✅ Images zijn succesvol gebouwd en gepusht!**
### Via Azure CLI:
```bash
# Lijst repositories
az acr repository list --name zuyderlandcmdbacr
# Lijst tags voor backend
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend --orderby time_desc
# Lijst tags voor frontend
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend --orderby time_desc
```
### In Azure DevOps:
1. **Ga naar je Pipeline**
- Klik op **"Pipelines"**
- Klik op je pipeline run
2. **Bekijk Logs**
- Klik op een job (bijv. "Build Docker Images")
- Bekijk de logs per stap
- Bij success zie je:
```
Backend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:123
Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123
```
---
## 🚨 Troubleshooting
### Pipeline Fails: "Service connection not found"
**Oplossing:**
- Controleer of de service connection naam in `azure-pipelines.yml` (regel 19) overeenkomt met de naam in Azure DevOps
- Ga naar Project Settings → Service connections en verifieer de naam
- Pas `dockerRegistryServiceConnection` aan in `azure-pipelines.yml` als nodig
### Pipeline Fails: "ACR not found"
**Oplossing:**
- Controleer of de `acrName` variabele correct is in `azure-pipelines.yml` (regel 17)
- Verifieer dat de ACR bestaat: `az acr list`
- Check of je de juiste subscription hebt geselecteerd in de service connection
### Pipeline Fails: "Permission denied"
**Oplossing:**
- Controleer of de service connection de juiste permissions heeft
- Verifieer dat je Azure subscription toegang heeft tot de ACR
- Check of de service connection is geverifieerd (groen vinkje in Azure DevOps)
- Probeer de service connection opnieuw aan te maken
### Images worden niet gepusht
**Oplossing:**
- Check de pipeline logs voor specifieke errors
- Verifieer dat de Docker build succesvol is
- Controleer of de ACR admin-enabled is (voor development)
- Check of de service connection correct is geconfigureerd
### Build Fails: "Dockerfile not found"
**Oplossing:**
- Verifieer dat `backend/Dockerfile.prod` en `frontend/Dockerfile.prod` bestaan
- Check of de paths correct zijn in `azure-pipelines.yml`
- Controleer of de files zijn gecommit en gepusht naar de repository
---
## 🎉 Success!
Als alles goed is gegaan, heb je nu:
- ✅ Azure Container Registry aangemaakt
- ✅ Service Connection geconfigureerd
- ✅ Pipeline aangemaakt en gerund
- ✅ Docker images gebouwd en gepusht naar ACR
**Je images zijn nu beschikbaar op:**
- Backend: `zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest`
- Frontend: `zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest`
---
## 🚀 Volgende Stappen
Nu je images in ACR staan, kun je ze deployen naar:
1. **Azure Container Instances (ACI)** - Eenvoudig, snel
2. **Azure App Service (Container)** - Managed service
3. **Azure Kubernetes Service (AKS)** - Voor complexere setups
4. **VM met Docker Compose** - Volledige controle
Zie `docs/AZURE-DEPLOYMENT-SUMMARY.md` voor deployment opties.
---
## 📚 Meer Informatie
- **Quick Start Guide**: `docs/AZURE-ACR-QUICKSTART.md`
- **Azure DevOps Setup**: `docs/AZURE-DEVOPS-SETUP.md`
- **Container Registry Guide**: `docs/AZURE-CONTAINER-REGISTRY.md`
- **Deployment Options**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
---
## 💡 Tips
1. **Automatische Triggers**: De pipeline triggert automatisch bij elke push naar `main` branch
2. **Version Tags**: Gebruik git tags (bijv. `v1.0.0`) voor versie-specifieke builds
3. **Monitor Costs**: Check Azure Portal regelmatig voor storage gebruik
4. **Cleanup**: Overweeg oude images te verwijderen om kosten te besparen
---
**Veel succes! 🚀**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,323 @@
# Quick Deployment Guide - Van Images naar Productie 🚀
Je Docker images zijn klaar! Hier is een snelle guide om ze te deployen.
## 🎯 Snelle Keuze: Welke Deployment Optie?
**Voor 20 gebruikers, productie:**
**Azure App Service** (Managed, eenvoudig, ~€20/maand)
**Voor test/development:**
**VM met Docker Compose** (Flexibel, snel op te zetten)
**Voor enterprise/scale:**
**Azure Kubernetes Service** (Complex, maar krachtig)
---
## ⚡ Optie 1: Azure App Service (Aanbevolen) - 15 minuten
### Stap 1: Maak App Service Plan aan
```bash
# Resource Group (als nog niet bestaat)
az group create --name rg-cmdb-gui --location westeurope
# App Service Plan (Basic B1 - voldoende voor 20 gebruikers)
az appservice plan create \
--name plan-cmdb-gui \
--resource-group rg-cmdb-gui \
--sku B1 \
--is-linux
```
### Stap 2: Maak Web Apps aan
```bash
# Backend Web App
az webapp create \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui \
--plan plan-cmdb-gui \
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
# Frontend Web App
az webapp create \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui \
--plan plan-cmdb-gui \
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
```
### Stap 3: Configureer ACR Authentication
```bash
# Enable managed identity
az webapp identity assign \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui
az webapp identity assign \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui
# Grant ACR pull permissions
az acr update \
--name zdlas \
--admin-enabled true
# Configure ACR credentials
az webapp config container set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui \
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \
--docker-registry-server-url https://zdlas.azurecr.io \
--docker-registry-server-user $(az acr credential show --name zdlas --query username -o tsv) \
--docker-registry-server-password $(az acr credential show --name zdlas --query passwords[0].value -o tsv)
az webapp config container set \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui \
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \
--docker-registry-server-url https://zdlas.azurecr.io \
--docker-registry-server-user $(az acr credential show --name zdlas --query username -o tsv) \
--docker-registry-server-password $(az acr credential show --name zdlas --query passwords[0].value -o tsv)
```
### Stap 4: Configureer Environment Variabelen
```bash
# Backend environment variables
az webapp config appsettings set \
--name cmdb-backend-prod \
--resource-group rg-cmdb-gui \
--settings \
NODE_ENV=production \
PORT=3001 \
JIRA_BASE_URL=https://jira.zuyderland.nl \
JIRA_SCHEMA_ID=your-schema-id \
SESSION_SECRET=your-secure-secret \
FRONTEND_URL=https://cmdb-frontend-prod.azurewebsites.net
# Frontend environment variables
az webapp config appsettings set \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui \
--settings \
VITE_API_URL=https://cmdb-backend-prod.azurewebsites.net/api
```
### Stap 5: Configureer SSL (Gratis via App Service)
```bash
# App Service heeft automatisch SSL via *.azurewebsites.net
# Voor custom domain:
az webapp config hostname add \
--webapp-name cmdb-frontend-prod \
--resource-group rg-cmdb-gui \
--hostname cmdb.zuyderland.nl
# Bind SSL certificate
az webapp config ssl bind \
--name cmdb-frontend-prod \
--resource-group rg-cmdb-gui \
--certificate-thumbprint <thumbprint> \
--ssl-type SNI
```
### Stap 6: Start de Apps
```bash
az webapp start --name cmdb-backend-prod --resource-group rg-cmdb-gui
az webapp start --name cmdb-frontend-prod --resource-group rg-cmdb-gui
```
**Je applicatie is nu live op:**
- Frontend: `https://cmdb-frontend-prod.azurewebsites.net`
- Backend API: `https://cmdb-backend-prod.azurewebsites.net/api`
---
## 🖥️ Optie 2: VM met Docker Compose - 20 minuten
### Stap 1: Maak VM aan
```bash
az vm create \
--resource-group rg-cmdb-gui \
--name vm-cmdb-gui \
--image Ubuntu2204 \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
```
### Stap 2: SSH naar VM en installeer Docker
```bash
# SSH naar VM
ssh azureuser@<vm-public-ip>
# Installeer Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
newgrp docker
# Installeer Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
### Stap 3: Login naar ACR
```bash
# Installeer Azure CLI (als nog niet geïnstalleerd)
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
az login
# Login naar ACR
az acr login --name zdlas
# OF gebruik credentials
az acr credential show --name zdlas
docker login zdlas.azurecr.io -u <username> -p <password>
```
### Stap 4: Clone Repository en Deploy
```bash
# Clone repository
git clone <your-repo-url>
cd zuyderland-cmdb-gui
# Maak .env.production aan
nano .env.production
# (Plak je environment variabelen)
# Update docker-compose.prod.acr.yml (ACR naam is al correct: zdlas)
# Start containers
docker-compose -f docker-compose.prod.acr.yml up -d
# Check status
docker-compose -f docker-compose.prod.acr.yml ps
docker-compose -f docker-compose.prod.acr.yml logs -f
```
### Stap 5: Configureer Nginx en SSL
```bash
# Installeer certbot voor Let's Encrypt
sudo apt update
sudo apt install certbot python3-certbot-nginx -y
# Configureer SSL
sudo certbot --nginx -d cmdb.zuyderland.nl
```
---
## 🔍 Verificatie
**Test of alles werkt:**
```bash
# Check backend health
curl https://your-backend-url/api/health
# Check frontend
curl https://your-frontend-url
# Check container logs
docker-compose -f docker-compose.prod.acr.yml logs backend
docker-compose -f docker-compose.prod.acr.yml logs frontend
```
---
## 🔄 Updates Deployen
**Wanneer je code pusht naar `main`:**
1. Pipeline bouwt automatisch nieuwe images
2. Images worden gepusht naar ACR met nieuwe tag
3. Pull nieuwe images:
```bash
# VM met Docker Compose
docker-compose -f docker-compose.prod.acr.yml pull
docker-compose -f docker-compose.prod.acr.yml up -d
# App Service (automatisch via Continuous Deployment)
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui
```
---
## 📝 Environment Variabelen Template
**Maak `.env.production` aan:**
```bash
# Backend
NODE_ENV=production
PORT=3001
# Jira
JIRA_BASE_URL=https://jira.zuyderland.nl
JIRA_SCHEMA_ID=your-schema-id
JIRA_PAT=your-pat-token
# OF OAuth
JIRA_OAUTH_CLIENT_ID=your-client-id
JIRA_OAUTH_CLIENT_SECRET=your-client-secret
JIRA_OAUTH_CALLBACK_URL=https://your-domain/api/auth/callback
# Session
SESSION_SECRET=$(openssl rand -hex 32)
# AI (Optioneel)
ANTHROPIC_API_KEY=your-key
OPENAI_API_KEY=your-key
# Database (als PostgreSQL)
DATABASE_URL=postgresql://user:pass@host:5432/db
# Frontend
VITE_API_URL=https://your-backend-url/api
```
**⚠️ BELANGRIJK:** Gebruik Azure Key Vault voor secrets in productie!
---
## 🎯 Volgende Stappen
1. **Kies deployment optie** (App Service of VM)
2. **Configureer environment variabelen**
3. **Deploy en test**
4. **Configureer SSL/TLS**
5. **Setup monitoring**
6. **Documenteer voor team**
---
## 📚 Meer Informatie
- **Volledige Deployment Guide**: `docs/DEPLOYMENT-NEXT-STEPS.md`
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
- **Azure Deployment Summary**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
---
## ✅ Success Checklist
- [ ] Images geverifieerd in ACR
- [ ] Deployment optie gekozen
- [ ] Environment variabelen geconfigureerd
- [ ] Applicatie gedeployed
- [ ] SSL/TLS geconfigureerd
- [ ] Health checks werken
- [ ] Monitoring ingesteld
- [ ] Team geïnformeerd
**Veel succes met de deployment! 🚀**

View File

@@ -0,0 +1,192 @@
# Waarom TypeScript Errors Lokaal Niet Optreden Maar Wel in CI/CD
## Het Probleem
TypeScript compilation errors die lokaal niet optreden, maar wel in Azure DevOps pipelines of Docker builds. Dit is een veelvoorkomend probleem met verschillende oorzaken.
## Belangrijkste Oorzaken
### 1. **tsx vs tsc - Development vs Production Build**
**Lokaal (Development):**
```bash
npm run dev # Gebruikt: tsx watch src/index.ts
```
**In Docker/CI (Production Build):**
```bash
npm run build # Gebruikt: tsc (TypeScript Compiler)
```
**Verschil:**
- **`tsx`** (TypeScript Execute): Een runtime TypeScript executor die code direct uitvoert zonder volledige type checking. Het is **minder strict** en laat veel type errors door.
- **`tsc`** (TypeScript Compiler): De officiële TypeScript compiler die **volledige type checking** doet en alle errors rapporteert.
**Oplossing:**
- Test altijd lokaal met `npm run build` voordat je pusht
- Of gebruik `tsc --noEmit` om type checking te doen zonder te builden
### 2. **TypeScript Versie Verschillen**
**Lokaal:**
```bash
npx tsc --version # Kan een andere versie zijn
```
**In Docker:**
- Gebruikt de versie uit `package.json` (`"typescript": "^5.6.3"`)
- Maar zonder `package-lock.json` kan een andere patch versie geïnstalleerd worden
**Oplossing:**
- Genereer `package-lock.json` met `npm install`
- Commit `package-lock.json` naar Git
- Dit zorgt voor consistente dependency versies
### 3. **tsconfig.json Strictness**
Je `tsconfig.json` heeft `"strict": true`, wat betekent:
- Alle strict type checking opties zijn aan
- Dit is goed voor productie, maar kan lokaal vervelend zijn
**Mogelijke verschillen:**
- Lokaal kan je IDE/editor andere TypeScript settings hebben
- Lokaal kan je `tsconfig.json` overrides hebben
- CI/CD gebruikt altijd de exacte `tsconfig.json` uit de repo
### 4. **Node.js Versie Verschillen**
**Lokaal:**
- Kan een andere Node.js versie hebben
- TypeScript gedrag kan verschillen tussen Node versies
**In Docker:**
```dockerfile
FROM node:20-alpine # Specifieke Node versie
```
**Oplossing:**
- Gebruik `.nvmrc` of `package.json` engines field om Node versie te specificeren
- Zorg dat lokaal dezelfde Node versie gebruikt wordt
### 5. **Cached Builds**
**Lokaal:**
- Oude compiled files in `dist/` kunnen nog werken
- IDE kan gecachte type informatie gebruiken
- `tsx` gebruikt geen build output, dus errors worden niet altijd gezien
**In CI/CD:**
- Schone build elke keer
- Geen cache, dus alle errors worden gezien
### 6. **Incremental Compilation**
**Lokaal:**
- TypeScript kan incremental compilation gebruiken
- Alleen gewijzigde files worden gecheckt
**In CI/CD:**
- Volledige rebuild elke keer
- Alle files worden gecheckt
## Best Practices om Dit Te Voorkomen
### 1. Test Lokaal Met Production Build
```bash
# Voordat je pusht, test altijd:
cd backend
npm run build # Dit gebruikt tsc, net als in Docker
```
### 2. Type Checking Zonder Build
```bash
# Alleen type checking, geen build:
npx tsc --noEmit
```
### 3. Pre-commit Hooks
Voeg een pre-commit hook toe die `tsc --noEmit` draait:
```json
// package.json
{
"scripts": {
"type-check": "tsc --noEmit",
"precommit": "npm run type-check"
}
}
```
### 4. Genereer package-lock.json
```bash
# Genereer lock file voor consistente dependencies:
npm install
# Commit package-lock.json naar Git
git add package-lock.json
git commit -m "Add package-lock.json for consistent builds"
```
### 5. Gebruik CI/CD Lokaal
Test je pipeline lokaal met:
- **act** (voor GitHub Actions)
- **Azure DevOps Pipeline Agent** lokaal
- **Docker build** lokaal: `docker build -f backend/Dockerfile.prod -t test-build ./backend`
### 6. IDE TypeScript Settings
Zorg dat je IDE dezelfde TypeScript versie gebruikt:
- VS Code: Check "TypeScript: Select TypeScript Version"
- Gebruik "Use Workspace Version"
## Voor Dit Project Specifiek
### Huidige Situatie
1. **Development:** `tsx watch` - minder strict
2. **Production Build:** `tsc` - volledig strict
3. **Geen package-lock.json** - verschillende dependency versies mogelijk
4. **TypeScript 5.6.3** in package.json, maar lokaal mogelijk 5.9.3
### Aanbevolen Acties
1. **Genereer package-lock.json:**
```bash
cd backend
npm install
git add package-lock.json
git commit -m "Add package-lock.json"
```
2. **Test altijd met build:**
```bash
npm run build # Voordat je pusht
```
3. **Voeg type-check script toe:**
```json
{
"scripts": {
"type-check": "tsc --noEmit"
}
}
```
4. **Test Docker build lokaal:**
```bash
docker build -f backend/Dockerfile.prod -t test-backend ./backend
```
## Conclusie
Het verschil komt vooral door:
- **tsx (dev) vs tsc (build)** - tsx is minder strict
- **Geen package-lock.json** - verschillende dependency versies
- **Cached builds lokaal** - oude code werkt nog
**Oplossing:** Test altijd met `npm run build` lokaal voordat je pusht, en genereer `package-lock.json` voor consistente builds.

View File

@@ -4,7 +4,7 @@ WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
RUN npm install
# Copy source and build
COPY . .

View File

@@ -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<void> | 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<HTMLDivElement>(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 && (
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
{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 (
<div className="relative">
<button
@@ -138,7 +172,7 @@ function UserMenu() {
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.displayName}
alt={displayName}
className="w-8 h-8 rounded-full"
/>
) : (
@@ -146,7 +180,7 @@ function UserMenu() {
<span className="text-white text-sm font-medium">{initials}</span>
</div>
)}
<span className="text-sm text-gray-700 hidden sm:block">{user.displayName}</span>
<span className="text-sm text-gray-700 hidden sm:block">{displayName}</span>
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
@@ -160,26 +194,36 @@ function UserMenu() {
/>
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-20">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.displayName}</p>
{user.emailAddress && (
<p className="text-xs text-gray-500 truncate">{user.emailAddress}</p>
<p className="text-sm font-medium text-gray-900">{displayName}</p>
{email && (
<p className="text-xs text-gray-500 truncate">{email}</p>
)}
{user.username && email !== user.username && (
<p className="text-xs text-gray-500 truncate">@{user.username}</p>
)}
<p className="text-xs text-gray-400 mt-1">
{authMethod === 'oauth' ? 'Jira OAuth' : 'Service Account'}
{authMethod === 'oauth' ? 'Jira OAuth' : authMethod === 'local' ? 'Lokaal Account' : 'Service Account'}
</p>
</div>
<div className="py-1">
{authMethod === 'oauth' && (
{(authMethod === 'local' || authMethod === 'oauth') && (
<>
<Link
to="/settings/profile"
onClick={() => setIsOpen(false)}
className="block px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Profiel & Instellingen
</Link>
<div className="border-t border-gray-100 my-1"></div>
</>
)}
<button
onClick={() => {
setIsOpen(false);
logout();
}}
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Uitloggen
</button>
)}
</div>
</div>
</>
@@ -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 (
<div className="min-h-screen bg-white">
@@ -254,37 +309,27 @@ function AppContent() {
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
<div>
<h1 className="text-lg font-semibold text-gray-900">
Analyse Tool
{config?.appName || 'CMDB Insight'}
</h1>
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
<p className="text-xs text-gray-500">{config?.appTagline || 'Management console for Jira Assets'}</p>
</div>
</Link>
<nav className="hidden md:flex items-center space-x-1">
{/* Dashboard (Search) */}
<Link
to="/"
className={clsx(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isDashboardActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
Dashboard
</Link>
{/* Application Component Dropdown */}
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} hasPermission={hasPermission} />
{/* Apps Dropdown */}
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} />
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
{/* Reports Dropdown */}
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} hasPermission={hasPermission} />
{/* Settings Dropdown */}
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} />
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} hasPermission={hasPermission} />
{/* Admin Dropdown */}
<NavDropdown dropdown={adminDropdown} isActive={isAdminActive} hasPermission={hasPermission} />
</nav>
</div>
@@ -297,36 +342,43 @@ function AppContent() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
{/* Main Dashboard (Search) */}
<Route path="/" element={<SearchDashboard />} />
<Route path="/" element={<ProtectedRoute><SearchDashboard /></ProtectedRoute>} />
{/* Application routes (new structure) */}
<Route path="/application/overview" element={<ApplicationList />} />
<Route path="/application/fte-calculator" element={<FTECalculator />} />
<Route path="/application/:id" element={<ApplicationInfo />} />
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
{/* Application routes (new structure) - specific routes first, then dynamic */}
<Route path="/application/overview" element={<ProtectedRoute requirePermission="search"><ApplicationList /></ProtectedRoute>} />
<Route path="/application/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></ProtectedRoute>} />
<Route path="/application/:id/edit" element={<ProtectedRoute requirePermission="edit_applications"><GovernanceModelHelper /></ProtectedRoute>} />
<Route path="/application/:id" element={<ProtectedRoute requirePermission="search"><ApplicationInfo /></ProtectedRoute>} />
{/* Application Component routes */}
<Route path="/app-components" element={<Dashboard />} />
<Route path="/app-components" element={<ProtectedRoute requirePermission="search"><Dashboard /></ProtectedRoute>} />
{/* Reports routes */}
<Route path="/reports" element={<ReportsDashboard />} />
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
<Route path="/reports/technical-debt-heatmap" element={<TechnicalDebtHeatmap />} />
<Route path="/reports/lifecycle-pipeline" element={<LifecyclePipeline />} />
<Route path="/reports/data-completeness" element={<DataCompletenessScore />} />
<Route path="/reports/zira-domain-coverage" element={<ZiRADomainCoverage />} />
<Route path="/reports/fte-per-zira-domain" element={<FTEPerZiRADomain />} />
<Route path="/reports/complexity-dynamics-bubble" element={<ComplexityDynamicsBubbleChart />} />
<Route path="/reports/business-importance-comparison" element={<BusinessImportanceComparison />} />
<Route path="/reports" element={<ProtectedRoute requirePermission="view_reports"><ReportsDashboard /></ProtectedRoute>} />
<Route path="/reports/team-dashboard" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
<Route path="/reports/governance-analysis" element={<ProtectedRoute requirePermission="view_reports"><GovernanceAnalysis /></ProtectedRoute>} />
<Route path="/reports/technical-debt-heatmap" element={<ProtectedRoute requirePermission="view_reports"><TechnicalDebtHeatmap /></ProtectedRoute>} />
<Route path="/reports/lifecycle-pipeline" element={<ProtectedRoute requirePermission="view_reports"><LifecyclePipeline /></ProtectedRoute>} />
<Route path="/reports/data-completeness" element={<ProtectedRoute requirePermission="view_reports"><DataCompletenessScore /></ProtectedRoute>} />
<Route path="/reports/zira-domain-coverage" element={<ProtectedRoute requirePermission="view_reports"><ZiRADomainCoverage /></ProtectedRoute>} />
<Route path="/reports/fte-per-zira-domain" element={<ProtectedRoute requirePermission="view_reports"><FTEPerZiRADomain /></ProtectedRoute>} />
<Route path="/reports/complexity-dynamics-bubble" element={<ProtectedRoute requirePermission="view_reports"><ComplexityDynamicsBubbleChart /></ProtectedRoute>} />
<Route path="/reports/business-importance-comparison" element={<ProtectedRoute requirePermission="view_reports"><BusinessImportanceComparison /></ProtectedRoute>} />
{/* Apps routes */}
<Route path="/apps/bia-sync" element={<BIASyncDashboard />} />
<Route path="/apps/bia-sync" element={<ProtectedRoute requirePermission="search"><BIASyncDashboard /></ProtectedRoute>} />
{/* Settings routes */}
<Route path="/settings/fte-config" element={<ConfigurationV25 />} />
<Route path="/settings/data-model" element={<DataModelDashboard />} />
<Route path="/settings/data-completeness-config" element={<DataCompletenessConfig />} />
<Route path="/settings/fte-config" element={<ProtectedRoute requirePermission="manage_settings"><ConfigurationV25 /></ProtectedRoute>} />
<Route path="/settings/data-model" element={<ProtectedRoute requirePermission="manage_settings"><DataModelDashboard /></ProtectedRoute>} />
<Route path="/settings/data-completeness-config" element={<ProtectedRoute requirePermission="manage_settings"><DataCompletenessConfig /></ProtectedRoute>} />
<Route path="/settings/profile" element={<ProtectedRoute><ProfileSettings /></ProtectedRoute>} />
{/* Legacy redirects for old routes */}
<Route path="/settings/user-settings" element={<Navigate to="/settings/profile" replace />} />
{/* Admin routes */}
<Route path="/admin/users" element={<ProtectedRoute requirePermission="manage_users"><UserManagement /></ProtectedRoute>} />
<Route path="/admin/roles" element={<ProtectedRoute requirePermission="manage_roles"><RoleManagement /></ProtectedRoute>} />
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
@@ -336,7 +388,7 @@ function AppContent() {
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/reports/data-model" element={<Navigate to="/settings/data-model" replace />} />
<Route path="/reports/bia-sync" element={<Navigate to="/apps/bia-sync" replace />} />
<Route path="/teams" element={<TeamDashboard />} />
<Route path="/teams" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
<Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />
</Routes>
</main>
@@ -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
// Show loading state
if (isLoading) {
// Check if already initialized by checking store state
const currentState = useAuthStore.getState();
if (currentState.config && currentState.isInitialized) {
return;
}
// 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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/accept-invitation" element={<AcceptInvitation />} />
</Routes>
);
}
// 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 (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Laden...</p>
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600 font-medium">Laden...</p>
</div>
</div>
);
}
// Show login if OAuth is enabled and not authenticated
if (config?.authMethod === 'oauth' && !isAuthenticated) {
return <Login />;
// 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 <Navigate to="/login" replace />;
}
// Show login if nothing is configured
if (config?.authMethod === 'none') {
return <Login />;
// If not authenticated as a real user, redirect to login
if (!isRealUser) {
return <Navigate to="/login" replace />;
}
// 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 <AppContent />;
}

View File

@@ -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<InvitationData | null>(null);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="text-center">
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Uitnodiging valideren...</p>
</div>
</div>
);
}
if (!token || error) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-semibold text-white mb-2">Ongeldige uitnodiging</h2>
<p className="text-slate-400 text-sm mb-6">
{error || 'De uitnodiging is ongeldig of verlopen.'}
</p>
<Link
to="/login"
className="inline-block px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium rounded-lg transition-all duration-200"
>
Terug naar inloggen
</Link>
</div>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold text-white mb-2">Account geactiveerd</h2>
<p className="text-slate-400 text-sm">
Je account is succesvol geactiveerd. Je wordt doorgestuurd naar de login pagina...
</p>
</div>
</div>
</div>
</div>
);
}
return (
<AuthLayout>
<h2 className="text-2xl font-semibold text-gray-900 mb-2 text-center">Welkom</h2>
<p className="text-gray-600 text-sm mb-6 text-center">
Stel je wachtwoord in om je account te activeren
</p>
{invitationData && (
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<p className="text-gray-700 text-sm mb-1">
<span className="font-semibold">E-mail:</span> {invitationData.user.email}
</p>
<p className="text-gray-700 text-sm">
<span className="font-semibold">Gebruikersnaam:</span> {invitationData.user.username}
</p>
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
Wachtwoord
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => 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 && (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
passwordStrength.color === 'red'
? 'bg-red-500'
: passwordStrength.color === 'yellow'
? 'bg-yellow-500'
: 'bg-green-500'
}`}
style={{ width: `${(passwordStrength.strength / 6) * 100}%` }}
/>
</div>
<span className={`text-xs font-medium ${
passwordStrength.color === 'red'
? 'text-red-600'
: passwordStrength.color === 'yellow'
? 'text-yellow-600'
: 'text-green-600'
}`}>
{passwordStrength.label}
</span>
</div>
</div>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-semibold text-gray-700 mb-2">
Bevestig wachtwoord
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-600">Wachtwoorden komen niet overeen</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting || password !== confirmPassword}
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
>
{isSubmitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Account activeren...
</span>
) : (
'Account activeren'
)}
</button>
</form>
</AuthLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -569,7 +569,7 @@ export default function ApplicationList() {
);
}
export function StatusBadge({ status }: { status: string | null }) {
export function StatusBadge({ status, variant = 'default' }: { status: string | null; variant?: 'default' | 'header' }) {
const statusColors: Record<string, string> = {
'Closed': 'badge-dark-red',
'Deprecated': 'badge-yellow',
@@ -582,7 +582,38 @@ export function StatusBadge({ status }: { status: string | null }) {
'Undefined': 'badge-gray',
};
if (!status) return <span className="text-sm text-gray-400">-</span>;
// Header variant colors - matching blue/indigo palette of the header
const headerStatusColors: Record<string, { bg: string; text: string }> = {
'Closed': { bg: 'bg-slate-600', text: 'text-white' },
'Deprecated': { bg: 'bg-amber-500', text: 'text-white' },
'End of life': { bg: 'bg-red-500', text: 'text-white' },
'End of support': { bg: 'bg-red-400', text: 'text-white' },
'Implementation': { bg: 'bg-blue-500', text: 'text-white' },
'In Production': { bg: 'bg-emerald-600', text: 'text-white' },
'Proof of Concept': { bg: 'bg-teal-500', text: 'text-white' },
'Shadow IT': { bg: 'bg-slate-800', text: 'text-white' },
'Undefined': { bg: 'bg-slate-400', text: 'text-white' },
};
if (!status) {
if (variant === 'header') {
return <span className="text-lg lg:text-xl text-white/70">-</span>;
}
return <span className="text-sm text-gray-400">-</span>;
}
if (variant === 'header') {
const colors = headerStatusColors[status] || { bg: 'bg-slate-400', text: 'text-white' };
return (
<span className={clsx(
'inline-flex items-center px-4 py-1.5 rounded-lg text-base lg:text-lg font-semibold backdrop-blur-sm shadow-sm',
colors.bg,
colors.text
)}>
{status}
</span>
);
}
return (
<span className={clsx('badge', statusColors[status] || 'badge-gray')}>

View File

@@ -0,0 +1,60 @@
/**
* Shared layout component for authentication pages
* Provides consistent styling and structure
*/
import { useAuthStore } from '../stores/authStore';
interface AuthLayoutProps {
children: React.ReactNode;
title?: string;
subtitle?: string;
}
export default function AuthLayout({ children, title, subtitle }: AuthLayoutProps) {
const { config } = useAuthStore();
// Use config values if title/subtitle not provided
const appName = title || config?.appName || 'CMDB Insight';
const appTagline = subtitle || config?.appTagline || 'Management console for Jira Assets';
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center p-4">
{/* Background Pattern */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-blue-100 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-cyan-100 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-2000"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-indigo-100 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-4000"></div>
</div>
<div className="w-full max-w-md relative z-10">
{/* Logo / Header */}
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center mb-6">
<img
src="/logo-zuyderland.svg"
alt="Zuyderland"
className="h-16 w-auto"
/>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{appName}</h1>
{appTagline && (
<p className="text-gray-600 text-lg">{appTagline}</p>
)}
</div>
{/* Content Card */}
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-2xl">
{children}
</div>
{/* Footer */}
<div className="mt-8 text-center">
<p className="text-gray-500 text-sm">
{config?.appCopyright?.replace('{year}', new Date().getFullYear().toString()) || `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`}
</p>
<p className="text-gray-400 text-xs mt-1">{appName} v1.0</p>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts';
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { searchApplications } from '../services/api';
import type { ApplicationListItem, ReferenceValue, ApplicationStatus } from '../types';
import type { ReferenceValue, ApplicationStatus } from '../types';
import { Link } from 'react-router-dom';
const ALL_STATUSES: ApplicationStatus[] = [
@@ -116,7 +116,7 @@ export default function ComplexityDynamicsBubbleChart() {
x: complexity,
y: dynamics,
z: Math.max(0.1, fte), // Minimum size for visibility
bia: app.businessImpactAnalyse?.name || null,
bia: app.businessImpactAnalyse?.name || '',
biaId: app.businessImpactAnalyse?.objectId || 'none',
name: app.name,
id: app.id,

View File

@@ -176,7 +176,7 @@ export default function Configuration() {
<div className="space-y-4">
{[...config.governanceModelRules]
.sort((a, b) => a.governanceModel.localeCompare(b.governanceModel))
.map((rule, originalIndex) => {
.map((rule) => {
// Find the original index in the unsorted array
const index = config.governanceModelRules.findIndex(r => r === rule);
return (
@@ -329,8 +329,9 @@ function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting
// Check if rule is a simple EffortRule or ApplicationTypeRule
const isSimpleRule = 'result' in rule && !('applicationTypes' in rule);
if (isSimpleRule) {
if (isSimpleRule && typeof rule === 'object' && 'result' in rule) {
// Simple EffortRule
const simpleRule = rule as { result: number };
return (
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
<div className="flex items-center justify-between">
@@ -339,8 +340,8 @@ function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting
<input
type="number"
step="0.01"
value={rule.result}
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
value={simpleRule.result}
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 } as any)}
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-500">FTE</span>
@@ -485,7 +486,7 @@ function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting
newRules[index] = { ...newRules[index], ...updates };
updateDefaultRule(newRules);
}}
onRemove={rule.default.length > 1 ? () => {
onRemove={rule.default && Array.isArray(rule.default) && rule.default.length > 1 ? () => {
const newRules = (rule.default as any[]).filter((_, i) => i !== index);
updateDefaultRule(newRules.length === 1 ? newRules[0] : newRules);
} : undefined}

View File

@@ -10,7 +10,6 @@ import {
type GovernanceModelConfigV25,
type ApplicationTypeConfigV25,
type BIALevelConfig,
type FTERange,
} from '../services/api';
import type { ReferenceValue } from '../types';
@@ -25,7 +24,7 @@ export default function ConfigurationV25() {
const [hostingOptions, setHostingOptions] = useState<ReferenceValue[]>([]);
const [applicationTypeOptions, setApplicationTypeOptions] = useState<ReferenceValue[]>([]);
const [biaOptions, setBiaOptions] = useState<ReferenceValue[]>([]);
const [governanceOptions, setGovernanceOptions] = useState<ReferenceValue[]>([]);
const [, setGovernanceOptions] = useState<ReferenceValue[]>([]);
useEffect(() => {
loadConfig();
@@ -241,7 +240,7 @@ function RegieModelEditor({
code,
model,
hostingOptions,
applicationTypeOptions,
applicationTypeOptions: _appTypes,
biaOptions,
onUpdate,
onUpdateAppType
@@ -325,7 +324,7 @@ interface ApplicationTypeEditorProps {
onUpdate: (updates: Partial<ApplicationTypeConfigV25>) => void;
}
function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions, onUpdate }: ApplicationTypeEditorProps) {
function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions: _bia, onUpdate }: ApplicationTypeEditorProps) {
const [expanded, setExpanded] = useState(false);
return (
@@ -445,7 +444,7 @@ interface BIALevelEditorProps {
onUpdate: (updates: Partial<BIALevelConfig>) => void;
}
function BIALevelEditor({ biaLevel, config, hostingOptions, onUpdate }: BIALevelEditorProps) {
function BIALevelEditor({ biaLevel, config, hostingOptions: _hosting, onUpdate }: BIALevelEditorProps) {
const [expanded, setExpanded] = useState(false);
return (

View File

@@ -51,7 +51,7 @@ function generateId(): string {
export default function DataCompletenessConfig() {
const [config, setConfig] = useState<DataCompletenessConfig | null>(null);
const [schema, setSchema] = useState<SchemaResponse | null>(null);
const [, setSchema] = useState<SchemaResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getDataCompletenessConfig, type DataCompletenessConfig } from '../services/api';
import type { DataCompletenessConfig } from '../services/api';
interface FieldCompleteness {
field: string;
@@ -79,19 +79,18 @@ export default function DataCompletenessScore() {
setError(null);
try {
// Fetch config and data in parallel
const [configResult, dataResponse] = await Promise.all([
getDataCompletenessConfig(),
fetch(`${API_BASE}/dashboard/data-completeness`)
]);
setConfig(configResult);
// Fetch data (config is included in the response)
const dataResponse = await fetch(`${API_BASE}/dashboard/data-completeness`);
if (!dataResponse.ok) {
throw new Error('Failed to fetch data completeness data');
}
const result = await dataResponse.json();
setData(result);
// Config is now included in the response
if (result.config) {
setConfig(result.config);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {

View File

@@ -44,7 +44,7 @@ export function EffortDisplay({
onOverrideChange,
}: EffortDisplayProps) {
const hasOverride = overrideFte !== null && overrideFte !== undefined;
const hasBreakdown = breakdown !== null && breakdown !== undefined;
// const hasBreakdown = breakdown !== null && breakdown !== undefined; // Unused
// Extract breakdown values
const baseEffort = breakdown?.baseEffort ?? null;

View File

@@ -0,0 +1,121 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import AuthLayout from './AuthLayout';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/api/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to send password reset email');
}
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<AuthLayout title="Wachtwoord vergeten" subtitle="Herstel je wachtwoord">
{success ? (
<div className="space-y-5">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-green-800 text-sm">
Als er een account bestaat met dit e-mailadres, is er een wachtwoord reset link verzonden.
</p>
</div>
<Link
to="/login"
className="block w-full text-center px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
>
Terug naar inloggen
</Link>
</div>
) : (
<>
<p className="text-gray-600 text-sm mb-6 text-center">
Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
E-mailadres
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
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="jouw@email.nl"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
>
{isSubmitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verzenden...
</span>
) : (
'Verstuur reset link'
)}
</button>
</form>
<div className="mt-6 text-center">
<Link
to="/login"
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
>
Terug naar inloggen
</Link>
</div>
</>
)}
</AuthLayout>
);
}

View File

@@ -54,7 +54,7 @@ export default function GovernanceModelBadge({
className = '',
}: GovernanceModelBadgeProps) {
const [isHovered, setIsHovered] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const style = getGovernanceModelStyle(governanceModelName);

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { clsx } from 'clsx';
import { useAuthStore } from '../stores/authStore';
import {
getApplicationForEdit,
updateApplication,
@@ -64,7 +65,7 @@ export default function GovernanceModelHelper() {
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
const [applicationFunctions, setApplicationFunctions] = useState<ReferenceValue[]>([]);
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
const [applicationTeams, setApplicationTeams] = useState<ReferenceValue[]>([]);
const [, setApplicationTeams] = useState<ReferenceValue[]>([]);
const [subteamToTeamMapping, setSubteamToTeamMapping] = useState<Record<string, ReferenceValue | null>>({});
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
@@ -268,11 +269,12 @@ export default function GovernanceModelHelper() {
// Set page title
useEffect(() => {
const appName = useAuthStore.getState().config?.appName || 'CMDB Insight';
if (application) {
document.title = `${application.name} - Bewerken | Zuyderland CMDB`;
document.title = `${application.name} - Bewerken | ${appName}`;
}
return () => {
document.title = 'Zuyderland CMDB';
document.title = appName;
};
}, [application]);
@@ -723,7 +725,7 @@ export default function GovernanceModelHelper() {
const selectedHasPrimary = aiPrimaryCode && selectedFunctions.some(
(f) => f.key === aiPrimaryCode
);
const source = aiSuggestion
const source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL' = aiSuggestion
? selectedHasPrimary
? 'AI_ACCEPTED'
: 'AI_MODIFIED'
@@ -809,7 +811,7 @@ export default function GovernanceModelHelper() {
const selectedHasPrimary = aiPrimaryCode && selectedFunctions.some(
(f) => f.key === aiPrimaryCode
);
const source = aiSuggestion
const source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL' = aiSuggestion
? selectedHasPrimary
? 'AI_ACCEPTED'
: 'AI_MODIFIED'

View File

@@ -1,8 +1,15 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore, getLoginUrl } from '../stores/authStore';
import AuthLayout from './AuthLayout';
export default function Login() {
const { config, error, isLoading, fetchConfig, checkAuth } = useAuthStore();
const { config, error, isLoading, isAuthenticated, fetchConfig, checkAuth, localLogin, setError } = useAuthStore();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [authChoice, setAuthChoice] = useState<'local' | 'oauth' | null>(null);
useEffect(() => {
fetchConfig();
@@ -15,59 +22,212 @@ export default function Login() {
if (loginSuccess === 'success') {
// Remove query params and check auth
window.history.replaceState({}, '', window.location.pathname);
checkAuth();
checkAuth().then(() => {
// After checkAuth completes, redirect if authenticated
const state = useAuthStore.getState();
if (state.isAuthenticated && state.user) {
navigate('/', { replace: true });
}
});
}
if (loginError) {
useAuthStore.getState().setError(decodeURIComponent(loginError));
setError(decodeURIComponent(loginError));
window.history.replaceState({}, '', window.location.pathname);
}
}, [fetchConfig, checkAuth]);
// Auto-select auth method if only one is available
if (config) {
if (config.localAuthEnabled && !config.oauthEnabled) {
setAuthChoice('local');
} else if (config.oauthEnabled && !config.localAuthEnabled) {
setAuthChoice('oauth');
}
}
}, [fetchConfig, checkAuth, setError, config, navigate]);
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
navigate('/', { replace: true });
}
}, [isAuthenticated, navigate]);
const handleJiraLogin = () => {
window.location.href = getLoginUrl();
};
const handleLocalLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await localLogin(email, password);
// Success - checkAuth will be called automatically
await checkAuth();
// Redirect to dashboard after successful login
navigate('/', { replace: true });
} catch (err) {
// Error is already set in the store
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Laden...</p>
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600 font-medium">Laden...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo / Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl mb-4 shadow-lg shadow-cyan-500/25">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h1 className="text-2xl font-bold text-white mb-2">CMDB Editor</h1>
<p className="text-slate-400">ZiRA Classificatie Tool</p>
</div>
const showLocalAuth = config?.localAuthEnabled;
const showOAuth = config?.oauthEnabled;
const showBoth = showLocalAuth && showOAuth;
{/* Login Card */}
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
<h2 className="text-xl font-semibold text-white mb-6 text-center">Inloggen</h2>
// PAT mode should NOT be shown to users - it's only for backend configuration
// Always show login form by default - local auth should be available even if not explicitly enabled
// (users can be created and local auth will be auto-enabled when first user exists)
// Only hide login form if explicitly disabled via config
const shouldShowLogin = showLocalAuth !== false; // Default to true unless explicitly false
const shouldShowLocalLogin = showLocalAuth !== false; // Always show local login unless explicitly disabled
// Debug logging
console.log('[Login] Config:', {
authMethod: config?.authMethod,
localAuthEnabled: config?.localAuthEnabled,
oauthEnabled: config?.oauthEnabled,
shouldShowLogin,
shouldShowLocalLogin,
showLocalAuth,
showOAuth,
});
return (
<AuthLayout>
<h2 className="text-2xl font-semibold text-gray-900 mb-2 text-center">Welkom terug</h2>
<p className="text-sm text-gray-600 text-center mb-8">Log in om toegang te krijgen tot de applicatie</p>
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
{config?.authMethod === 'oauth' ? (
{/* Auth Method Selection (if both are available) */}
{showBoth && !authChoice && shouldShowLogin && (
<div className="mb-6">
<p className="text-sm text-gray-600 text-center mb-4">Kies een inlogmethode:</p>
<div className="space-y-3">
<button
onClick={() => setAuthChoice('local')}
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-white border-2 border-gray-300 hover:border-blue-500 text-gray-700 hover:text-blue-700 font-medium rounded-xl transition-all duration-200 shadow-sm hover:shadow-md group"
>
<svg className="w-5 h-5 group-hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Lokaal Inloggen</span>
<span className="text-xs text-gray-500 ml-auto">E-mail & Wachtwoord</span>
</button>
<button
onClick={() => setAuthChoice('oauth')}
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.005 1.005 0 0 0 23.013 0z"/>
</svg>
<span>Inloggen met Jira</span>
<span className="text-xs text-blue-200 ml-auto">OAuth 2.0</span>
</button>
</div>
</div>
)}
{/* Local Login Form - Always show unless explicitly disabled */}
{/* Show if: user selected local, OR local auth is enabled, OR no auth is configured (default to local) */}
{(authChoice === 'local' || shouldShowLocalLogin || (!showOAuth && !config?.authMethod)) && (
<form onSubmit={handleLocalLogin} className="space-y-5">
<div>
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
E-mailadres of gebruikersnaam
</label>
<input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
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="jouw@email.nl of gebruikersnaam"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
Wachtwoord
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-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="••••••••"
/>
</div>
<div className="flex items-center justify-between">
<Link
to="/forgot-password"
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
>
Wachtwoord vergeten?
</Link>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
>
{isSubmitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Inloggen...
</span>
) : (
'Inloggen'
)}
</button>
{showBoth && (
<button
type="button"
onClick={() => setAuthChoice(null)}
className="w-full text-sm text-gray-600 hover:text-gray-900 font-medium transition-colors mt-2"
>
Terug naar keuze
</button>
)}
</form>
)}
{/* OAuth Login */}
{(authChoice === 'oauth' || (showOAuth && !showLocalAuth)) && (
<>
<button
onClick={handleJiraLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white font-medium rounded-xl transition-all duration-200 shadow-lg shadow-blue-600/25 hover:shadow-blue-500/40"
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.005 1.005 0 0 0 23.013 0z"/>
@@ -75,49 +235,53 @@ export default function Login() {
Inloggen met Jira
</button>
<p className="mt-4 text-center text-slate-500 text-sm">
<p className="mt-4 text-center text-gray-600 text-sm">
Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
</p>
</>
) : config?.authMethod === 'pat' ? (
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-slate-300 mb-2">Personal Access Token Modus</p>
<p className="text-slate-500 text-sm">
De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
</p>
{showBoth && (
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
type="button"
onClick={() => setAuthChoice(null)}
className="w-full text-sm text-gray-600 hover:text-gray-900 font-medium transition-colors mt-4"
>
Doorgaan
Terug naar keuze
</button>
</div>
) : (
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
)}
</>
)}
{/* Not Configured - Only show if both auth methods are explicitly disabled */}
{/* PAT mode should NEVER be shown - it's only for backend Jira API configuration */}
{/* Users always authenticate via local auth or OAuth */}
{config?.localAuthEnabled === false && config?.oauthEnabled === false && (
<div className="text-center py-6">
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
<p className="text-slate-500 text-sm">
Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.
<p className="text-gray-900 font-semibold mb-2">Authenticatie niet geconfigureerd</p>
<p className="text-gray-600 text-sm mb-4">
Lokale authenticatie of OAuth moet worden ingeschakeld om gebruikers in te laten loggen.
Neem contact op met de beheerder om authenticatie te configureren.
</p>
</div>
)}
</div>
{/* Footer */}
<p className="mt-8 text-center text-slate-600 text-sm">
Zuyderland Medisch Centrum CMDB Editor v1.0
{/* Not Configured */}
{config?.authMethod === 'none' && (
<div className="text-center py-6">
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-gray-900 font-semibold mb-2">Niet geconfigureerd</p>
<p className="text-gray-600 text-sm">
Neem contact op met de beheerder om authenticatie te configureren.
</p>
</div>
</div>
)}
</AuthLayout>
);
}

View File

@@ -0,0 +1,305 @@
import { useState, useEffect } from 'react';
import { useAuthStore } from '../stores/authStore';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface Profile {
id: number;
email: string;
username: string;
display_name: string | null;
email_verified: boolean;
created_at: string;
last_login: string | null;
}
export default function Profile() {
const { user } = useAuthStore();
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
useEffect(() => {
fetchProfile();
}, []);
const fetchProfile = async () => {
try {
const response = await fetch(`${API_BASE}/api/profile`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch profile');
const data = await response.json();
setProfile(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load profile');
} finally {
setIsLoading(false);
}
};
const handleUpdateProfile = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSaving(true);
setError(null);
setSuccess(null);
try {
const formData = new FormData(e.currentTarget);
const updates = {
username: formData.get('username') as string,
display_name: formData.get('display_name') as string || null,
};
const response = await fetch(`${API_BASE}/api/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updates),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update profile');
}
const data = await response.json();
setProfile(data);
setSuccess('Profiel bijgewerkt');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
} finally {
setIsSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSaving(true);
setError(null);
setSuccess(null);
try {
const formData = new FormData(e.currentTarget);
const currentPassword = formData.get('current_password') as string;
const newPassword = formData.get('new_password') as string;
const confirmPassword = formData.get('confirm_password') as string;
if (newPassword !== confirmPassword) {
throw new Error('Wachtwoorden komen niet overeen');
}
const response = await fetch(`${API_BASE}/api/profile/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to change password');
}
setSuccess('Wachtwoord gewijzigd');
setShowPasswordModal(false);
(e.target as HTMLFormElement).reset();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (!profile) {
return <div>Failed to load profile</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Profiel</h1>
<p className="text-gray-600 mt-1">Beheer je profielgegevens</p>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">{error}</p>
</div>
)}
{success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800">{success}</p>
</div>
)}
<div className="bg-white rounded-lg shadow p-6 space-y-6">
{/* Profile Information */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Profielgegevens</h2>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">E-mail</label>
<input
type="email"
value={profile.email}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
/>
<p className="mt-1 text-sm text-gray-500">
E-mailadres kan niet worden gewijzigd
{profile.email_verified ? (
<span className="ml-2 text-green-600"> Geverifieerd</span>
) : (
<span className="ml-2 text-yellow-600"> Niet geverifieerd</span>
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Gebruikersnaam</label>
<input
type="text"
name="username"
defaultValue={profile.username}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Weergavenaam</label>
<input
type="text"
name="display_name"
defaultValue={profile.display_name || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSaving ? 'Opslaan...' : 'Opslaan'}
</button>
</div>
</form>
</div>
{/* Account Information */}
<div className="border-t border-gray-200 pt-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Accountinformatie</h2>
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">Account aangemaakt</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(profile.created_at).toLocaleDateString('nl-NL')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Laatste login</dt>
<dd className="mt-1 text-sm text-gray-900">
{profile.last_login
? new Date(profile.last_login).toLocaleDateString('nl-NL')
: 'Nog niet ingelogd'}
</dd>
</div>
</dl>
</div>
{/* Password Change */}
<div className="border-t border-gray-200 pt-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Wachtwoord</h2>
<button
onClick={() => setShowPasswordModal(true)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Wachtwoord wijzigen
</button>
</div>
</div>
{/* Change Password Modal */}
{showPasswordModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Wachtwoord wijzigen</h2>
<form onSubmit={handleChangePassword}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Huidig wachtwoord
</label>
<input
type="password"
name="current_password"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nieuw wachtwoord
</label>
<input
type="password"
name="new_password"
required
minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bevestig nieuw wachtwoord
</label>
<input
type="password"
name="confirm_password"
required
minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="submit"
disabled={isSaving}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isSaving ? 'Wijzigen...' : 'Wijzigen'}
</button>
<button
type="button"
onClick={() => {
setShowPasswordModal(false);
setError(null);
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Annuleren
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
/**
* Protected Route Component
*
* Wrapper component for routes requiring authentication and/or permissions.
*/
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { useHasPermission, useHasRole } from '../hooks/usePermissions';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAuth?: boolean;
requirePermission?: string;
requireRole?: string;
requireAnyPermission?: string[];
requireAllPermissions?: string[];
}
export default function ProtectedRoute({
children,
requireAuth = true,
requirePermission,
requireRole,
requireAnyPermission,
requireAllPermissions,
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore();
const hasPermission = useHasPermission(requirePermission || '');
const hasRole = useHasRole(requireRole || '');
const hasAnyPermission = requireAnyPermission
? requireAnyPermission.some(p => useHasPermission(p))
: true;
const hasAllPermissions = requireAllPermissions
? requireAllPermissions.every(p => useHasPermission(p))
: true;
const location = useLocation();
// Show loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Laden...</p>
</div>
</div>
);
}
// Check authentication
if (requireAuth && !isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Check role
if (requireRole && !hasRole) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
<p className="text-gray-600 mb-4">Je hebt niet de juiste rol om deze pagina te bekijken.</p>
<p className="text-sm text-gray-500">Vereiste rol: {requireRole}</p>
</div>
</div>
);
}
// Check permission
if (requirePermission && !hasPermission) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
<p className="text-gray-600 mb-4">Je hebt niet de juiste rechten om deze pagina te bekijken.</p>
<p className="text-sm text-gray-500">Vereiste rechten: {requirePermission}</p>
</div>
</div>
);
}
// Check any permission
if (requireAnyPermission && !hasAnyPermission) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
<p className="text-gray-600 mb-4">Je hebt niet de juiste rechten om deze pagina te bekijken.</p>
<p className="text-sm text-gray-500">Vereiste rechten (één van): {requireAnyPermission.join(', ')}</p>
</div>
</div>
);
}
// Check all permissions
if (requireAllPermissions && !hasAllPermissions) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
<p className="text-gray-600 mb-4">Je hebt niet alle vereiste rechten om deze pagina te bekijken.</p>
<p className="text-sm text-gray-500">Vereiste rechten (alle): {requireAllPermissions.join(', ')}</p>
</div>
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,222 @@
import { useState, useEffect } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError('Geen reset token gevonden in de URL');
}
}, [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/reset-password`, {
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 reset password');
}
setSuccess(true);
setTimeout(() => {
navigate('/login');
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
if (!token) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-semibold text-white mb-2">Ongeldige link</h2>
<p className="text-slate-400 text-sm mb-6">
De reset link is ongeldig of ontbreekt.
</p>
<Link
to="/login"
className="inline-block px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium rounded-lg transition-all duration-200"
>
Terug naar inloggen
</Link>
</div>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold text-white mb-2">Wachtwoord gereset</h2>
<p className="text-slate-400 text-sm">
Je wachtwoord is succesvol gereset. Je wordt doorgestuurd naar de login pagina...
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
<h2 className="text-xl font-semibold text-white mb-6 text-center">Nieuw wachtwoord instellen</h2>
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-300 mb-2">
Nieuw wachtwoord
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="••••••••"
/>
{passwordStrength && (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-slate-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
passwordStrength.color === 'red'
? 'bg-red-500'
: passwordStrength.color === 'yellow'
? 'bg-yellow-500'
: 'bg-green-500'
}`}
style={{ width: `${(passwordStrength.strength / 6) * 100}%` }}
/>
</div>
<span className={`text-xs ${
passwordStrength.color === 'red'
? 'text-red-400'
: passwordStrength.color === 'yellow'
? 'text-yellow-400'
: 'text-green-400'
}`}>
{passwordStrength.label}
</span>
</div>
</div>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-300 mb-2">
Bevestig wachtwoord
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="••••••••"
/>
{confirmPassword && password !== confirmPassword && (
<p className="mt-1 text-sm text-red-400">Wachtwoorden komen niet overeen</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting || password !== confirmPassword}
className="w-full px-4 py-3 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium rounded-xl transition-all duration-200 shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Wachtwoord resetten...' : 'Wachtwoord resetten'}
</button>
</form>
<div className="mt-6 text-center">
<Link
to="/login"
className="text-sm text-cyan-400 hover:text-cyan-300 transition-colors"
>
Terug naar inloggen
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,410 @@
import { useState, useEffect } from 'react';
import { useHasPermission } from '../hooks/usePermissions';
import ProtectedRoute from './ProtectedRoute';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface Role {
id: number;
name: string;
description: string | null;
is_system_role: boolean;
created_at: string;
permissions: Array<{ id: number; name: string; description: string | null; resource: string | null }>;
}
interface Permission {
id: number;
name: string;
description: string | null;
resource: string | null;
}
export default function RoleManagement() {
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [showPermissionModal, setShowPermissionModal] = useState(false);
const hasManageRoles = useHasPermission('manage_roles');
useEffect(() => {
if (hasManageRoles) {
fetchRoles();
fetchPermissions();
}
}, [hasManageRoles]);
const fetchRoles = async () => {
try {
const response = await fetch(`${API_BASE}/api/roles`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch roles');
const data = await response.json();
setRoles(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load roles');
} finally {
setIsLoading(false);
}
};
const fetchPermissions = async () => {
try {
const response = await fetch(`${API_BASE}/api/roles/permissions/all`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch permissions');
const data = await response.json();
setPermissions(data);
} catch (err) {
console.error('Failed to fetch permissions:', err);
}
};
const handleCreateRole = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const description = formData.get('description') as string;
try {
const response = await fetch(`${API_BASE}/api/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, description: description || null }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create role');
}
setShowCreateModal(false);
fetchRoles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create role');
}
};
const handleUpdateRole = async (roleId: number, name: string, description: string) => {
try {
const response = await fetch(`${API_BASE}/api/roles/${roleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, description: description || null }),
});
if (!response.ok) throw new Error('Failed to update role');
fetchRoles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const handleDeleteRole = async (roleId: number) => {
if (!confirm('Are you sure you want to delete this role?')) return;
try {
const response = await fetch(`${API_BASE}/api/roles/${roleId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete role');
fetchRoles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete role');
}
};
const handleAssignPermission = async (roleId: number, permissionId: number) => {
try {
const response = await fetch(`${API_BASE}/api/roles/${roleId}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ permission_id: permissionId }),
});
if (!response.ok) throw new Error('Failed to assign permission');
fetchRoles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to assign permission');
}
};
const handleRemovePermission = async (roleId: number, permissionId: number) => {
try {
const response = await fetch(`${API_BASE}/api/roles/${roleId}/permissions/${permissionId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to remove permission');
fetchRoles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove permission');
}
};
if (!hasManageRoles) {
return (
<ProtectedRoute requirePermission="manage_roles">
<div>Access denied</div>
</ProtectedRoute>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rollenbeheer</h1>
<p className="text-gray-600 mt-1">Beheer rollen en rechten</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+ Nieuwe rol
</button>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">{error}</p>
</div>
)}
<div className="bg-white rounded-lg shadow">
{isLoading ? (
<div className="p-8 text-center">
<div className="inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-2 text-gray-600">Laden...</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rol</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rechten</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Acties</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{roles.map((role) => (
<tr key={role.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="font-medium text-gray-900">{role.name}</div>
{role.description && (
<div className="text-sm text-gray-500">{role.description}</div>
)}
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{role.permissions.map((perm) => (
<span
key={perm.id}
className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800"
>
{perm.name}
{!role.is_system_role && (
<button
onClick={() => handleRemovePermission(role.id, perm.id)}
className="ml-1 text-green-600 hover:text-green-800"
>
×
</button>
)}
</span>
))}
{!role.is_system_role && (
<select
onChange={(e) => {
if (e.target.value) {
handleAssignPermission(role.id, parseInt(e.target.value));
e.target.value = '';
}
}}
className="text-xs border border-gray-300 rounded px-1 py-0.5"
>
<option value="">+ Recht</option>
{permissions
.filter((perm) => !role.permissions.some((rp) => rp.id === perm.id))
.map((perm) => (
<option key={perm.id} value={perm.id}>
{perm.name}
</option>
))}
</select>
)}
</div>
</td>
<td className="px-6 py-4">
{role.is_system_role ? (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
Systeem
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
Aangepast
</span>
)}
</td>
<td className="px-6 py-4">
{!role.is_system_role && (
<div className="flex gap-2">
<button
onClick={() => {
setSelectedRole(role);
setShowPermissionModal(true);
}}
className="text-sm text-blue-600 hover:text-blue-800"
>
Bewerken
</button>
<button
onClick={() => handleDeleteRole(role.id)}
className="text-sm text-red-600 hover:text-red-800"
>
Verwijderen
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Create Role Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Nieuwe rol</h2>
<form onSubmit={handleCreateRole}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Naam</label>
<input
type="text"
name="name"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschrijving</label>
<textarea
name="description"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Aanmaken
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Annuleren
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Role Permissions Modal */}
{showPermissionModal && selectedRole && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Rechten beheren: {selectedRole.name}</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2">Toegewezen rechten</h3>
<div className="flex flex-wrap gap-2">
{selectedRole.permissions.map((perm) => (
<span
key={perm.id}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800"
>
{perm.name}
<button
onClick={() => {
handleRemovePermission(selectedRole.id, perm.id);
setSelectedRole({
...selectedRole,
permissions: selectedRole.permissions.filter((p) => p.id !== perm.id),
});
}}
className="ml-2 text-green-600 hover:text-green-800"
>
×
</button>
</span>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2">Beschikbare rechten</h3>
<div className="grid grid-cols-2 gap-2">
{permissions
.filter((perm) => !selectedRole.permissions.some((rp) => rp.id === perm.id))
.map((perm) => (
<button
key={perm.id}
onClick={() => {
handleAssignPermission(selectedRole.id, perm.id);
setSelectedRole({
...selectedRole,
permissions: [...selectedRole.permissions, perm],
});
}}
className="text-left px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
<div className="font-medium text-sm">{perm.name}</div>
{perm.description && (
<div className="text-xs text-gray-500">{perm.description}</div>
)}
</button>
))}
</div>
</div>
</div>
<div className="mt-6">
<button
onClick={() => {
setShowPermissionModal(false);
setSelectedRole(null);
}}
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Sluiten
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
const ITEMS_PER_PAGE = 25;
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent';
const APPLICATION_COMPONENT_JIRA_NAME = 'Application Component'; // Jira API returns this name with space
// Helper to strip HTML tags from description
function stripHtml(html: string): string {
@@ -43,6 +44,74 @@ function getStatusInfo(status: string | null): { color: string; bg: string } {
return { color: 'text-gray-600', bg: 'bg-gray-100' };
}
// Helper to get icon component for object type
function getObjectTypeIcon(objectTypeName: string | undefined, className: string = "w-6 h-6") {
const name = objectTypeName?.toLowerCase() || '';
// Application Component - application window icon
if (name.includes('application component') || name === 'applicationcomponent') {
return (
<svg className={`${className} text-blue-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
{/* Window frame */}
<rect x="3" y="4" width="18" height="14" rx="2" />
{/* Title bar separator */}
<line x1="3" y1="8" x2="21" y2="8" />
{/* Window control dots */}
<circle cx="6" cy="6" r="0.8" fill="currentColor" />
<circle cx="9" cy="6" r="0.8" fill="currentColor" />
<circle cx="12" cy="6" r="0.8" fill="currentColor" />
{/* Content lines */}
<line x1="5" y1="11" x2="19" y2="11" strokeWidth="1.5" />
<line x1="5" y1="14" x2="15" y2="14" strokeWidth="1.5" />
<line x1="5" y1="17" x2="17" y2="17" strokeWidth="1.5" />
</svg>
);
}
// Flows - pijlen/flow icoon
if (name.includes('flow')) {
return (
<svg className={`${className} text-indigo-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
);
}
// Server - server icoon
if (name.includes('server')) {
return (
<svg className={`${className} text-green-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
);
}
// Application Function - functie/doel icoon (bar chart)
if (name.includes('application function') || name.includes('function')) {
return (
<svg className={`${className} text-purple-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
);
}
// User/Privileged User - gebruiker icoon
if (name.includes('user') || name.includes('privileged')) {
return (
<svg className={`${className} text-orange-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
);
}
// Default - generiek object icoon (grid)
return (
<svg className={`${className} text-gray-500`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
);
}
export default function SearchDashboard() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
@@ -90,11 +159,20 @@ export default function SearchDashboard() {
return map;
}, [searchResults]);
// Get sorted object types (by result count, descending)
// Get sorted object types (Application Component first if exists, then by result count, descending)
const sortedObjectTypes = useMemo(() => {
if (!searchResults?.objectTypes) return [];
return [...searchResults.objectTypes].sort((a, b) => {
// Check if either is Application Component
const aIsAppComponent = a.name === APPLICATION_COMPONENT_TYPE_NAME || a.name === APPLICATION_COMPONENT_JIRA_NAME;
const bIsAppComponent = b.name === APPLICATION_COMPONENT_TYPE_NAME || b.name === APPLICATION_COMPONENT_JIRA_NAME;
// If one is Application Component and the other isn't, Application Component comes first
if (aIsAppComponent && !bIsAppComponent) return -1;
if (!aIsAppComponent && bIsAppComponent) return 1;
// Otherwise, sort by count (descending)
const countA = resultsByType.get(a.id)?.length || 0;
const countB = resultsByType.get(b.id)?.length || 0;
return countB - countA;
@@ -178,8 +256,18 @@ export default function SearchDashboard() {
setSearchResults(results);
// Auto-select first tab if results exist
// Prioritize Application Component if it exists, otherwise select the tab with most results
if (results.objectTypes && results.objectTypes.length > 0) {
// Sort by count and select the first one
// Find Application Component type if it exists
const appComponentType = results.objectTypes.find(
ot => ot.name === APPLICATION_COMPONENT_TYPE_NAME || ot.name === APPLICATION_COMPONENT_JIRA_NAME
);
if (appComponentType) {
// Application Component exists, select it
setSelectedTab(appComponentType.id);
} else {
// No Application Component, sort by count and select the first one
const sorted = [...results.objectTypes].sort((a, b) => {
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
@@ -187,6 +275,7 @@ export default function SearchDashboard() {
});
setSelectedTab(sorted[0].id);
}
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Zoeken mislukt');
@@ -211,9 +300,11 @@ export default function SearchDashboard() {
};
// Helper to check if a result is an Application Component (by looking up type name)
// Jira API returns "Application Component" (with space), but internal typeName is "ApplicationComponent" (no space)
const isApplicationComponent = useCallback((result: CMDBSearchResult) => {
const objectType = objectTypeMap.get(result.objectTypeId);
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME;
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME ||
objectType?.name === APPLICATION_COMPONENT_JIRA_NAME;
}, [objectTypeMap]);
// Handle result click (for Application Components)
@@ -231,25 +322,28 @@ export default function SearchDashboard() {
};
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
{/* Header Section */}
<div className="text-center mb-10 lg:mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 lg:w-20 lg:h-20 bg-gradient-to-br from-blue-600 via-blue-500 to-indigo-600 rounded-2xl mb-6 shadow-xl shadow-blue-500/20">
<svg className="w-8 h-8 lg:w-10 lg:h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-1">CMDB Zoeken</h1>
<p className="text-gray-500 text-sm">
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB.
<h1 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-3 tracking-tight">
CMDB Zoeken
</h1>
<p className="text-base lg:text-lg text-gray-600 max-w-4xl mx-auto">
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB van Zuyderland
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<form onSubmit={handleSearch} className="max-w-4xl mx-auto mb-8">
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none">
<svg className="h-6 w-6 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
@@ -257,64 +351,113 @@ export default function SearchDashboard() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Zoek op naam, key, of beschrijving..."
className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none"
placeholder="Zoek op naam, key, beschrijving of andere attributen..."
className="w-full pl-14 pr-32 py-4 lg:py-5 text-base lg:text-lg border-2 border-gray-200 rounded-2xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100/50 transition-all outline-none shadow-sm hover:shadow-md focus:shadow-lg bg-white"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !searchQuery.trim()}
className="absolute inset-y-1.5 right-1.5 px-5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
className="absolute inset-y-2 right-2 px-6 lg:px-8 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:from-gray-300 disabled:to-gray-400 text-white font-semibold rounded-xl transition-all flex items-center gap-2 shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 disabled:shadow-none disabled:cursor-not-allowed"
>
{loading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
{loading ? (
<>
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="hidden sm:inline">Zoeken...</span>
</>
) : (
<>
<span>Zoeken</span>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
Zoeken
</button>
</div>
{!hasSearched && (
<p className="mt-3 text-sm text-gray-500 text-center">
Tip: Gebruik trefwoorden zoals applicatienaam, object key of beschrijving
</p>
)}
</form>
{/* Loading State */}
{loading && (
<div className="max-w-4xl mx-auto mb-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-6">
<svg className="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Zoeken...</h3>
<p className="text-sm text-gray-600">We zoeken in de CMDB naar "{searchQuery}"</p>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="max-w-4xl mx-auto mb-8">
<div className="bg-red-50 border-l-4 border-red-500 rounded-lg p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
<div>
<h3 className="text-sm font-semibold text-red-800 mb-1">Fout bij zoeken</h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
</div>
)}
{/* Results */}
{/* Results Section */}
{hasSearched && searchResults && !loading && (
<div className="space-y-4">
<div className="max-w-7xl mx-auto">
{/* Results Summary */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
<span className="font-medium">{searchResults.metadata.total}</span> resultaten gevonden
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-4 py-2 bg-white rounded-xl shadow-sm border border-gray-200">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm font-medium text-gray-700">
<span className="text-blue-600 font-semibold">{searchResults.metadata.total}</span> resultaten
</span>
</div>
{searchResults.metadata.total !== searchResults.results.length && (
<span className="text-gray-400"> (eerste {searchResults.results.length} getoond)</span>
<span className="text-sm text-gray-500">
(eerste {searchResults.results.length} getoond)
</span>
)}
</p>
</div>
</div>
{searchResults.results.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="text-center py-16 lg:py-20 bg-white rounded-2xl shadow-sm border border-gray-200">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gray-100 rounded-full mb-6">
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500">Geen resultaten gevonden voor "{searchQuery}"</p>
<p className="text-gray-400 text-sm mt-1">Probeer een andere zoekterm</p>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Geen resultaten gevonden</h3>
<p className="text-gray-600 mb-1">We hebben geen resultaten gevonden voor "<span className="font-medium text-gray-900">"{searchQuery}"</span>"</p>
<p className="text-sm text-gray-500 mt-4">Probeer een andere zoekterm of verfijn je zoekopdracht</p>
</div>
) : (
<>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
{/* Object Type Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs">
<div className="border-b border-gray-200 bg-gray-50/50 px-4 lg:px-6">
<nav className="flex space-x-1 overflow-x-auto pb-0 -mb-px scrollbar-hide" aria-label="Tabs">
{sortedObjectTypes.map((objectType) => {
const count = resultsByType.get(objectType.id)?.length || 0;
const isActive = selectedTab === objectType.id;
@@ -324,27 +467,32 @@ export default function SearchDashboard() {
key={objectType.id}
onClick={() => handleTabChange(objectType.id)}
className={`
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors
flex items-center gap-2.5 whitespace-nowrap py-4 px-5 border-b-2 text-sm font-medium transition-all relative
${isActive
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}
? 'border-blue-600 text-blue-700 bg-white'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300 hover:bg-white/50'}
`}
>
{jiraHost && objectType.iconUrl && (
<img
src={getAvatarUrl(objectType.iconUrl) || ''}
alt=""
className="w-4 h-4"
className="w-5 h-5 flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span>{objectType.name}</span>
<span className="font-medium">{objectType.name}</span>
<span className={`
px-2 py-0.5 text-xs rounded-full
${isActive ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'}
px-2.5 py-1 text-xs font-semibold rounded-full flex-shrink-0
${isActive
? 'bg-blue-100 text-blue-700'
: 'bg-gray-200 text-gray-600'}
`}>
{count}
</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>
)}
</button>
);
})}
@@ -353,12 +501,18 @@ export default function SearchDashboard() {
{/* Status Filter */}
{statusOptions.length > 0 && (
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600">Filter op status:</label>
<div className="px-4 lg:px-6 py-4 bg-white border-b border-gray-100">
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Filter op status:
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none"
className="text-sm border border-gray-300 rounded-lg px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none bg-white font-medium text-gray-700 min-w-[200px]"
>
<option value="">Alle statussen ({currentTabResults.length})</option>
{statusOptions.map(status => {
@@ -377,16 +531,20 @@ export default function SearchDashboard() {
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
className="text-sm text-blue-600 hover:text-blue-700"
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1 px-3 py-2 rounded-lg hover:bg-blue-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Wis filter
</button>
)}
</div>
</div>
)}
{/* Results List */}
<div className="space-y-2">
<div className="divide-y divide-gray-100">
{paginatedResults.map((result) => {
const status = getAttributeValue(result, 'Status');
// Handle status objects with nested structure (null check required because typeof null === 'object')
@@ -402,62 +560,64 @@ export default function SearchDashboard() {
key={result.id}
onClick={() => isClickable && handleResultClick(result)}
className={`
bg-white border border-gray-200 rounded-lg p-4
px-4 lg:px-6 py-5 transition-all group
${isClickable
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all'
? 'cursor-pointer hover:bg-blue-50/50 hover:shadow-sm'
: ''}
`}
>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="flex-shrink-0 w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{result.avatarUrl && jiraHost ? (
<img
src={getAvatarUrl(result.avatarUrl) || ''}
alt=""
className="w-6 h-6"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
`;
}}
/>
) : (
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
)}
<div className="flex items-start gap-4">
{/* Avatar/Icon */}
<div className="relative flex-shrink-0 w-12 h-12 bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl flex items-center justify-center overflow-hidden shadow-sm group-hover:shadow-md transition-shadow border border-blue-200/50">
{(() => {
const objectType = objectTypeMap.get(result.objectTypeId);
const typeIcon = getObjectTypeIcon(objectType?.name);
// Show type-specific icon (more meaningful than generic placeholder)
// If Jira provides an avatar, it will be shown via CSS background-image if needed
return typeIcon;
})()}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400 font-mono">{result.key}</span>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap mb-2">
<span className="text-xs text-gray-500 font-mono bg-gray-100 px-2 py-1 rounded-md">{result.key}</span>
{statusDisplay && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}>
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold ${statusInfo.bg} ${statusInfo.color}`}>
{statusDisplay}
</span>
)}
{isClickable && (
<span className="text-xs text-blue-500 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="text-xs text-blue-600 font-medium flex items-center gap-1.5 px-2.5 py-1 bg-blue-50 rounded-full">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Klik om te openen
</span>
)}
</div>
<h3 className="font-medium text-gray-900 mt-0.5">{result.label}</h3>
<h3 className="text-base lg:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-700 transition-colors">
{result.label}
</h3>
{description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{stripHtml(description).substring(0, 200)}
{stripHtml(description).length > 200 && '...'}
<p className="text-sm text-gray-600 leading-relaxed line-clamp-2">
{stripHtml(description).substring(0, 250)}
{stripHtml(description).length > 250 && '...'}
</p>
)}
</div>
{isClickable && (
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
)}
</div>
</div>
</div>
</div>
);
@@ -466,83 +626,115 @@ export default function SearchDashboard() {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600">
Pagina {pageForCurrentTab} van {totalPages} ({filteredResults.length} items)
<div className="px-4 lg:px-6 py-4 bg-gray-50/50 border-t border-gray-200">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-gray-600 font-medium">
Pagina <span className="text-gray-900">{pageForCurrentTab}</span> van <span className="text-gray-900">{totalPages}</span>
<span className="text-gray-500 ml-2">({filteredResults.length} items)</span>
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(pageForCurrentTab - 1)}
disabled={pageForCurrentTab === 1}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-white hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors bg-white shadow-sm"
>
<span className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Vorige
</span>
</button>
<button
onClick={() => handlePageChange(pageForCurrentTab + 1)}
disabled={pageForCurrentTab === totalPages}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-white hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors bg-white shadow-sm"
>
<span className="flex items-center gap-2">
Volgende
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</span>
</button>
</div>
</div>
</div>
)}
</>
</div>
)}
</div>
)}
{/* Quick Links (only show when no search has been performed) */}
{!hasSearched && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
<a
href="/app-components"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
<div className="mt-12 lg:mt-16">
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Snelle toegang</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
<Link
to="/app-components"
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-blue-200 transition-all overflow-hidden"
>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative">
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-transform">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Application Components</p>
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-700 transition-colors">
Application Components
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
Dashboard & overzicht van alle applicatiecomponenten
</p>
</div>
</a>
</Link>
<a
href="/reports/team-dashboard"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
<Link
to="/reports/team-dashboard"
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-green-200 transition-all overflow-hidden"
>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-green-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative">
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-green-500/20 group-hover:scale-110 transition-transform">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Rapporten</p>
<p className="text-sm text-gray-500">Team-indeling & analyses</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-green-700 transition-colors">
Rapporten
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
Team-indeling, analyses en portfolio-overzichten
</p>
</div>
</a>
</Link>
<a
href="/app-components/fte-config"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
<Link
to="/settings/fte-config"
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-purple-200 transition-all overflow-hidden"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative">
<div className="w-14 h-14 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-purple-500/20 group-hover:scale-110 transition-transform">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Configuratie</p>
<p className="text-sm text-gray-500">FTE berekening</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-purple-700 transition-colors">
Configuratie
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
FTE berekening en beheerparameters instellen
</p>
</div>
</Link>
</div>
</a>
</div>
)}
</div>
</div>
);
}

View File

@@ -68,7 +68,7 @@ export default function TeamDashboard() {
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Hover handlers with delayed hide to prevent flickering when moving between badges
const handleGovModelMouseEnter = useCallback((hoverKey: string) => {

View File

@@ -40,25 +40,26 @@ const STATUS_LABELS: Record<string, string> = {
};
// Risk calculation: High BIA (F, E, D) + EOL/EOS/Deprecated = critical risk
function calculateRiskLevel(status: string | null, bia: string | null): 'critical' | 'high' | 'medium' | 'low' {
if (!status || !bia) return 'low';
const isHighBIA = ['F', 'E', 'D'].includes(bia);
const isEOL = status === 'End of life';
const isEOS = status === 'End of support';
const isDeprecated = status === 'Deprecated';
if (isHighBIA && (isEOL || isEOS || isDeprecated)) {
if (isEOL && ['F', 'E'].includes(bia)) return 'critical';
if (isEOL || (isEOS && bia === 'F')) return 'critical';
if (isHighBIA) return 'high';
}
if (isEOL || isEOS) return 'high';
if (isDeprecated) return 'medium';
return 'low';
}
// Unused function - kept for reference
// function calculateRiskLevel(status: string | null, bia: string | null): 'critical' | 'high' | 'medium' | 'low' {
// if (!status || !bia) return 'low';
//
// const isHighBIA = ['F', 'E', 'D'].includes(bia);
// const isEOL = status === 'End of life';
// const isEOS = status === 'End of support';
// const isDeprecated = status === 'Deprecated';
//
// if (isHighBIA && (isEOL || isEOS || isDeprecated)) {
// if (isEOL && ['F', 'E'].includes(bia)) return 'critical';
// if (isEOL || (isEOS && bia === 'F')) return 'critical';
// if (isHighBIA) return 'high';
// }
//
// if (isEOL || isEOS) return 'high';
// if (isDeprecated) return 'medium';
//
// return 'low';
// }
function getRiskColor(riskLevel: string): string {
switch (riskLevel) {

View File

@@ -0,0 +1,740 @@
import { useState, useEffect } from 'react';
import { useHasPermission } from '../hooks/usePermissions';
import ProtectedRoute from './ProtectedRoute';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface User {
id: number;
email: string;
username: string;
display_name: string | null;
is_active: boolean;
email_verified: boolean;
created_at: string;
last_login: string | null;
roles: Array<{ id: number; name: string; description: string | null }>;
}
interface Role {
id: number;
name: string;
description: string | null;
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [expandedUser, setExpandedUser] = useState<number | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<number | null>(null);
const hasManageUsers = useHasPermission('manage_users');
useEffect(() => {
if (hasManageUsers) {
fetchUsers();
fetchRoles();
}
}, [hasManageUsers]);
// Close action menu when clicking outside
useEffect(() => {
const handleClickOutside = () => {
setActionMenuOpen(null);
};
if (actionMenuOpen !== null) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [actionMenuOpen]);
const fetchUsers = async () => {
try {
const response = await fetch(`${API_BASE}/api/users`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch users');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setIsLoading(false);
}
};
const fetchRoles = async () => {
try {
const response = await fetch(`${API_BASE}/api/roles`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch roles');
const data = await response.json();
setRoles(data);
} catch (err) {
console.error('Failed to fetch roles:', err);
}
};
const showSuccess = (message: string) => {
setSuccess(message);
setTimeout(() => setSuccess(null), 5000);
};
const showError = (message: string) => {
setError(message);
setTimeout(() => setError(null), 5000);
};
const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const username = formData.get('username') as string;
const displayName = formData.get('display_name') as string;
const sendInvitation = formData.get('send_invitation') === 'on';
try {
const response = await fetch(`${API_BASE}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email,
username,
display_name: displayName || null,
send_invitation: sendInvitation,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create user');
}
setShowCreateModal(false);
showSuccess('Gebruiker succesvol aangemaakt');
fetchUsers();
(e.target as HTMLFormElement).reset();
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to create user');
}
};
const handleInviteUser = async (userId: number) => {
try {
const response = await fetch(`${API_BASE}/api/users/${userId}/invite`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to send invitation');
}
showSuccess('Uitnodiging succesvol verzonden');
setActionMenuOpen(null);
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to send invitation');
}
};
const handleToggleActive = async (userId: number, isActive: boolean) => {
try {
const response = await fetch(`${API_BASE}/api/users/${userId}/activate`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ is_active: !isActive }),
});
if (!response.ok) throw new Error('Failed to update user');
showSuccess(`Gebruiker ${!isActive ? 'geactiveerd' : 'gedeactiveerd'}`);
fetchUsers();
setActionMenuOpen(null);
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to update user');
}
};
const handleAssignRole = async (userId: number, roleId: number) => {
try {
const response = await fetch(`${API_BASE}/api/users/${userId}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ role_id: roleId }),
});
if (!response.ok) throw new Error('Failed to assign role');
showSuccess('Rol toegewezen');
fetchUsers();
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to assign role');
}
};
const handleRemoveRole = async (userId: number, roleId: number) => {
try {
const response = await fetch(`${API_BASE}/api/users/${userId}/roles/${roleId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to remove role');
showSuccess('Rol verwijderd');
fetchUsers();
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to remove role');
}
};
const handleDeleteUser = async (userId: number) => {
if (!confirm('Weet je zeker dat je deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.')) return;
try {
const response = await fetch(`${API_BASE}/api/users/${userId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete user');
showSuccess('Gebruiker succesvol verwijderd');
fetchUsers();
setActionMenuOpen(null);
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to delete user');
}
};
const handleVerifyEmail = async (userId: number) => {
if (!confirm('Weet je zeker dat je het e-mailadres van deze gebruiker wilt verifiëren?')) return;
try {
const response = await fetch(`${API_BASE}/api/users/${userId}/verify-email`, {
method: 'PUT',
credentials: 'include',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to verify email');
}
showSuccess('E-mailadres succesvol geverifieerd');
fetchUsers();
setActionMenuOpen(null);
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to verify email');
}
};
const handleSetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!selectedUser) return;
setError(null);
const formData = new FormData(e.currentTarget);
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirm_password') as string;
if (password !== confirmPassword) {
showError('Wachtwoorden komen niet overeen');
return;
}
if (password.length < 8) {
showError('Wachtwoord moet minimaal 8 tekens lang zijn');
return;
}
try {
const response = await fetch(`${API_BASE}/api/users/${selectedUser.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to set password');
}
showSuccess('Wachtwoord succesvol ingesteld');
setShowPasswordModal(false);
setSelectedUser(null);
(e.target as HTMLFormElement).reset();
} catch (err) {
showError(err instanceof Error ? err.message : 'Failed to set password');
}
};
const filteredUsers = users.filter(
(user) =>
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
(user.display_name && user.display_name.toLowerCase().includes(searchTerm.toLowerCase()))
);
const getUserInitials = (user: User) => {
if (user.display_name) {
return user.display_name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
return user.username.substring(0, 2).toUpperCase();
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Nog niet ingelogd';
return new Date(dateString).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (!hasManageUsers) {
return (
<ProtectedRoute requirePermission="manage_users">
<div>Access denied</div>
</ProtectedRoute>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Gebruikersbeheer</h1>
<p className="text-gray-600 mt-1">Beheer gebruikers, rollen en rechten</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm hover:shadow-md"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nieuwe gebruiker
</button>
</div>
{/* Success/Error Messages */}
{success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3 animate-in slide-in-from-top">
<svg className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-green-800 font-medium">{success}</p>
</div>
)}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-in slide-in-from-top">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-red-800 font-medium">{error}</p>
</div>
)}
{/* Search and Stats */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between mb-6">
<div className="relative flex-1 max-w-md">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Zoek op naam, e-mail of gebruikersnaam..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span className="font-medium text-gray-900">{filteredUsers.length}</span>
<span>van {users.length} gebruikers</span>
</div>
</div>
{/* Users Grid */}
{isLoading ? (
<div className="py-12 text-center">
<div className="inline-block w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-4 text-gray-600 font-medium">Gebruikers laden...</p>
</div>
) : filteredUsers.length === 0 ? (
<div className="py-12 text-center">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p className="mt-4 text-gray-600 font-medium">Geen gebruikers gevonden</p>
<p className="mt-1 text-sm text-gray-500">
{searchTerm ? 'Probeer een andere zoekterm' : 'Maak je eerste gebruiker aan'}
</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredUsers.map((user) => (
<div
key={user.id}
className="bg-gradient-to-br from-white to-gray-50 rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all duration-200"
>
<div className="p-5">
{/* User Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-semibold text-lg shadow-md">
{getUserInitials(user)}
</div>
<div>
<h3 className="font-semibold text-gray-900 text-lg">
{user.display_name || user.username}
</h3>
<p className="text-sm text-gray-500">@{user.username}</p>
</div>
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setActionMenuOpen(actionMenuOpen === user.id ? null : user.id);
}}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{actionMenuOpen === user.id && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<div className="py-1">
{!user.email_verified && (
<button
onClick={() => handleVerifyEmail(user.id)}
className="w-full text-left px-4 py-2 text-sm text-yellow-700 hover:bg-yellow-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Verifieer e-mail
</button>
)}
<button
onClick={() => {
setSelectedUser(user);
setShowPasswordModal(true);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Wachtwoord instellen
</button>
<button
onClick={() => handleInviteUser(user.id)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Uitnodiging verzenden
</button>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={() => handleToggleActive(user.id, user.is_active)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={user.is_active ? "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" : "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"} />
</svg>
{user.is_active ? 'Deactiveren' : 'Activeren'}
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Verwijderen
</button>
</div>
</div>
)}
</div>
</div>
{/* Email */}
<div className="mb-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>{user.email}</span>
</div>
</div>
{/* Status Badges */}
<div className="flex flex-wrap gap-2 mb-4">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
<div className={`w-1.5 h-1.5 rounded-full ${user.is_active ? 'bg-green-600' : 'bg-red-600'}`}></div>
{user.is_active ? 'Actief' : 'Inactief'}
</span>
{user.email_verified ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
E-mail geverifieerd
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
E-mail niet geverifieerd
</span>
)}
</div>
{/* Roles */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span className="text-xs font-medium text-gray-500 uppercase">Rollen</span>
</div>
<div className="flex flex-wrap gap-1.5">
{user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200"
>
{role.name}
<button
onClick={() => handleRemoveRole(user.id, role.id)}
className="hover:text-blue-900 transition-colors"
title="Rol verwijderen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))
) : (
<span className="text-xs text-gray-400 italic">Geen rollen toegewezen</span>
)}
<select
onChange={(e) => {
if (e.target.value) {
handleAssignRole(user.id, parseInt(e.target.value));
e.target.value = '';
}
}}
className="text-xs border border-gray-300 rounded-md px-2 py-1 bg-white hover:bg-gray-50 focus:outline-none focus:ring-1 focus:ring-blue-500"
title="Rol toevoegen"
>
<option value="">+ Rol toevoegen</option>
{roles
.filter((role) => !user.roles.some((ur) => ur.id === role.id))
.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
</div>
{/* Additional Info */}
<div className="pt-4 border-t border-gray-100">
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div>
<span className="font-medium text-gray-600">Aangemaakt:</span>
<p className="mt-0.5">{formatDate(user.created_at)}</p>
</div>
<div>
<span className="font-medium text-gray-600">Laatste login:</span>
<p className="mt-0.5">{formatDate(user.last_login)}</p>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Create User Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Nieuwe gebruiker</h2>
<p className="text-sm text-gray-600 mt-1">Voeg een nieuwe gebruiker toe aan het systeem</p>
</div>
<form onSubmit={handleCreateUser} className="p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
E-mailadres
</label>
<input
type="email"
name="email"
required
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="gebruiker@voorbeeld.nl"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Gebruikersnaam
</label>
<input
type="text"
name="username"
required
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="gebruikersnaam"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Weergavenaam
</label>
<input
type="text"
name="display_name"
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Voornaam Achternaam (optioneel)"
/>
</div>
<div className="flex items-center p-3 bg-blue-50 rounded-lg border border-blue-200">
<input
type="checkbox"
name="send_invitation"
id="send_invitation"
defaultChecked
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="send_invitation" className="ml-3 text-sm text-gray-700">
Stuur uitnodigingsemail naar de gebruiker
</label>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="submit"
className="flex-1 px-4 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Gebruiker aanmaken
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors"
>
Annuleren
</button>
</div>
</form>
</div>
</div>
)}
{/* Set Password Modal */}
{showPasswordModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Wachtwoord instellen</h2>
<p className="text-sm text-gray-600 mt-1">
Stel een nieuw wachtwoord in voor <strong>{selectedUser.display_name || selectedUser.username}</strong>
</p>
</div>
<form onSubmit={handleSetPassword} className="p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Nieuw wachtwoord
</label>
<input
type="password"
name="password"
required
minLength={8}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Minimaal 8 tekens"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Bevestig wachtwoord
</label>
<input
type="password"
name="confirm_password"
required
minLength={8}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Bevestig het wachtwoord"
/>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="submit"
className="flex-1 px-4 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Wachtwoord instellen
</button>
<button
type="button"
onClick={() => {
setShowPasswordModal(false);
setSelectedUser(null);
setError(null);
}}
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors"
>
Annuleren
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useState, useEffect } from 'react';
import { useAuthStore } from '../stores/authStore';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface UserSettings {
jira_pat: string | null;
ai_enabled: boolean;
ai_provider: 'openai' | 'anthropic' | null;
ai_api_key: string | null;
web_search_enabled: boolean;
tavily_api_key: string | null;
}
export default function UserSettings() {
const { user } = useAuthStore();
const [settings, setSettings] = useState<UserSettings>({
jira_pat: null,
ai_enabled: false,
ai_provider: null,
ai_api_key: null,
web_search_enabled: false,
tavily_api_key: null,
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [jiraPatStatus, setJiraPatStatus] = useState<{ configured: boolean; valid: boolean } | null>(null);
useEffect(() => {
fetchSettings();
fetchJiraPatStatus();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch(`${API_BASE}/api/user-settings`, {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to fetch settings');
const data = await response.json();
setSettings({
jira_pat: data.jira_pat === '***' ? '' : data.jira_pat,
ai_enabled: data.ai_enabled,
ai_provider: data.ai_provider,
ai_api_key: data.ai_api_key === '***' ? '' : data.ai_api_key,
web_search_enabled: data.web_search_enabled,
tavily_api_key: data.tavily_api_key === '***' ? '' : data.tavily_api_key,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load settings');
} finally {
setIsLoading(false);
}
};
const fetchJiraPatStatus = async () => {
try {
const response = await fetch(`${API_BASE}/api/user-settings/jira-pat/status`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setJiraPatStatus(data);
}
} catch (err) {
console.error('Failed to fetch Jira PAT status:', err);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
setError(null);
setSuccess(null);
try {
const formData = new FormData(e.currentTarget);
const updates: Partial<UserSettings> = {
jira_pat: formData.get('jira_pat') as string || undefined,
ai_enabled: formData.get('ai_enabled') === 'on',
ai_provider: (formData.get('ai_provider') as 'openai' | 'anthropic') || undefined,
ai_api_key: formData.get('ai_api_key') as string || undefined,
web_search_enabled: formData.get('web_search_enabled') === 'on',
tavily_api_key: formData.get('tavily_api_key') as string || undefined,
};
// Only send fields that have values
Object.keys(updates).forEach((key) => {
if (updates[key as keyof UserSettings] === '' || updates[key as keyof UserSettings] === undefined) {
delete updates[key as keyof UserSettings];
}
});
const response = await fetch(`${API_BASE}/api/user-settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updates),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to save settings');
}
setSuccess('Instellingen opgeslagen');
fetchSettings();
fetchJiraPatStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
} finally {
setIsSaving(false);
}
};
const handleValidateJiraPat = async () => {
const form = document.getElementById('settings-form') as HTMLFormElement;
const formData = new FormData(form);
const pat = formData.get('jira_pat') as string;
if (!pat) {
setError('Voer eerst een Jira PAT in');
return;
}
try {
const response = await fetch(`${API_BASE}/api/user-settings/jira-pat/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ pat }),
});
if (!response.ok) throw new Error('Validation failed');
const data = await response.json();
if (data.valid) {
setSuccess('Jira PAT is geldig');
} else {
setError('Jira PAT is ongeldig');
}
fetchJiraPatStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to validate Jira PAT');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Instellingen</h1>
<p className="text-gray-600 mt-1">Beheer je persoonlijke instellingen</p>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">{error}</p>
</div>
)}
{success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800">{success}</p>
</div>
)}
<form id="settings-form" onSubmit={handleSave} className="bg-white rounded-lg shadow p-6 space-y-6">
{/* Jira PAT Section */}
<div className="border-b border-gray-200 pb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Jira Personal Access Token</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Personal Access Token
</label>
<input
type="password"
name="jira_pat"
defaultValue={settings.jira_pat || ''}
placeholder="Voer je Jira PAT in"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-sm text-gray-500">
Dit token wordt gebruikt voor authenticatie met Jira
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={handleValidateJiraPat}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Valideer PAT
</button>
{jiraPatStatus && (
<span
className={`inline-flex items-center px-3 py-2 rounded-lg text-sm font-medium ${
jiraPatStatus.configured && jiraPatStatus.valid
? 'bg-green-100 text-green-800'
: jiraPatStatus.configured
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{jiraPatStatus.configured && jiraPatStatus.valid
? '✓ Geconfigureerd en geldig'
: jiraPatStatus.configured
? '⚠ Geconfigureerd maar ongeldig'
: 'Niet geconfigureerd'}
</span>
)}
</div>
</div>
</div>
{/* AI Features Section */}
<div className="border-b border-gray-200 pb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">AI Functies</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
name="ai_enabled"
defaultChecked={settings.ai_enabled}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-700">AI functies inschakelen</span>
</label>
{settings.ai_enabled && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">AI Provider</label>
<select
name="ai_provider"
defaultValue={settings.ai_provider || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Selecteer provider</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">API Key</label>
<input
type="password"
name="ai_api_key"
defaultValue={settings.ai_api_key || ''}
placeholder="Voer je API key in"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</>
)}
</div>
</div>
{/* Web Search Section */}
<div className="border-b border-gray-200 pb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Web Zoeken</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
name="web_search_enabled"
defaultChecked={settings.web_search_enabled}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-700">Web zoeken inschakelen</span>
</label>
{settings.web_search_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Tavily API Key</label>
<input
type="password"
name="tavily_api_key"
defaultValue={settings.tavily_api_key || ''}
placeholder="Voer je Tavily API key in"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSaving ? 'Opslaan...' : 'Opslaan'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,55 @@
/**
* Permission Hooks
*
* React hooks for checking user permissions and roles.
*/
import { useAuthStore } from '../stores/authStore';
/**
* Check if user has a specific permission
*/
export function useHasPermission(permission: string): boolean {
const hasPermission = useAuthStore((state) => state.hasPermission(permission));
return hasPermission;
}
/**
* Check if user has a specific role
*/
export function useHasRole(role: string): boolean {
const hasRole = useAuthStore((state) => state.hasRole(role));
return hasRole;
}
/**
* Get all user permissions
*/
export function usePermissions(): string[] {
const user = useAuthStore((state) => state.user);
return user?.permissions || [];
}
/**
* Get all user roles
*/
export function useRoles(): string[] {
const user = useAuthStore((state) => state.user);
return user?.roles || [];
}
/**
* Check if user has any of the specified permissions
*/
export function useHasAnyPermission(permissions: string[]): boolean {
const userPermissions = usePermissions();
return permissions.some(permission => userPermissions.includes(permission));
}
/**
* Check if user has all of the specified permissions
*/
export function useHasAllPermissions(permissions: string[]): boolean {
const userPermissions = usePermissions();
return permissions.every(permission => userPermissions.includes(permission));
}

View File

@@ -8,6 +8,33 @@
}
}
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
@@ -112,4 +139,13 @@
.badge-lighter-blue {
@apply bg-blue-300 text-white;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}

View File

@@ -6,7 +6,12 @@ import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<App />
</BrowserRouter>
</React.StrictMode>

View File

@@ -466,24 +466,24 @@ export interface EffortCalculationConfig {
[key: string]: {
result: number;
conditions?: {
hostingType?: string | string[];
applicationManagementHosting?: string | string[];
};
} | Array<{
result: number;
conditions?: {
hostingType?: string | string[];
applicationManagementHosting?: string | string[];
};
}>;
};
default?: {
result: number;
conditions?: {
hostingType?: string | string[];
applicationManagementHosting?: string | string[];
};
} | Array<{
result: number;
conditions?: {
hostingType?: string | string[];
applicationManagementHosting?: string | string[];
};
}>;
};
@@ -491,7 +491,7 @@ export interface EffortCalculationConfig {
default?: {
result: number;
conditions?: {
hostingType?: string | string[];
applicationManagementHosting?: string | string[];
};
};
}>;

View File

@@ -2,18 +2,28 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface User {
accountId: string;
id?: number;
accountId?: string;
email?: string;
username?: string;
displayName: string;
emailAddress?: string;
avatarUrl?: string;
roles?: string[];
permissions?: string[];
}
interface AuthConfig {
// Application branding
appName: string;
appTagline: string;
appCopyright: string;
// The configured authentication method
authMethod: 'pat' | 'oauth' | 'none';
authMethod: 'pat' | 'oauth' | 'local' | 'none';
// Legacy fields (for backward compatibility)
oauthEnabled: boolean;
serviceAccountEnabled: boolean;
localAuthEnabled: boolean;
jiraHost: string;
}
@@ -21,19 +31,24 @@ interface AuthState {
// State
user: User | null;
isAuthenticated: boolean;
authMethod: 'oauth' | 'service-account' | null;
authMethod: 'oauth' | 'local' | 'service-account' | null;
isLoading: boolean;
error: string | null;
config: AuthConfig | null;
isInitialized: boolean; // Track if initialization has completed
// Actions
setUser: (user: User | null, method: 'oauth' | 'service-account' | null) => void;
setUser: (user: User | null, method: 'oauth' | 'local' | 'service-account' | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setConfig: (config: AuthConfig) => void;
setInitialized: (initialized: boolean) => void;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
fetchConfig: () => Promise<void>;
localLogin: (email: string, password: string) => Promise<void>;
hasPermission: (permission: string) => boolean;
hasRole: (role: string) => boolean;
}
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
@@ -48,6 +63,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: true,
error: null,
config: null,
isInitialized: false,
// Actions
setUser: (user, method) => set({
@@ -63,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
setConfig: (config) => set({ config }),
setInitialized: (initialized) => set({ isInitialized: initialized }),
logout: async () => {
try {
await fetch(`${API_BASE}/api/auth/logout`, {
@@ -81,19 +99,49 @@ export const useAuthStore = create<AuthState>()(
},
checkAuth: async () => {
// Use a simple flag to prevent concurrent calls
const currentState = get();
if (currentState.isLoading) {
// Wait for the existing call to complete (max 1 second)
let waitCount = 0;
while (get().isLoading && waitCount < 10) {
await new Promise(resolve => setTimeout(resolve, 100));
waitCount++;
}
const stateAfterWait = get();
// If previous call completed and we have auth state, we're done
if (!stateAfterWait.isLoading && (stateAfterWait.isAuthenticated || stateAfterWait.user)) {
return;
}
}
set({ isLoading: true });
try {
const response = await fetch(`${API_BASE}/api/auth/me`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Auth check failed');
// Handle rate limiting (429) gracefully
if (response.status === 429) {
set({
user: null,
isAuthenticated: false,
authMethod: null,
isLoading: false,
error: null,
});
return;
}
throw new Error(`Auth check failed: ${response.status}`);
}
const data = await response.json();
if (data.authenticated) {
if (data.authenticated && data.user) {
set({
user: data.user,
isAuthenticated: true,
@@ -110,7 +158,7 @@ export const useAuthStore = create<AuthState>()(
});
}
} catch (error) {
console.error('Auth check error:', error);
console.error('[checkAuth] Auth check error:', error);
set({
user: null,
isAuthenticated: false,
@@ -122,18 +170,98 @@ export const useAuthStore = create<AuthState>()(
},
fetchConfig: async () => {
// Check if config is already loaded to prevent duplicate calls
const currentState = get();
if (currentState.config) {
return; // Config already loaded, skip API call
}
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: '',
};
try {
const response = await fetch(`${API_BASE}/api/auth/config`, {
credentials: 'include',
});
if (response.ok) {
const config = await response.json();
set({ config });
const configData = await response.json();
set({ config: configData });
} else {
// Any non-OK response - set default config
set({ config: defaultConfig });
}
} catch (error) {
console.error('Failed to fetch auth config:', error);
console.error('[fetchConfig] Failed to fetch auth config:', error);
// Set default config to allow app to proceed
set({ config: defaultConfig });
}
// Final verification - ensure config is set
const finalState = get();
if (!finalState.config) {
set({ config: defaultConfig });
}
},
localLogin: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Login failed');
}
const data = await response.json();
set({
user: data.user,
isAuthenticated: true,
authMethod: 'local',
isLoading: false,
error: null,
});
} catch (error) {
console.error('Local login error:', error);
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Login failed',
});
throw error;
}
},
hasPermission: (permission: string) => {
const { user } = get();
if (!user || !user.permissions) {
return false;
}
return user.permissions.includes(permission);
},
hasRole: (role: string) => {
const { user } = get();
if (!user || !user.roles) {
return false;
}
return user.roles.includes(role);
},
}),
{
@@ -150,4 +278,3 @@ export const useAuthStore = create<AuthState>()(
export function getLoginUrl(): string {
return `${API_BASE}/api/auth/login`;
}

View File

@@ -88,6 +88,12 @@ 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
_jiraUpdatedAt?: string | null; // Internal field for conflict detection (not exposed in API)
}
// Search filters

5
frontend/src/types/node.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// Type definitions for Node.js types used in frontend
// In browser context, setTimeout returns number, not NodeJS.Timeout
declare namespace NodeJS {
interface Timeout extends ReturnType<typeof setTimeout> {}
}

Some files were not shown because too many files have changed in this diff Show More