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
This commit is contained in:
2026-01-15 03:20:50 +01:00
parent f3637b85e1
commit 1fa424efb9
70 changed files with 15597 additions and 2098 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 PORT=3001
NODE_ENV=development 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 # Database Configuration
# -----------------------------------------------------------------------------
# Use 'postgres' for PostgreSQL or 'sqlite' for SQLite (default) # Use 'postgres' for PostgreSQL or 'sqlite' for SQLite (default)
DATABASE_TYPE=postgres DATABASE_TYPE=postgres
# Option 1: Use DATABASE_URL (recommended for PostgreSQL)
DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb 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_HOST=localhost
# DATABASE_PORT=5432 # DATABASE_PORT=5432
# DATABASE_NAME=cmdb # DATABASE_NAME=cmdb
@@ -14,17 +33,71 @@ DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb
# DATABASE_PASSWORD=cmdb-dev # DATABASE_PASSWORD=cmdb-dev
# DATABASE_SSL=false # DATABASE_SSL=false
# -----------------------------------------------------------------------------
# Jira Assets Configuration # Jira Assets Configuration
# -----------------------------------------------------------------------------
JIRA_HOST=https://jira.zuyderland.nl JIRA_HOST=https://jira.zuyderland.nl
JIRA_PAT=your_personal_access_token_here
JIRA_SCHEMA_ID=your_schema_id JIRA_SCHEMA_ID=your_schema_id
JIRA_API_BATCH_SIZE=20
# Claude API # Jira Service Account Token (for read operations: sync, fetching data)
ANTHROPIC_API_KEY=your_anthropic_api_key_here # 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) # Jira Authentication Method
TAVILY_API_KEY=your_tavily_api_key_here # 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 # Options: 'pat' (Personal Access Token) or 'oauth' (OAuth 2.0)
OPENAI_API_KEY=your_openai_api_key_here 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> JIRA_ATTR_APPLICATION_TYPE=<attr_id>
# AI Classification # AI Classification
ANTHROPIC_API_KEY=<claude_api_key> # Note: AI API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY),
OPENAI_API_KEY=<openai_api_key> # Optional: alternative to Claude # default AI provider, and web search are configured per-user in profile settings,
DEFAULT_AI_PROVIDER=claude # 'claude' or 'openai' # not in environment variables
# Server # Server
PORT=3001 PORT=3001

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,15 @@
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"generate-schema": "tsx scripts/generate-schema.ts", "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" "migrate:sqlite-to-postgres": "tsx scripts/migrate-sqlite-to-postgres.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.32.1", "@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", "better-sqlite3": "^11.6.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -20,6 +25,7 @@
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"nodemailer": "^7.0.12",
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"winston": "^3.17.0", "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; jiraHost: string;
jiraSchemaId: string; jiraSchemaId: string;
// Jira Service Account Token (for read operations: sync, fetching data)
jiraServiceAccountToken: string;
// Jira Authentication Method ('pat' or 'oauth') // Jira Authentication Method ('pat' or 'oauth')
jiraAuthMethod: JiraAuthMethod; jiraAuthMethod: JiraAuthMethod;
// Jira Personal Access Token (used when jiraAuthMethod = 'pat')
jiraPat: string;
// Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth') // Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth')
jiraOAuthClientId: string; jiraOAuthClientId: string;
jiraOAuthClientSecret: string; jiraOAuthClientSecret: string;
@@ -45,14 +45,9 @@ interface Config {
// Session Configuration // Session Configuration
sessionSecret: string; sessionSecret: string;
// AI API Keys // AI Configuration
anthropicApiKey: string; // Note: API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY), default AI provider,
openaiApiKey: string; // and web search are now configured per-user in their profile settings, not in environment variables
defaultAIProvider: 'claude' | 'openai';
// Web Search API (Tavily)
tavilyApiKey: string;
enableWebSearch: boolean;
// Application // Application
port: number; port: number;
@@ -60,6 +55,9 @@ interface Config {
isDevelopment: boolean; isDevelopment: boolean;
isProduction: boolean; isProduction: boolean;
frontendUrl: string; frontendUrl: string;
appName: string;
appTagline: string;
appCopyright: string;
// API Configuration // API Configuration
jiraApiBatchSize: number; jiraApiBatchSize: number;
@@ -69,9 +67,9 @@ function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
return process.env[name] || defaultValue; return process.env[name] || defaultValue;
} }
// Helper to determine auth method with backward compatibility // Helper to determine auth method
function getJiraAuthMethod(): JiraAuthMethod { function getJiraAuthMethod(): JiraAuthMethod {
// Check new JIRA_AUTH_METHOD first // Check JIRA_AUTH_METHOD first
const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase(); const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase();
if (authMethod === 'oauth') return 'oauth'; if (authMethod === 'oauth') return 'oauth';
if (authMethod === 'pat') return 'pat'; if (authMethod === 'pat') return 'pat';
@@ -80,14 +78,12 @@ function getJiraAuthMethod(): JiraAuthMethod {
const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true'; const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true';
if (oauthEnabled) return 'oauth'; if (oauthEnabled) return 'oauth';
// Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist // Default to 'oauth' if OAuth credentials exist, otherwise 'pat'
const hasPat = !!getOptionalEnvVar('JIRA_PAT');
const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'); const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET');
if (hasPat) return 'pat';
if (hasOAuthCredentials) return 'oauth'; if (hasOAuthCredentials) return 'oauth';
// Default to 'pat' (will show warning during validation) // Default to 'pat' (users configure PAT in their profile)
return 'pat'; return 'pat';
} }
@@ -96,12 +92,12 @@ export const config: Config = {
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'), jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'), jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
// Jira Service Account Token (for read operations: sync, fetching data)
jiraServiceAccountToken: getOptionalEnvVar('JIRA_SERVICE_ACCOUNT_TOKEN'),
// Jira Authentication Method // Jira Authentication Method
jiraAuthMethod: getJiraAuthMethod(), jiraAuthMethod: getJiraAuthMethod(),
// Jira Personal Access Token (for PAT authentication)
jiraPat: getOptionalEnvVar('JIRA_PAT'),
// Jira OAuth 2.0 Configuration (for OAuth authentication) // Jira OAuth 2.0 Configuration (for OAuth authentication)
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'), jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'), jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
@@ -111,21 +107,15 @@ export const config: Config = {
// Session Configuration // Session Configuration
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'), 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 // Application
port: parseInt(getOptionalEnvVar('PORT', '3001'), 10), port: parseInt(getOptionalEnvVar('PORT', '3001'), 10),
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'), nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development', isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production', isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'), 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 // API Configuration
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10), 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()}`); console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`);
if (config.jiraAuthMethod === 'pat') { if (config.jiraAuthMethod === 'pat') {
if (!config.jiraPat) { // JIRA_PAT is configured in user profiles, not in ENV
missingVars.push('JIRA_PAT (required for PAT authentication)'); warnings.push('JIRA_AUTH_METHOD=pat - users must configure PAT in their profile settings');
}
} else if (config.jiraAuthMethod === 'oauth') { } else if (config.jiraAuthMethod === 'oauth') {
if (!config.jiraOAuthClientId) { if (!config.jiraOAuthClientId) {
missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)'); missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)');
@@ -156,7 +145,14 @@ export function validateConfig(): void {
// General required config // General required config
if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID'); 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) { if (warnings.length > 0) {
warnings.forEach(w => console.warn(`Warning: ${w}`)); warnings.forEach(w => console.warn(`Warning: ${w}`));

View File

@@ -44,7 +44,7 @@ export interface ApplicationComponent extends BaseCMDBObject {
updated: string | null; updated: string | null;
description: string | null; // * Application description description: string | null; // * Application description
status: string | null; // Application Lifecycle Management 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; zenyaID: number | null;
zenyaURL: string | null; zenyaURL: string | null;
customDevelopment: boolean | null; // Is er sprake van eigen programmatuur? 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 dashboardRouter from './routes/dashboard.js';
import configurationRouter from './routes/configuration.js'; import configurationRouter from './routes/configuration.js';
import authRouter, { authMiddleware } from './routes/auth.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 searchRouter from './routes/search.js';
import cacheRouter from './routes/cache.js'; import cacheRouter from './routes/cache.js';
import objectsRouter from './routes/objects.js'; import objectsRouter from './routes/objects.js';
import schemaRouter from './routes/schema.js'; import schemaRouter from './routes/schema.js';
import { runMigrations } from './services/database/migrations.js';
// Validate configuration // Validate configuration
validateConfig(); validateConfig();
@@ -55,13 +60,49 @@ app.use((req, res, next) => {
// Auth middleware - extract session info for all requests // Auth middleware - extract session info for all requests
app.use(authMiddleware); app.use(authMiddleware);
// Set user token on CMDBService for each request (for user-specific OAuth) // Set user token and settings on services for each request
app.use((req, res, next) => { app.use(async (req, res, next) => {
// Set user's OAuth token if available // Set user's OAuth token if available (for OAuth sessions)
if (req.accessToken) { if (req.accessToken) {
cmdbService.setUserToken(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 // Clear token after response is sent
res.on('finish', () => { res.on('finish', () => {
cmdbService.clearUserToken(); cmdbService.clearUserToken();
@@ -80,7 +121,7 @@ app.get('/health', async (req, res) => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data', dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data',
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null, jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
aiConfigured: !!config.anthropicApiKey, aiConfigured: true, // AI is configured per-user in profile settings
cache: { cache: {
isWarm: cacheStatus.isWarm, isWarm: cacheStatus.isWarm,
objectCount: cacheStatus.totalObjects, objectCount: cacheStatus.totalObjects,
@@ -98,6 +139,10 @@ app.get('/api/config', (req, res) => {
// API routes // API routes
app.use('/api/auth', authRouter); 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/applications', applicationsRouter);
app.use('/api/classifications', classificationsRouter); app.use('/api/classifications', classificationsRouter);
app.use('/api/reference-data', referenceDataRouter); app.use('/api/reference-data', referenceDataRouter);
@@ -127,14 +172,24 @@ const PORT = config.port;
app.listen(PORT, async () => { app.listen(PORT, async () => {
logger.info(`Server running on http://localhost:${PORT}`); logger.info(`Server running on http://localhost:${PORT}`);
logger.info(`Environment: ${config.nodeEnv}`); logger.info(`Environment: ${config.nodeEnv}`);
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`); logger.info(`AI Classification: Configured per-user in profile settings`);
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`); logger.info(`Jira Assets: ${config.jiraSchemaId ? 'Schema configured - users configure PAT in profile' : 'Schema not configured'}`);
// Initialize sync engine if using Jira Assets // Run database migrations
if (config.jiraPat && config.jiraSchemaId) { 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 { try {
await syncEngine.initialize(); await syncEngine.initialize();
logger.info('Sync Engine: Initialized and running'); logger.info('Sync Engine: Initialized (sync on-demand per user request)');
} catch (error) { } catch (error) {
logger.error('Failed to initialize sync engine', 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

@@ -7,13 +7,17 @@ import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../se
import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js'; import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js';
import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js'; import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js';
import { getQueryString, getParamString } from '../utils/queryHelpers.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 { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js'; import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router(); const router = Router();
// Search applications with filters // All routes require authentication
router.post('/search', async (req: Request, res: Response) => { router.use(requireAuth);
// Search applications with filters (requires search permission)
router.post('/search', requirePermission('search'), async (req: Request, res: Response) => {
try { try {
const { filters, page = 1, pageSize = 25 } = req.body as { const { filters, page = 1, pageSize = 25 } = req.body as {
filters: SearchFilters; filters: SearchFilters;
@@ -356,9 +360,22 @@ router.get('/:id', async (req: Request, res: Response) => {
} }
}); });
// Update application with conflict detection // Update application with conflict detection (requires edit permission)
router.put('/:id', async (req: Request, res: Response) => { router.put('/:id', requirePermission('edit_applications'), async (req: Request, res: Response) => {
try { try {
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
const userSettings = (req as any).userSettings;
const { config } = await import('../config/env.js');
// Allow writes if user has PAT OR service account token is configured
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
res.status(403).json({
error: 'Jira PAT not configured',
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
});
return;
}
const id = getParamString(req, 'id'); const id = getParamString(req, 'id');
const { updates, _jiraUpdatedAt } = req.body as { const { updates, _jiraUpdatedAt } = req.body as {
updates?: { updates?: {
@@ -468,9 +485,22 @@ router.put('/:id', async (req: Request, res: Response) => {
} }
}); });
// Force update (ignore conflicts) // Force update (ignore conflicts) (requires edit permission)
router.put('/:id/force', async (req: Request, res: Response) => { router.put('/:id/force', requirePermission('edit_applications'), async (req: Request, res: Response) => {
try { try {
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
const userSettings = (req as any).userSettings;
const { config } = await import('../config/env.js');
// Allow writes if user has PAT OR service account token is configured
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
res.status(403).json({
error: 'Jira PAT not configured',
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
});
return;
}
const id = getParamString(req, 'id'); const id = getParamString(req, 'id');
const updates = req.body; const updates = req.body;

View File

@@ -1,7 +1,10 @@
import { Router, Request, Response, NextFunction } from 'express'; 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 { config } from '../config/env.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { getAuthDatabase } from '../services/database/migrations.js';
const router = Router(); const router = Router();
@@ -10,55 +13,179 @@ declare global {
namespace Express { namespace Express {
interface Request { interface Request {
sessionId?: string; sessionId?: string;
user?: JiraUser; user?: SessionUser | JiraUser;
accessToken?: string; accessToken?: string;
} }
} }
} }
// Get auth configuration // Get auth configuration
router.get('/config', (req: Request, res: Response) => { router.get('/config', async (req: Request, res: Response) => {
const authMethod = authService.getAuthMethod(); // 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({ 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, authMethod,
// Legacy fields for backward compatibility // Authentication options
oauthEnabled: authService.isOAuthEnabled(), oauthEnabled,
serviceAccountEnabled: authService.isUsingServiceAccount(), serviceAccountEnabled: false, // Service accounts are NOT for app authentication
localAuthEnabled,
// Jira host for display purposes // Jira host for display purposes
jiraHost: config.jiraHost, jiraHost: config.jiraHost,
}); });
}); });
// Get current user (check if logged in) // Get current user (check if logged in)
router.get('/me', (req: Request, res: Response) => { router.get('/me', async (req: Request, res: Response) => {
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId; // 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 (!sessionId) {
// If OAuth not enabled, allow anonymous access with service account // No session = not authenticated
if (authService.isUsingServiceAccount() && !authService.isOAuthEnabled()) { // Service account mode is NOT a valid authentication method for the application
return res.json({ return res.json({ authenticated: false });
authenticated: true, }
authMethod: 'service-account',
user: { try {
accountId: 'service-account', const session = await authService.getSession(sessionId);
displayName: 'Service Account', if (!session) {
}, return res.json({ authenticated: false });
});
} }
// Determine auth method from session
let authMethod = 'local';
if ('accountId' in session.user) {
authMethod = 'oauth';
} else if ('id' in session.user) {
authMethod = 'local';
}
// For local users, ensure we have all required fields
let userData = session.user;
if ('id' in session.user) {
// Local user - ensure proper format
userData = {
id: session.user.id,
email: session.user.email || session.user.emailAddress,
username: session.user.username,
displayName: session.user.displayName,
emailAddress: session.user.email || session.user.emailAddress,
roles: session.user.roles || [],
permissions: session.user.permissions || [],
};
}
res.json({
authenticated: true,
authMethod,
user: userData,
});
} catch (error) {
logger.error('Error getting session:', error);
return res.json({ authenticated: false }); return res.json({ authenticated: false });
} }
});
const user = authService.getUser(sessionId); // Local login (email/password)
if (!user) { router.post('/login', async (req: Request, res: Response) => {
return res.json({ authenticated: false }); if (!authService.isLocalAuthEnabled()) {
return res.status(400).json({ error: 'Local authentication is not enabled' });
} }
res.json({ const { email, password } = req.body;
authenticated: true,
authMethod: 'oauth', if (!email || !password) {
user, 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 // Initiate OAuth login
@@ -102,21 +229,41 @@ router.get('/callback', async (req: Request, res: Response) => {
} }
try { try {
const ipAddress = req.ip || req.socket.remoteAddress || undefined;
const userAgent = req.get('user-agent') || undefined;
// Exchange code for tokens // Exchange code for tokens
const { sessionId, user } = await authService.exchangeCodeForTokens( const { sessionId, user } = await authService.exchangeCodeForTokens(
String(code), String(code),
String(state) String(state),
ipAddress,
userAgent
); );
logger.info(`OAuth login successful for: ${user.displayName}`); logger.info(`OAuth login successful for: ${user.displayName}`);
// Set session cookie // 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, httpOnly: true,
secure: config.isProduction, secure: config.isProduction,
sameSite: 'lax', sameSite: 'lax' as const,
path: '/', // Make cookie available for all paths
maxAge: 24 * 60 * 60 * 1000, // 24 hours 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 // Redirect to frontend with session info
res.redirect(`${config.frontendUrl}?login=success`); res.redirect(`${config.frontendUrl}?login=success`);
@@ -128,16 +275,16 @@ router.get('/callback', async (req: Request, res: Response) => {
}); });
// Logout // 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; const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
if (sessionId) { if (sessionId) {
authService.logout(sessionId); await authService.logout(sessionId);
} }
// Clear cookies // Clear cookies (must use same path as when setting)
res.clearCookie('sessionId'); res.clearCookie('sessionId', { path: '/' });
res.clearCookie('oauth_state'); res.clearCookie('oauth_state', { path: '/' });
res.json({ success: true }); 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 // 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; 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) { if (sessionId) {
const session = authService.getSession(sessionId); try {
if (session) { const session = await authService.getSession(sessionId);
req.sessionId = sessionId; if (session) {
req.user = session.user; req.sessionId = sessionId;
req.accessToken = session.accessToken; 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(); next();
} }
// Middleware to require authentication // Re-export authorization middleware for convenience
export function requireAuth(req: Request, res: Response, next: NextFunction) { export { requireAuth, requireRole, requirePermission, requireAdmin } from '../middleware/authorization.js';
// 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();
}
export default router; export default router;

View File

@@ -8,12 +8,17 @@ import { Router, Request, Response } from 'express';
import { cacheStore } from '../services/cacheStore.js'; import { cacheStore } from '../services/cacheStore.js';
import { syncEngine } from '../services/syncEngine.js'; import { syncEngine } from '../services/syncEngine.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { requireAuth, requirePermission } from '../middleware/authorization.js';
import { getQueryString, getParamString } from '../utils/queryHelpers.js'; import { getQueryString, getParamString } from '../utils/queryHelpers.js';
import { OBJECT_TYPES } from '../generated/jira-schema.js'; import { OBJECT_TYPES } from '../generated/jira-schema.js';
import type { CMDBObjectTypeName } from '../generated/jira-types.js'; import type { CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router(); const router = Router();
// All routes require authentication and manage_settings permission
router.use(requireAuth);
router.use(requirePermission('manage_settings'));
// Get cache status // Get cache status
router.get('/status', async (req: Request, res: Response) => { router.get('/status', async (req: Request, res: Response) => {
try { try {

View File

@@ -4,21 +4,50 @@ import { dataService } from '../services/dataService.js';
import { databaseService } from '../services/database.js'; import { databaseService } from '../services/database.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { config } from '../config/env.js'; import { config } from '../config/env.js';
import { requireAuth, requirePermission } from '../middleware/authorization.js';
import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js'; import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js';
const router = Router(); const router = Router();
// Get AI classification for an application // All routes require authentication
router.post('/suggest/:id', async (req: Request, res: Response) => { router.use(requireAuth);
// Get AI classification for an application (requires search permission)
router.post('/suggest/:id', requirePermission('search'), async (req: Request, res: Response) => {
try { try {
const id = getParamString(req, 'id'); const id = getParamString(req, 'id');
// Get provider from query parameter or request body, default to config // Get provider from query parameter, request body, or user settings (default to 'claude')
const provider = (getQueryString(req, 'provider') as AIProvider) || (req.body.provider as AIProvider) || config.defaultAIProvider; 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 = (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({ res.status(503).json({
error: 'AI classification not available', 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; return;
} }
@@ -29,8 +58,15 @@ router.post('/suggest/:id', async (req: Request, res: Response) => {
return; 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}`); 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); res.json(suggestion);
} catch (error) { } catch (error) {
@@ -92,12 +128,16 @@ router.get('/stats', async (req: Request, res: Response) => {
}); });
// Check if AI is available - returns available providers // 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(); 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({ res.json({
available: availableProviders.length > 0, available: availableProviders.length > 0,
providers: availableProviders, providers: availableProviders,
defaultProvider: config.defaultAIProvider, defaultProvider: userDefaultProvider,
claude: { claude: {
available: aiService.isProviderConfigured('claude'), available: aiService.isProviderConfigured('claude'),
model: 'claude-sonnet-4-20250514', model: 'claude-sonnet-4-20250514',
@@ -128,8 +168,8 @@ router.get('/prompt/:id', async (req: Request, res: Response) => {
} }
}); });
// Chat with AI about an application // Chat with AI about an application (requires search permission)
router.post('/chat/:id', async (req: Request, res: Response) => { router.post('/chat/:id', requirePermission('search'), async (req: Request, res: Response) => {
try { try {
const id = getParamString(req, 'id'); const id = getParamString(req, 'id');
const { message, conversationId, provider: requestProvider } = req.body; const { message, conversationId, provider: requestProvider } = req.body;
@@ -139,12 +179,38 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
return; 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({ res.status(503).json({
error: 'AI chat not available', 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; return;
} }
@@ -155,8 +221,15 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
return; 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}`); 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); res.json(response);
} catch (error) { } catch (error) {

View File

@@ -4,6 +4,7 @@ import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.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 { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js';
import type { DataCompletenessConfig } from '../types/index.js'; import type { DataCompletenessConfig } from '../types/index.js';
@@ -13,9 +14,13 @@ const __dirname = dirname(__filename);
const router = Router(); const router = Router();
// All routes require authentication and manage_settings permission
router.use(requireAuth);
router.use(requirePermission('manage_settings'));
// Path to the configuration files // Path to the configuration files
const CONFIG_FILE_PATH = join(__dirname, '../../data/effort-calculation-config.json'); 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'); const COMPLETENESS_CONFIG_FILE_PATH = join(__dirname, '../../data/data-completeness-config.json');
/** /**

View File

@@ -4,6 +4,7 @@ import { databaseService } from '../services/database.js';
import { syncEngine } from '../services/syncEngine.js'; import { syncEngine } from '../services/syncEngine.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { validateApplicationConfiguration, calculateRequiredEffortWithMinMax } from '../services/effortCalculation.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 type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
@@ -11,6 +12,10 @@ import { fileURLToPath } from 'url';
const router = Router(); 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 // Simple in-memory cache for dashboard stats
interface CachedStats { interface CachedStats {
data: unknown; data: unknown;
@@ -778,6 +783,7 @@ router.get('/data-completeness', async (req: Request, res: Response) => {
byField: byFieldArray, byField: byFieldArray,
byApplication, byApplication,
byTeam: byTeamArray, byTeam: byTeamArray,
config: completenessConfig, // Include config so frontend doesn't need to fetch it separately
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get data completeness', error); logger.error('Failed to get data completeness', error);

View File

@@ -7,12 +7,17 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { cmdbService } from '../services/cmdbService.js'; import { cmdbService } from '../services/cmdbService.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { requireAuth, requirePermission } from '../middleware/authorization.js';
import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js'; import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js';
import { OBJECT_TYPES } from '../generated/jira-schema.js'; import { OBJECT_TYPES } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router(); const router = Router();
// All routes require authentication and search permission
router.use(requireAuth);
router.use(requirePermission('search'));
// Get list of supported object types // Get list of supported object types
router.get('/', (req: Request, res: Response) => { router.get('/', (req: Request, res: Response) => {
const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({ const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({

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 { Router, Request, Response } from 'express';
import { dataService } from '../services/dataService.js'; import { dataService } from '../services/dataService.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { requireAuth } from '../middleware/authorization.js';
const router = Router(); const router = Router();
// All routes require authentication
router.use(requireAuth);
// Get all reference data // Get all reference data
router.get('/', async (req: Request, res: Response) => { router.get('/', async (req: Request, res: Response) => {
try { 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 { dataService } from '../services/dataService.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { jiraAssetsClient } from '../services/jiraAssetsClient.js'; import { jiraAssetsClient } from '../services/jiraAssetsClient.js';
import { requireAuth, requirePermission } from '../middleware/authorization.js';
import type { CMDBObjectTypeName } from '../generated/jira-types.js'; import type { CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router(); const router = Router();
// All routes require authentication and search permission
router.use(requireAuth);
router.use(requirePermission('search'));
// Extended types for API response // Extended types for API response
interface ObjectTypeWithLinks extends ObjectTypeDefinition { interface ObjectTypeWithLinks extends ObjectTypeDefinition {
incomingLinks: Array<{ incomingLinks: Array<{

View File

@@ -1,11 +1,16 @@
import { Router, Request, Response } from 'express'; 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 { logger } from '../services/logger.js';
import { config } from '../config/env.js'; import { config } from '../config/env.js';
import { requireAuth, requirePermission } from '../middleware/authorization.js';
const router = Router(); 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) => { router.get('/', async (req: Request, res: Response) => {
try { try {
const query = req.query.query as string; 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}`); logger.info(`CMDB search request: query="${query}", limit=${limit}`);
// Search all types in cache // Set user token on jiraAssetsService (same logic as middleware)
const results = await cmdbService.searchAllTypes(query.trim(), { limit }); // Use OAuth token if available, otherwise user's PAT, otherwise service account token
if (req.accessToken) {
// Group results by object type jiraAssetsService.setRequestToken(req.accessToken);
const objectTypeMap = new Map<string, { id: number; name: string; iconUrl: string }>(); } else if (req.user && 'id' in req.user) {
const formattedResults = results.map(obj => { const userSettings = (req as any).userSettings;
const typeName = obj._objectType || 'Unknown'; if (userSettings?.jira_pat) {
jiraAssetsService.setRequestToken(userSettings.jira_pat);
// Track unique object types } else if (config.jiraServiceAccountToken) {
if (!objectTypeMap.has(typeName)) { jiraAssetsService.setRequestToken(config.jiraServiceAccountToken);
objectTypeMap.set(typeName, { } else {
id: objectTypeMap.size + 1, jiraAssetsService.setRequestToken(null);
name: typeName,
iconUrl: '', // Can be enhanced to include actual icons
});
} }
} 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 { // Clear token after request
id: parseInt(obj.id, 10) || 0, jiraAssetsService.clearRequestToken();
key: obj.objectKey,
label: obj.label,
objectTypeId: objectType.id,
avatarUrl: '',
attributes: [], // Can be enhanced to include attributes
};
});
// Build response matching CMDBSearchResponse interface res.json(response);
const response = { } catch (error) {
metadata: { // Clear token on error
count: formattedResults.length, jiraAssetsService.clearRequestToken();
offset: 0, throw error;
limit: limit, }
total: formattedResults.length,
criteria: {
query: query,
type: 'global',
schema: parseInt(config.jiraSchemaId, 10) || 0,
},
},
objectTypes: Array.from(objectTypeMap.values()),
results: formattedResults,
};
res.json(response);
} catch (error) { } catch (error) {
logger.error('CMDB search failed', error); logger.error('CMDB search failed', error);
res.status(500).json({ error: 'Failed to search CMDB' }); 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 { config } from '../config/env.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { randomBytes, createHash } from 'crypto'; 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) // Extended user interface for sessions
interface UserSession { export interface SessionUser {
accessToken: string; id: number;
refreshToken?: string; email: string;
expiresAt: number; username: string;
user: JiraUser; displayName: string;
emailAddress?: string;
avatarUrl?: string;
roles: string[];
permissions: string[];
} }
export interface JiraUser { export interface JiraUser {
@@ -17,19 +24,21 @@ export interface JiraUser {
avatarUrl?: string; avatarUrl?: string;
} }
// In-memory session store (replace with Redis in production) interface DatabaseSession {
const sessionStore = new Map<string, UserSession>(); 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) const isPostgres = (): boolean => {
setInterval(() => { return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
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);
// PKCE helpers for OAuth 2.0 // PKCE helpers for OAuth 2.0
export function generateCodeVerifier(): string { export function generateCodeVerifier(): string {
@@ -59,8 +68,192 @@ setInterval(() => {
} }
}, 60 * 1000); }, 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 { 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 } { getAuthorizationUrl(): { url: string; state: string } {
const state = generateState(); const state = generateState();
const codeVerifier = generateCodeVerifier(); const codeVerifier = generateCodeVerifier();
@@ -86,8 +279,15 @@ class AuthService {
return { url: authUrl, state }; 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 // Retrieve and validate state
const flowData = authFlowStore.get(state); const flowData = authFlowStore.get(state);
if (!flowData) { if (!flowData) {
@@ -129,25 +329,52 @@ class AuthService {
token_type: string; token_type: string;
}; };
// Fetch user info // Fetch user info from Jira
const user = await this.fetchUserInfo(tokenData.access_token); const jiraUser = await this.fetchUserInfo(tokenData.access_token);
// Create session // Try to find local user by email
const sessionId = randomBytes(32).toString('hex'); let localUser: User | null = null;
const session: UserSession = { if (jiraUser.emailAddress) {
accessToken: tokenData.access_token, localUser = await userService.getUserByEmail(jiraUser.emailAddress);
refreshToken: tokenData.refresh_token, }
expiresAt: Date.now() + (tokenData.expires_in * 1000),
user,
};
sessionStore.set(sessionId, session); if (localUser) {
logger.info(`Created session for user: ${user.displayName}`); // Link OAuth to existing local user
const sessionId = await this.createSession(
localUser.id,
'jira-oauth',
tokenData.access_token,
tokenData.refresh_token,
ipAddress,
userAgent
);
return { sessionId, user }; const sessionUser = await this.getSessionUser(sessionId);
if (!sessionUser) {
throw new Error('Failed to create session');
}
logger.info(`OAuth login successful for local user: ${localUser.email}`);
return { sessionId, user: sessionUser };
} else {
// Create session without local user (OAuth-only)
const sessionId = await this.createSession(
null,
'jira-oauth',
tokenData.access_token,
tokenData.refresh_token,
ipAddress,
userAgent
);
logger.info(`OAuth login successful for Jira user: ${jiraUser.displayName}`);
return { sessionId, user: jiraUser };
}
} }
// Fetch current user info from Jira /**
* Fetch current user info from Jira
*/
async fetchUserInfo(accessToken: string): Promise<JiraUser> { async fetchUserInfo(accessToken: string): Promise<JiraUser> {
const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, { const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, {
headers: { headers: {
@@ -177,38 +404,54 @@ class AuthService {
}; };
} }
// Get session by ID /**
getSession(sessionId: string): UserSession | null { * Get session by ID
const session = sessionStore.get(sessionId); */
async getSession(sessionId: string): Promise<{ user: SessionUser | JiraUser; accessToken?: string } | null> {
const session = await this.getSessionFromDb(sessionId);
if (!session) { if (!session) {
return null; return null;
} }
// Check if expired if (session.user_id) {
if (session.expiresAt < Date.now()) { // Local user session
sessionStore.delete(sessionId); const user = await this.getSessionUser(sessionId);
return null; if (!user) {
return null;
}
return { user };
} else if (session.access_token) {
// OAuth-only session
const user = await this.fetchUserInfo(session.access_token);
return { user, accessToken: session.access_token };
} }
return session; return null;
} }
// Get access token for a session /**
getAccessToken(sessionId: string): string | null { * Get access token for a session
const session = this.getSession(sessionId); */
return session?.accessToken || null; async getAccessToken(sessionId: string): Promise<string | null> {
const session = await this.getSessionFromDb(sessionId);
return session?.access_token || null;
} }
// Get user for a session /**
* Get user for a session (legacy method for compatibility)
*/
getUser(sessionId: string): JiraUser | null { getUser(sessionId: string): JiraUser | null {
const session = this.getSession(sessionId); // This is a legacy method - use getSessionUser or getSession instead
return session?.user || null; // For now, return null to maintain compatibility
return null;
} }
// Refresh access token /**
* Refresh access token
*/
async refreshAccessToken(sessionId: string): Promise<boolean> { async refreshAccessToken(sessionId: string): Promise<boolean> {
const session = sessionStore.get(sessionId); const session = await this.getSessionFromDb(sessionId);
if (!session?.refreshToken) { if (!session?.refresh_token) {
return false; return false;
} }
@@ -218,7 +461,7 @@ class AuthService {
grant_type: 'refresh_token', grant_type: 'refresh_token',
client_id: config.jiraOAuthClientId, client_id: config.jiraOAuthClientId,
client_secret: config.jiraOAuthClientSecret, client_secret: config.jiraOAuthClientSecret,
refresh_token: session.refreshToken, refresh_token: session.refresh_token,
}); });
try { try {
@@ -241,16 +484,23 @@ class AuthService {
expires_in: number; expires_in: number;
}; };
// Update session // Update session in database
session.accessToken = tokenData.access_token; const db = getAuthDatabase();
if (tokenData.refresh_token) { try {
session.refreshToken = tokenData.refresh_token; 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)}...`); logger.info(`Refreshed token for session: ${sessionId.substring(0, 8)}...`);
return true; return true;
} catch (error) { } catch (error) {
logger.error('Token refresh error:', error); logger.error('Token refresh error:', error);
@@ -258,28 +508,55 @@ class AuthService {
} }
} }
// Logout / destroy session /**
logout(sessionId: string): boolean { * Logout / destroy session
const existed = sessionStore.has(sessionId); */
sessionStore.delete(sessionId); async logout(sessionId: string): Promise<boolean> {
if (existed) { const db = getAuthDatabase();
logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`); 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 { isOAuthEnabled(): boolean {
return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret; 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 { 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.isOAuthEnabled()) return 'oauth';
if (this.isUsingServiceAccount()) return 'pat'; if (this.isUsingServiceAccount()) return 'pat';
return 'none'; return 'none';
@@ -287,4 +564,3 @@ class AuthService {
} }
export const authService = new AuthService(); export const authService = new AuthService();

View File

@@ -337,8 +337,9 @@ interface TavilySearchResponse {
} }
// Perform web search using Tavily API // Perform web search using Tavily API
async function performWebSearch(query: string): Promise<string | null> { async function performWebSearch(query: string, tavilyApiKey?: string): Promise<string | null> {
if (!config.enableWebSearch || !config.tavilyApiKey) { // Tavily API key must be provided - it's configured in user profile settings
if (!tavilyApiKey) {
return null; return null;
} }
@@ -349,7 +350,7 @@ async function performWebSearch(query: string): Promise<string | null> {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
api_key: config.tavilyApiKey, api_key: apiKey,
query: query, query: query,
search_depth: 'basic', search_depth: 'basic',
include_answer: true, include_answer: true,
@@ -610,49 +611,56 @@ class AIService {
private openaiClient: OpenAI | null = null; private openaiClient: OpenAI | null = null;
constructor() { constructor() {
if (config.anthropicApiKey) { // AI API keys are now configured per-user in their profile settings
this.anthropicClient = new Anthropic({ // Global clients are not initialized - clients are created per-request with user keys
apiKey: config.anthropicApiKey, logger.info('AI service initialized - API keys must be configured in user profile settings');
});
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.');
}
} }
// Check if a specific provider is configured // 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 { isProviderConfigured(provider: AIProvider): boolean {
if (provider === 'claude') { // Always return true - configuration is checked per-request with user keys
return this.anthropicClient !== null; // This maintains backward compatibility for the isConfigured() method
} else { return true;
return this.openaiClient !== null;
}
} }
// Get available providers // Get available providers
getAvailableProviders(): AIProvider[] { getAvailableProviders(): AIProvider[] {
const providers: AIProvider[] = []; // Providers are available if users have configured API keys in their settings
if (this.anthropicClient) providers.push('claude'); // This method is kept for backward compatibility but always returns both providers
if (this.openaiClient) providers.push('openai'); // The actual availability is checked per-request with user API keys
return providers; return ['claude', 'openai'];
} }
async classifyApplication(application: ApplicationDetails, provider: AIProvider = config.defaultAIProvider): Promise<AISuggestion> { async classifyApplication(
// Validate provider application: ApplicationDetails,
if (provider === 'claude' && !this.anthropicClient) { provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings
throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.'); 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) { if (openaiKey) {
throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.'); 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 // Check if web search is needed
@@ -661,7 +669,7 @@ class AIService {
logger.info(`Insufficient information detected for ${application.name}, performing web search...`); logger.info(`Insufficient information detected for ${application.name}, performing web search...`);
const supplierPart = application.supplierProduct ? `${application.supplierProduct} ` : ''; const supplierPart = application.supplierProduct ? `${application.supplierProduct} ` : '';
const searchQuery = `${application.name} ${supplierPart}healthcare software`.trim(); const searchQuery = `${application.name} ${supplierPart}healthcare software`.trim();
webSearchResults = await performWebSearch(searchQuery); webSearchResults = await performWebSearch(searchQuery, tavilyKey);
if (webSearchResults) { if (webSearchResults) {
logger.info(`Web search completed for ${application.name}`); logger.info(`Web search completed for ${application.name}`);
} else { } else {
@@ -719,8 +727,12 @@ class AIService {
let responseText: string; let responseText: string;
if (provider === 'claude') { if (provider === 'claude') {
// Use Claude (Anthropic) // Use Claude (Anthropic) - client created from user API key
const message = await this.anthropicClient!.messages.create({ 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', model: 'claude-sonnet-4-20250514',
max_tokens: 4096, max_tokens: 4096,
messages: [ messages: [
@@ -737,8 +749,12 @@ class AIService {
} }
responseText = textBlock.text.trim(); responseText = textBlock.text.trim();
} else { } else {
// Use OpenAI // Use OpenAI - client created from user API key
const completion = 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 client = openaiClient;
const completion = await client.chat.completions.create({
model: 'gpt-4o', model: 'gpt-4o',
max_tokens: 4096, max_tokens: 4096,
messages: [ messages: [
@@ -884,7 +900,7 @@ class AIService {
async classifyBatch( async classifyBatch(
applications: ApplicationDetails[], applications: ApplicationDetails[],
onProgress?: (completed: number, total: number) => void, 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>> { ): Promise<Map<string, AISuggestion>> {
const results = new Map<string, AISuggestion>(); const results = new Map<string, AISuggestion>();
const total = applications.length; const total = applications.length;
@@ -936,8 +952,9 @@ class AIService {
if (provider) { if (provider) {
return this.isProviderConfigured(provider); return this.isProviderConfigured(provider);
} }
// Return true if at least one provider is configured // Configuration is checked per-request with user API keys
return this.anthropicClient !== null || this.openaiClient !== null; // This method is kept for backward compatibility
return true;
} }
// Get the prompt that would be sent to the AI for a given application // Get the prompt that would be sent to the AI for a given application
@@ -1011,14 +1028,30 @@ class AIService {
application: ApplicationDetails, application: ApplicationDetails,
userMessage: string, userMessage: string,
conversationId?: 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> { ): Promise<ChatResponse> {
// Validate provider // API keys must be provided via userApiKeys - they're configured in user profile settings
if (provider === 'claude' && !this.anthropicClient) { const anthropicKey = userApiKeys?.anthropic;
throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.'); 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) { if (openaiKey) {
throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.'); 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 // Get or create conversation
@@ -1062,7 +1095,11 @@ class AIService {
const systemMessage = aiMessages.find(m => m.role === 'system'); const systemMessage = aiMessages.find(m => m.role === 'system');
const otherMessages = aiMessages.filter(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', model: 'claude-sonnet-4-20250514',
max_tokens: 4096, max_tokens: 4096,
system: systemMessage?.content || '', system: systemMessage?.content || '',
@@ -1075,7 +1112,11 @@ class AIService {
assistantContent = response.content[0].type === 'text' ? response.content[0].text : ''; assistantContent = response.content[0].type === 'text' ? response.content[0].text : '';
} else { } else {
// OpenAI // 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', model: 'gpt-4o',
max_tokens: 4096, max_tokens: 4096,
messages: aiMessages.map(m => ({ messages: aiMessages.map(m => ({

View File

@@ -79,6 +79,13 @@ class CMDBService {
): Promise<T | null> { ): Promise<T | null> {
// Force refresh: search Jira by key // Force refresh: search Jira by key
if (options?.forceRefresh) { 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]; const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) return null; if (!typeDef) return null;
@@ -235,7 +242,15 @@ class CMDBService {
return { success: true }; 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); const success = await jiraAssetsClient.updateObject(id, payload);
if (!success) { if (!success) {
@@ -271,6 +286,14 @@ class CMDBService {
id: string, id: string,
updates: Record<string, unknown> updates: Record<string, unknown>
): Promise<UpdateResult> { ): 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 { try {
const payload = this.buildUpdatePayload(typeName, updates); const payload = this.buildUpdatePayload(typeName, updates);

View File

@@ -48,7 +48,9 @@ import { calculateRequiredEffortWithMinMax } from './effortCalculation.js';
import { calculateApplicationCompleteness } from './dataCompletenessConfig.js'; import { calculateApplicationCompleteness } from './dataCompletenessConfig.js';
// Determine if we should use real Jira Assets or mock data // 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) { if (useJiraAssets) {
logger.info('DataService: Using CMDB cache layer with Jira Assets API'); 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 * 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; 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 { return {
objectId: ref.objectId, objectId: ref.objectId,
key: ref.objectKey, key: ref.objectKey,
@@ -188,25 +221,49 @@ function extractDisplayValue(value: unknown): string | null {
* References are now stored as ObjectReference objects directly (not IDs) * References are now stored as ObjectReference objects directly (not IDs)
*/ */
async function toApplicationDetails(app: ApplicationComponent): Promise<ApplicationDetails> { 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 // Ensure factor caches are loaded for factor value lookup
await ensureFactorCaches(); await ensureFactorCaches();
// Convert ObjectReference to ReferenceValue format // Convert ObjectReference to ReferenceValue format
const governanceModel = toReferenceValue(app.ictGovernanceModel); // Fetch descriptions async if not in cache
// Note: applicationManagementSubteam and applicationManagementTeam are not in the generated schema // Use Promise.all to fetch all reference values in parallel for better performance
// They are only available when fetching directly from Jira API (via jiraAssetsClient) const [
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); governanceModel,
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); applicationSubteam,
const applicationType = toReferenceValue(app.applicationManagementApplicationType); applicationTeam,
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting); applicationType,
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM); applicationManagementHosting,
const hostingType = toReferenceValue(app.applicationComponentHostingType); applicationManagementTAM,
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse); hostingType,
const platform = toReferenceValue(app.platform); businessImpactAnalyse,
const organisation = toReferenceValue(app.organisation); platform,
const businessImportance = toReferenceValue(app.businessImportance); 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) // Look up factor values from cached factor objects (same as toMinimalDetailsForEffort)
// Also include descriptions from cache if available
let dynamicsFactor: ReferenceValue | null = null; let dynamicsFactor: ReferenceValue | null = null;
if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') { if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') {
const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId); const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId);
@@ -215,6 +272,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
key: app.applicationManagementDynamicsFactor.objectKey, key: app.applicationManagementDynamicsFactor.objectKey,
name: app.applicationManagementDynamicsFactor.label, name: app.applicationManagementDynamicsFactor.label,
factor: factorObj?.factor ?? undefined, 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, key: app.applicationManagementComplexityFactor.objectKey,
name: app.applicationManagementComplexityFactor.label, name: app.applicationManagementComplexityFactor.label,
factor: factorObj?.factor ?? undefined, 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, key: app.applicationManagementNumberOfUsers.objectKey,
name: app.applicationManagementNumberOfUsers.label, name: app.applicationManagementNumberOfUsers.label,
factor: factorObj?.factor ?? undefined, factor: factorObj?.factor ?? undefined,
description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types)
}; };
} }
@@ -286,6 +346,12 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
// Override // Override
overrideFTE: app.applicationManagementOverrideFTE ?? null, overrideFTE: app.applicationManagementOverrideFTE ?? null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
// Enterprise Architect reference
reference: app.reference || null,
// Confluence Space (URL string)
confluenceSpace: confluenceSpaceValue,
}; };
// Calculate data completeness percentage // Calculate data completeness percentage
@@ -356,6 +422,12 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
overrideFTE: app.applicationManagementOverrideFTE ?? null, overrideFTE: app.applicationManagementOverrideFTE ?? null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
// Enterprise Architect reference
reference: app.reference || null,
// Confluence Space (URL string)
confluenceSpace: confluenceSpaceValue,
}; };
} }
@@ -397,11 +469,18 @@ function clearFactorCaches(): void {
* This avoids the overhead of toApplicationDetails while providing enough data for effort calculation * This avoids the overhead of toApplicationDetails while providing enough data for effort calculation
* Note: ensureFactorCaches() must be called before using this function * Note: ensureFactorCaches() must be called before using this function
*/ */
function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetails { async function toMinimalDetailsForEffort(app: ApplicationComponent): Promise<ApplicationDetails> {
const governanceModel = toReferenceValue(app.ictGovernanceModel); const [
const applicationType = toReferenceValue(app.applicationManagementApplicationType); governanceModel,
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse); applicationType,
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting); 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 // Look up factor values from cached factor objects
let dynamicsFactor: ReferenceValue | null = null; let dynamicsFactor: ReferenceValue | null = null;
@@ -434,6 +513,7 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
key: app.applicationManagementNumberOfUsers.objectKey, key: app.applicationManagementNumberOfUsers.objectKey,
name: app.applicationManagementNumberOfUsers.label, name: app.applicationManagementNumberOfUsers.label,
factor: factorObj?.factor ?? undefined, factor: factorObj?.factor ?? undefined,
description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types)
}; };
} }
@@ -474,23 +554,38 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
/** /**
* Convert ApplicationComponent to ApplicationListItem (lighter weight, for lists) * 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 // Use direct ObjectReference conversion instead of lookups
const governanceModel = toReferenceValue(app.ictGovernanceModel); // Fetch all reference values in parallel
const dynamicsFactor = toReferenceValue(app.applicationManagementDynamicsFactor); const [
const complexityFactor = toReferenceValue(app.applicationManagementComplexityFactor); governanceModel,
// Note: Team/Subteam fields are not in generated schema, use type assertion dynamicsFactor,
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); complexityFactor,
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); applicationSubteam,
const applicationType = toReferenceValue(app.applicationManagementApplicationType); applicationTeam,
const platform = toReferenceValue(app.platform); 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 applicationFunctions = toReferenceValues(app.applicationFunction);
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM);
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
// Calculate effort using minimal details // Calculate effort using minimal details
const minimalDetails = toMinimalDetailsForEffort(app);
const effortResult = calculateRequiredEffortWithMinMax(minimalDetails); const effortResult = calculateRequiredEffortWithMinMax(minimalDetails);
const result: ApplicationListItem = { const result: ApplicationListItem = {
@@ -518,12 +613,17 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
// Calculate data completeness percentage // Calculate data completeness percentage
// Convert ApplicationListItem to format expected by completeness calculator // Convert ApplicationListItem to format expected by completeness calculator
const [organisationRef, hostingTypeRef] = await Promise.all([
toReferenceValue(app.organisation),
toReferenceValue(app.applicationComponentHostingType),
]);
const appForCompleteness = { const appForCompleteness = {
organisation: toReferenceValue(app.organisation)?.name || null, organisation: organisationRef?.name || null,
applicationFunctions: result.applicationFunctions, applicationFunctions: result.applicationFunctions,
status: result.status, status: result.status,
businessImpactAnalyse: result.businessImpactAnalyse, businessImpactAnalyse: result.businessImpactAnalyse,
hostingType: toReferenceValue(app.applicationComponentHostingType), hostingType: hostingTypeRef,
supplierProduct: app.supplierProduct?.label || null, supplierProduct: app.supplierProduct?.label || null,
businessOwner: app.businessOwner?.label || null, businessOwner: app.businessOwner?.label || null,
systemOwner: app.systemOwner?.label || null, systemOwner: app.systemOwner?.label || null,
@@ -535,7 +635,7 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
applicationManagementTAM: result.applicationManagementTAM, applicationManagementTAM: result.applicationManagementTAM,
dynamicsFactor: result.dynamicsFactor, dynamicsFactor: result.dynamicsFactor,
complexityFactor: result.complexityFactor, complexityFactor: result.complexityFactor,
numberOfUsers: toReferenceValue(app.applicationManagementNumberOfUsers), numberOfUsers: await toReferenceValue(app.applicationManagementNumberOfUsers),
}; };
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness); const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
@@ -718,8 +818,8 @@ export const dataService = {
// Ensure factor caches are loaded for effort calculation // Ensure factor caches are loaded for effort calculation
await ensureFactorCaches(); await ensureFactorCaches();
// Convert to list items (synchronous now) // Convert to list items (async now to fetch descriptions)
const applications = paginatedApps.map(toApplicationListItem); const applications = await Promise.all(paginatedApps.map(toApplicationListItem));
return { return {
applications, applications,
@@ -1221,8 +1321,8 @@ export const dataService = {
for (const app of apps) { for (const app of apps) {
// Get team from application (via subteam lookup if needed) // Get team from application (via subteam lookup if needed)
let team: ReferenceValue | null = null; let team: ReferenceValue | null = null;
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); const applicationSubteam = await toReferenceValue((app as any).applicationManagementSubteam);
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); const applicationTeam = await toReferenceValue((app as any).applicationManagementTeam);
// Prefer direct team assignment, otherwise try to get from subteam // Prefer direct team assignment, otherwise try to get from subteam
if (applicationTeam) { if (applicationTeam) {
@@ -1265,7 +1365,7 @@ export const dataService = {
// Get BIA value // Get BIA value
if (app.businessImpactAnalyse) { if (app.businessImpactAnalyse) {
const biaRef = toReferenceValue(app.businessImpactAnalyse); const biaRef = await toReferenceValue(app.businessImpactAnalyse);
if (biaRef) { if (biaRef) {
const biaNum = biaToNumeric(biaRef.name); const biaNum = biaToNumeric(biaRef.name);
if (biaNum !== null) metrics.biaValues.push(biaNum); if (biaNum !== null) metrics.biaValues.push(biaNum);
@@ -1274,7 +1374,7 @@ export const dataService = {
// Get governance maturity // Get governance maturity
if (app.ictGovernanceModel) { if (app.ictGovernanceModel) {
const govRef = toReferenceValue(app.ictGovernanceModel); const govRef = await toReferenceValue(app.ictGovernanceModel);
if (govRef) { if (govRef) {
const maturity = governanceToMaturity(govRef.name); const maturity = governanceToMaturity(govRef.name);
if (maturity !== null) metrics.governanceValues.push(maturity); if (maturity !== null) metrics.governanceValues.push(maturity);
@@ -1327,6 +1427,10 @@ export const dataService = {
async testConnection(): Promise<boolean> { async testConnection(): Promise<boolean> {
if (!useJiraAssets) return true; if (!useJiraAssets) return true;
// Only test connection if token is configured
if (!jiraAssetsClient.hasToken()) {
return false;
}
return jiraAssetsClient.testConnection(); return jiraAssetsClient.testConnection();
}, },
@@ -1413,7 +1517,7 @@ export const dataService = {
if (!app.id || !app.label) continue; if (!app.id || !app.label) continue;
// Extract Business Importance from app object // Extract Business Importance from app object
const businessImportanceRef = toReferenceValue(app.businessImportance); const businessImportanceRef = await toReferenceValue(app.businessImportance);
const businessImportanceName = businessImportanceRef?.name || null; const businessImportanceName = businessImportanceRef?.name || null;
// Normalize Business Importance // Normalize Business Importance
@@ -1436,7 +1540,7 @@ export const dataService = {
} }
// Extract BIA from app object // Extract BIA from app object
const businessImpactAnalyseRef = toReferenceValue(app.businessImpactAnalyse); const businessImpactAnalyseRef = await toReferenceValue(app.businessImpactAnalyse);
// Normalize BIA Class // Normalize BIA Class
let biaClass: string | null = null; let biaClass: string | null = null;

View File

@@ -16,8 +16,9 @@ const __dirname = dirname(__filename);
/** /**
* Create a database adapter based on environment variables * 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 type = dbType || process.env.DATABASE_TYPE || 'sqlite';
const databaseUrl = process.env.DATABASE_URL; 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}`; const constructedUrl = `postgresql://${user}:${password}@${host}:${port}/${name}${ssl}`;
logger.info('Creating PostgreSQL adapter with constructed connection string'); logger.info('Creating PostgreSQL adapter with constructed connection string');
return new PostgresAdapter(constructedUrl); return new PostgresAdapter(constructedUrl, allowClose);
} }
logger.info('Creating PostgreSQL adapter'); logger.info('Creating PostgreSQL adapter');
return new PostgresAdapter(databaseUrl); return new PostgresAdapter(databaseUrl, allowClose);
} }
// Default to SQLite // 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 { export class PostgresAdapter implements DatabaseAdapter {
private pool: Pool; private pool: Pool;
private connectionString: string; 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.connectionString = connectionString;
this.allowClose = allowClose;
this.pool = new Pool({ this.pool = new Pool({
connectionString, connectionString,
max: 20, // Maximum number of clients in the pool max: 20, // Maximum number of clients in the pool
@@ -124,7 +127,23 @@ export class PostgresAdapter implements DatabaseAdapter {
} }
async close(): Promise<void> { async close(): Promise<void> {
await this.pool.end(); // Don't close singleton instances - they should remain open for the app lifetime
if (!this.allowClose) {
return;
}
// Make close() idempotent - safe to call multiple times
if (this.isClosed) {
return;
}
try {
await this.pool.end();
this.isClosed = true;
} catch (error) {
// Pool might already be closed, ignore the error
this.isClosed = true;
}
} }
async getSizeBytes(): Promise<number> { async getSizeBytes(): Promise<number> {

View File

@@ -18,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// Path to the configuration file (v25) // 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 // Cache for loaded configuration
let cachedConfigV25: EffortCalculationConfigV25 | null = null; let cachedConfigV25: EffortCalculationConfigV25 | null = null;
@@ -275,12 +275,6 @@ export function calculateRequiredEffortApplicationManagementV25(
breakdown.businessImpactAnalyse = biaClass; breakdown.businessImpactAnalyse = biaClass;
breakdown.applicationManagementHosting = applicationManagementHosting; 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 // Level 1: Find Regiemodel configuration
if (!regieModelCode || !config.regiemodellen[regieModelCode]) { if (!regieModelCode || !config.regiemodellen[regieModelCode]) {
breakdown.errors.push(`Geen configuratie gevonden voor regiemodel: ${governanceModelRaw || 'niet ingesteld'}`); breakdown.errors.push(`Geen configuratie gevonden voor regiemodel: ${governanceModelRaw || 'niet ingesteld'}`);
@@ -413,10 +407,6 @@ export function calculateRequiredEffortApplicationManagementV25(
breakdown.hoursPerMonth = breakdown.hoursPerYear / 12; breakdown.hoursPerMonth = breakdown.hoursPerYear / 12;
breakdown.hoursPerWeek = breakdown.hoursPerYear / NET_WORK_WEEKS; 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 }; return { finalEffort, breakdown };
} catch (error) { } 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_HOSTING: 'Application Management - Hosting',
APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM', APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM',
TECHNISCHE_ARCHITECTUUR: 'Technische Architectuur (TA)', 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) // 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; private numberOfUsersCache: Map<string, ReferenceValue> | null = null;
// Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue) // Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue)
private referenceObjectCache: Map<string, ReferenceValue> = new Map(); 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 // Cache: Team dashboard data
private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null; private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null;
private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 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) // Try both API paths - Insight (Data Center) and Assets (Cloud)
this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`; this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`;
this.assetsBaseUrl = `${config.jiraHost}/rest/assets/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 = { this.defaultHeaders = {
Authorization: `Bearer ${config.jiraPat}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}; };
@@ -136,9 +143,14 @@ class JiraAssetsService {
this.requestToken = null; 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> { 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 { return {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -153,12 +165,15 @@ class JiraAssetsService {
private async request<T>( private async request<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {},
retryCount: number = 0
): Promise<T> { ): Promise<T> {
const url = `${this.getBaseUrl()}${endpoint}`; const url = `${this.getBaseUrl()}${endpoint}`;
const maxRetries = 3;
const retryableStatusCodes = [502, 503, 504]; // Bad Gateway, Service Unavailable, Gateway Timeout
try { 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, { const response = await fetch(url, {
...options, ...options,
headers: { headers: {
@@ -169,11 +184,29 @@ class JiraAssetsService {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); 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>; return response.json() as Promise<T>;
} catch (error) { } 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); logger.error(`Jira API request failed: ${endpoint}`, error);
throw error; throw error;
} }
@@ -285,19 +318,117 @@ class JiraAssetsService {
attrSchema?: Map<number, string> attrSchema?: Map<number, string>
): string | null { ): string | null {
const attr = this.getAttributeByName(obj, attributeName, attrSchema); const attr = this.getAttributeByName(obj, attributeName, attrSchema);
if (!attr || attr.objectAttributeValues.length === 0) {
return null; // Enhanced logging for Reference field
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
if (!attr) {
// Log all available attributes with their names and IDs for debugging
const availableAttrs = obj.attributes?.map(a => {
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
const attrName = a.objectTypeAttribute?.name || 'unnamed';
return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`;
}).join(', ') || 'none';
logger.warn(`Reference attribute "${ATTRIBUTE_NAMES.REFERENCE}" not found for object ${obj.objectKey}.`);
logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`);
// Try to find similar attribute names (case-insensitive, partial matches)
const similarAttrs = obj.attributes?.filter(a => {
const attrName = a.objectTypeAttribute?.name || '';
const lowerAttrName = attrName.toLowerCase();
return lowerAttrName.includes('reference') || lowerAttrName.includes('enterprise') || lowerAttrName.includes('architect');
});
if (similarAttrs && similarAttrs.length > 0) {
logger.warn(`Found similar attributes that might be the Reference field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
}
return null;
}
if (attr.objectAttributeValues.length === 0) {
logger.warn(`Reference attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`);
return null;
}
logger.info(`Reference attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`);
} else if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) {
// Enhanced logging for Confluence Space field
if (!attr) {
// Log all available attributes with their names and IDs for debugging
const availableAttrs = obj.attributes?.map(a => {
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
const attrName = a.objectTypeAttribute?.name || 'unnamed';
return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`;
}).join(', ') || 'none';
logger.warn(`Confluence Space attribute "${ATTRIBUTE_NAMES.CONFLUENCE_SPACE}" not found for object ${obj.objectKey}.`);
logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`);
// Try to find similar attribute names (case-insensitive, partial matches)
const similarAttrs = obj.attributes?.filter(a => {
const attrName = a.objectTypeAttribute?.name || '';
const lowerAttrName = attrName.toLowerCase();
return lowerAttrName.includes('confluence') || lowerAttrName.includes('space');
});
if (similarAttrs && similarAttrs.length > 0) {
logger.warn(`Found similar attributes that might be the Confluence Space field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
}
return null;
}
if (attr.objectAttributeValues.length === 0) {
logger.warn(`Confluence Space attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`);
return null;
}
logger.info(`Confluence Space attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`);
} else {
if (!attr || attr.objectAttributeValues.length === 0) {
return null;
}
} }
const value = attr.objectAttributeValues[0]; const value = attr.objectAttributeValues[0];
// For select/status fields, use displayValue; for text fields, use value // For select/status fields, use displayValue; for text fields, use value
let result: string | null = null;
if (value.displayValue !== undefined && value.displayValue !== null) { if (value.displayValue !== undefined && value.displayValue !== null) {
return value.displayValue; result = String(value.displayValue); // Ensure it's a string
} else if (value.value !== undefined && value.value !== null) {
result = String(value.value); // Ensure it's a string
} }
if (value.value !== undefined && value.value !== null) {
return value.value; // Enhanced logging for Reference field
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
logger.info(`Reference field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`);
} }
return null;
// Enhanced logging for Confluence Space field
if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) {
logger.info(`Confluence Space field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`);
logger.info(`Confluence Space raw attribute: ${JSON.stringify(attr, null, 2)}`);
}
// Check if result is the string "undefined" (which shouldn't happen but could)
if (result === 'undefined') {
logger.warn(`Reference field has string value "undefined" for object ${obj.objectKey}. This indicates a problem with the data.`);
return null;
}
// Normalize empty/whitespace-only strings to null
// This handles: empty strings, whitespace-only, Unicode whitespace, zero-width chars
if (result !== null && typeof result === 'string') {
const trimmed = result.trim();
// Check if empty after trim, or only whitespace (including Unicode whitespace)
if (trimmed === '' || /^\s*$/.test(result) || trimmed.replace(/[\u200B-\u200D\uFEFF]/g, '') === '') {
// Log for Reference field to help debug
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
logger.debug(`Normalizing empty Reference field to null for object ${obj.objectKey}. Original value: "${result}" (length: ${result.length})`);
}
return null;
}
}
// Final logging for Reference field
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
logger.info(`Reference field final result for object ${obj.objectKey}: "${result}"`);
}
return result;
} }
// Get attribute value by attribute ID (useful when we know the ID but not the name) // Get attribute value by attribute ID (useful when we know the ID but not the name)
@@ -416,6 +547,146 @@ class JiraAssetsService {
} }
// Get reference value with schema fallback for attribute lookup // 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( private async getReferenceValueWithSchema(
obj: JiraAssetsObject, obj: JiraAssetsObject,
attributeName: string, attributeName: string,
@@ -428,53 +699,188 @@ class JiraAssetsService {
const value = attr.objectAttributeValues[0]; const value = attr.objectAttributeValues[0];
if (value.referencedObject) { if (value.referencedObject) {
return { // Try to get description from the embedded referenced object
objectId: value.referencedObject.id.toString(), // Embedded referenced objects might not have all attributes, so we might need to fetch separately
key: value.referencedObject.objectKey, const embeddedRefObj = value.referencedObject;
name: value.referencedObject.label,
}; // 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 // 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 // Note: value.value might be an object key (e.g., "GOV-A") or an object ID
if (value.value && !value.referencedObject) { if (value.value && !value.referencedObject) {
// Check cache first // Check cache first - only use if it has description
const cached = this.referenceObjectCache.get(value.value); const cached = this.referenceObjectCache.get(value.value);
if (cached) { if (cached && cached.description) {
return cached; return cached;
} }
try { // Check if there's already a pending request for this value
// Try to fetch the referenced object by its key or ID const pendingRequest = this.pendingReferenceRequests.get(value.value);
// First try as object key (most common) if (pendingRequest) {
let refObj: JiraAssetsObject | null = null; // Wait for the existing request instead of creating a duplicate
try { return pendingRequest;
refObj = await this.request<JiraAssetsObject>(`/object/${value.value}`); }
} catch (keyError) {
// If that fails, try as object ID // Create a new request and store it in pending requests
try { const fetchPromise = (async (): Promise<ReferenceValue | null> => {
refObj = await this.request<JiraAssetsObject>(`/object/${parseInt(value.value, 10)}`); if (!value.value) {
} catch (idError) { return null;
// 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}`);
}
} }
if (refObj) { try {
const refValue: ReferenceValue = { // Try to fetch the referenced object by its key or ID
objectId: refObj.id.toString(), // First try as object key (most common)
key: refObj.objectKey, let refObj: JiraAssetsObject | null = null;
name: refObj.label, try {
}; refObj = await this.request<JiraAssetsObject>(`/object/${value.value}`);
// Cache it for future use } catch (keyError) {
this.referenceObjectCache.set(value.value, refValue); // If that fails, try as object ID
this.referenceObjectCache.set(refObj.objectKey, refValue); try {
this.referenceObjectCache.set(refObj.id.toString(), refValue); refObj = await this.request<JiraAssetsObject>(`/object/${parseInt(value.value, 10)}`);
return refValue; } catch (idError) {
// 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);
this.referenceObjectCache.set(refObj.objectKey, refValue);
this.referenceObjectCache.set(refObj.id.toString(), refValue);
return refValue;
}
} catch (error) {
logger.warn(`getReferenceValueWithSchema: Fallback fetch failed for ${value.value}`, error);
} }
} catch (error) { return null;
// 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);
// 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), 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 dynamicsFactor = this.enrichWithFactor(dynamicsFactorRaw, this.dynamicsFactorsCache);
const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache); const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache);
const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache); 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 // 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> { 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 appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema);
const rawDescription = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.DESCRIPTION, attrSchema); const rawDescription = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.DESCRIPTION, attrSchema);
@@ -670,6 +1075,9 @@ class JiraAssetsService {
platform, platform,
applicationManagementHosting, applicationManagementHosting,
applicationManagementTAM, applicationManagementTAM,
supplierTechnical,
supplierImplementation,
supplierConsultancy,
] = await Promise.all([ ] = await Promise.all([
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema), this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema),
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_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.PLATFORM, attrSchema),
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema), this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema),
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, 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 // Enrich with factors
@@ -689,6 +1100,19 @@ class JiraAssetsService {
const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache); const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache);
const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache); 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 = { const applicationDetails: ApplicationDetails = {
id: obj.id.toString(), id: obj.id.toString(),
key: obj.objectKey, key: obj.objectKey,
@@ -716,7 +1140,7 @@ class JiraAssetsService {
governanceModel, governanceModel,
// "Application Management - Subteam" on ApplicationComponent references Subteam objects // "Application Management - Subteam" on ApplicationComponent references Subteam objects
applicationSubteam, applicationSubteam,
applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent applicationTeam, // Team is looked up via Subteam
applicationType, applicationType,
platform, platform,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -729,6 +1153,97 @@ class JiraAssetsService {
})(), })(),
applicationManagementHosting, applicationManagementHosting,
applicationManagementTAM, 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 // Calculate required effort application management
@@ -1201,34 +1716,6 @@ class JiraAssetsService {
logger.info(`Cached attribute schema for ${objectType}: ${attrSchema.size} attributes`); 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) => { const results = response.objectEntries.map((obj) => {
@@ -1314,11 +1801,6 @@ class JiraAssetsService {
return result; 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; return results;
} catch (error) { } catch (error) {
logger.error(`Failed to get reference objects for type: ${objectType}`, error); logger.error(`Failed to get reference objects for type: ${objectType}`, error);
@@ -1546,6 +2028,118 @@ class JiraAssetsService {
return this.getReferenceObjects('Application Management - TAM'); 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> { async testConnection(): Promise<boolean> {
try { try {
await this.detectApiType(); await this.detectApiType();
@@ -2396,7 +2990,7 @@ class JiraAssetsService {
`attributes=Key,Object+Type,Label,Name,Description,Status&` + `attributes=Key,Object+Type,Label,Name,Description,Status&` +
`offset=0&limit=${limit}`; `offset=0&limit=${limit}`;
logger.info(`CMDB search: ${searchUrl}`); logger.info(`CMDB search API call - Query: "${query}", URL: ${searchUrl}`);
const response = await fetch(searchUrl, { const response = await fetch(searchUrl, {
method: 'GET', method: 'GET',

View File

@@ -49,7 +49,8 @@ class JiraAssetsClient {
private baseUrl: string; private baseUrl: string;
private defaultHeaders: Record<string, string>; private defaultHeaders: Record<string, string>;
private isDataCenter: boolean | null = null; 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() { constructor() {
this.baseUrl = `${config.jiraHost}/rest/insight/1.0`; this.baseUrl = `${config.jiraHost}/rest/insight/1.0`;
@@ -58,17 +59,18 @@ class JiraAssetsClient {
'Accept': 'application/json', 'Accept': 'application/json',
}; };
// Add PAT authentication if configured // Initialize service account token from config (for read operations)
if (config.jiraAuthMethod === 'pat' && config.jiraPat) { this.serviceAccountToken = config.jiraServiceAccountToken || null;
this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`;
} // User PAT is configured per-user in profile settings
// Authorization header is set per-request via setRequestToken()
} }
// ========================================================================== // ==========================================================================
// Request Token Management (for user-context requests) // Request Token Management (for user-context requests)
// ========================================================================== // ==========================================================================
setRequestToken(token: string): void { setRequestToken(token: string | null): void {
this.requestToken = token; this.requestToken = token;
} }
@@ -76,6 +78,21 @@ class JiraAssetsClient {
this.requestToken = null; 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 // 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 }; const headers = { ...this.defaultHeaders };
// Use request-scoped token if available (for user context) if (forWrite) {
if (this.requestToken) { // 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}`; 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; return headers;
@@ -110,15 +141,21 @@ class JiraAssetsClient {
// Core API Methods // 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}`; 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, { const response = await fetch(url, {
...options, ...options,
headers: { headers: {
...this.getHeaders(), ...this.getHeaders(forWrite),
...options.headers, ...options.headers,
}, },
}); });
@@ -136,10 +173,16 @@ class JiraAssetsClient {
// ========================================================================== // ==========================================================================
async testConnection(): Promise<boolean> { 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 { try {
await this.detectApiType(); await this.detectApiType();
const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, { 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; return response.ok;
} catch (error) { } catch (error) {
@@ -150,7 +193,9 @@ class JiraAssetsClient {
async getObject(objectId: string): Promise<JiraAssetsObject | null> { async getObject(objectId: string): Promise<JiraAssetsObject | null> {
try { 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) { } catch (error) {
// Check if this is a 404 (object not found / deleted) // Check if this is a 404 (object not found / deleted)
if (error instanceof Error && error.message.includes('404')) { if (error instanceof Error && error.message.includes('404')) {
@@ -182,7 +227,7 @@ class JiraAssetsClient {
includeAttributesDeep: '1', includeAttributesDeep: '1',
objectSchemaId: config.jiraSchemaId, 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) { } catch (error) {
// Fallback to deprecated IQL endpoint // Fallback to deprecated IQL endpoint
logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`); logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`);
@@ -194,7 +239,7 @@ class JiraAssetsClient {
includeAttributesDeep: '1', includeAttributesDeep: '1',
objectSchemaId: config.jiraSchemaId, 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 { } else {
// Jira Cloud uses POST for AQL // Jira Cloud uses POST for AQL
@@ -205,8 +250,9 @@ class JiraAssetsClient {
page, page,
resultPerPage: pageSize, resultPerPage: pageSize,
includeAttributes: true, includeAttributes: true,
includeAttributesDeep: 1, // Include attributes of referenced objects (e.g., descriptions)
}), }),
}); }, false); // Read operation
} }
const totalCount = response.totalFilterCount || response.totalCount || 0; const totalCount = response.totalFilterCount || response.totalCount || 0;
@@ -287,6 +333,11 @@ class JiraAssetsClient {
} }
async updateObject(objectId: string, payload: JiraUpdatePayload): Promise<boolean> { 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 { try {
logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, { logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, {
attributeCount: payload.attributes.length, attributeCount: payload.attributes.length,
@@ -296,7 +347,7 @@ class JiraAssetsClient {
await this.request(`/object/${objectId}`, { await this.request(`/object/${objectId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); }, true); // Write operation - requires user PAT
logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`); logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`);
return true; return true;
@@ -337,7 +388,36 @@ class JiraAssetsClient {
// Parse each attribute based on schema // Parse each attribute based on schema
for (const attrDef of typeDef.attributes) { for (const attrDef of typeDef.attributes) {
const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name); 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; return result as T;
@@ -363,7 +443,7 @@ class JiraAssetsClient {
private parseAttributeValue( private parseAttributeValue(
jiraAttr: JiraAssetsAttribute | undefined, jiraAttr: JiraAssetsAttribute | undefined,
attrDef: { type: string; isMultiple: boolean } attrDef: { type: string; isMultiple: boolean; fieldName?: string }
): unknown { ): unknown {
if (!jiraAttr?.objectAttributeValues?.length) { if (!jiraAttr?.objectAttributeValues?.length) {
return attrDef.isMultiple ? [] : null; return attrDef.isMultiple ? [] : null;
@@ -371,6 +451,30 @@ class JiraAssetsClient {
const values = jiraAttr.objectAttributeValues; 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) { switch (attrDef.type) {
case 'reference': { case 'reference': {
const refs = values const refs = values
@@ -403,8 +507,19 @@ class JiraAssetsClient {
} }
case 'float': { case 'float': {
// Regular float parsing
const val = values[0]?.value; 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': { 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 * Initialize the sync engine
* Performs initial sync if cache is cold, then starts incremental sync * 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> { async initialize(): Promise<void> {
if (this.isRunning) { if (this.isRunning) {
@@ -88,27 +90,11 @@ class SyncEngine {
} }
logger.info('SyncEngine: Initializing...'); logger.info('SyncEngine: Initializing...');
logger.info('SyncEngine: Sync uses service account token (JIRA_SERVICE_ACCOUNT_TOKEN) from .env');
this.isRunning = true; this.isRunning = true;
// Check if we need a full sync // Sync can run automatically using service account token
const stats = await cacheStore.getStats(); logger.info('SyncEngine: Initialized (using service account token for sync operations)');
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');
} }
/** /**
@@ -140,8 +126,22 @@ class SyncEngine {
/** /**
* Perform a full sync of all object types * Perform a full sync of all object types
* Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
*/ */
async fullSync(): Promise<SyncResult> { 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) { if (this.isSyncing) {
logger.warn('SyncEngine: Sync already in progress'); logger.warn('SyncEngine: Sync already in progress');
return { return {
@@ -312,11 +312,18 @@ class SyncEngine {
/** /**
* Perform an incremental sync (only updated objects) * 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. * Note: On Jira Data Center, IQL-based incremental sync is not supported.
* We instead check if a periodic full sync is needed. * We instead check if a periodic full sync is needed.
*/ */
async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> { 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) { if (this.isSyncing) {
return { success: false, updatedCount: 0 }; 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

@@ -89,6 +89,11 @@ export interface ApplicationDetails {
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572) technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
dataCompletenessPercentage?: number; // Data completeness percentage (0-100) 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 // Search filters

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'; 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 { clsx } from 'clsx';
import SearchDashboard from './components/SearchDashboard'; import SearchDashboard from './components/SearchDashboard';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
@@ -22,8 +22,18 @@ import DataCompletenessConfig from './components/DataCompletenessConfig';
import BIASyncDashboard from './components/BIASyncDashboard'; import BIASyncDashboard from './components/BIASyncDashboard';
import BusinessImportanceComparison from './components/BusinessImportanceComparison'; import BusinessImportanceComparison from './components/BusinessImportanceComparison';
import Login from './components/Login'; 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'; 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 // Redirect component for old app-components/overview/:id paths
function RedirectToApplicationEdit() { function RedirectToApplicationEdit() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -35,6 +45,7 @@ interface NavItem {
path: string; path: string;
label: string; label: string;
exact?: boolean; exact?: boolean;
requiredPermission?: string; // Permission required to see this menu item
} }
interface NavDropdown { interface NavDropdown {
@@ -45,11 +56,24 @@ interface NavDropdown {
} }
// Dropdown component for navigation // 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 [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation(); 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 // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -90,7 +114,7 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive:
{isOpen && ( {isOpen && (
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50"> <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 const itemActive = item.exact
? location.pathname === item.path ? location.pathname === item.path
: location.pathname.startsWith(item.path); : location.pathname.startsWith(item.path);
@@ -119,16 +143,26 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive:
function UserMenu() { function UserMenu() {
const { user, authMethod, logout } = useAuthStore(); const { user, authMethod, logout } = useAuthStore();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
if (!user) return null; 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(' ') .split(' ')
.map(n => n[0]) .map(n => n[0])
.join('') .join('')
.toUpperCase() .toUpperCase()
.slice(0, 2); .slice(0, 2);
const handleLogout = async () => {
setIsOpen(false);
await logout();
navigate('/login');
};
return ( return (
<div className="relative"> <div className="relative">
<button <button
@@ -138,7 +172,7 @@ function UserMenu() {
{user.avatarUrl ? ( {user.avatarUrl ? (
<img <img
src={user.avatarUrl} src={user.avatarUrl}
alt={user.displayName} alt={displayName}
className="w-8 h-8 rounded-full" className="w-8 h-8 rounded-full"
/> />
) : ( ) : (
@@ -146,7 +180,7 @@ function UserMenu() {
<span className="text-white text-sm font-medium">{initials}</span> <span className="text-white text-sm font-medium">{initials}</span>
</div> </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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </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="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"> <div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.displayName}</p> <p className="text-sm font-medium text-gray-900">{displayName}</p>
{user.emailAddress && ( {email && (
<p className="text-xs text-gray-500 truncate">{user.emailAddress}</p> <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"> <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> </p>
</div> </div>
<div className="py-1"> <div className="py-1">
{authMethod === 'oauth' && ( {(authMethod === 'local' || authMethod === 'oauth') && (
<button <>
onClick={() => { <Link
setIsOpen(false); to="/settings/profile"
logout(); onClick={() => setIsOpen(false)}
}} className="block px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors" >
> Profiel & Instellingen
Uitloggen </Link>
</button> <div className="border-t border-gray-100 my-1"></div>
</>
)} )}
<button
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>
</div> </div>
</> </>
@@ -190,15 +234,17 @@ function UserMenu() {
function AppContent() { function AppContent() {
const location = useLocation(); const location = useLocation();
const hasPermission = useAuthStore((state) => state.hasPermission);
const config = useAuthStore((state) => state.config);
// Navigation structure // Navigation structure
const appComponentsDropdown: NavDropdown = { const appComponentsDropdown: NavDropdown = {
label: 'Application Component', label: 'Application Component',
basePath: '/application', basePath: '/application',
items: [ items: [
{ path: '/app-components', label: 'Dashboard', exact: true }, { path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' },
{ path: '/application/overview', label: 'Overzicht', exact: false }, { path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' },
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true }, { path: '/application/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' },
], ],
}; };
@@ -206,16 +252,16 @@ function AppContent() {
label: 'Rapporten', label: 'Rapporten',
basePath: '/reports', basePath: '/reports',
items: [ items: [
{ path: '/reports', label: 'Overzicht', exact: true }, { path: '/reports', label: 'Overzicht', exact: true, requiredPermission: 'view_reports' },
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true }, { path: '/reports/team-dashboard', label: 'Team-indeling', exact: true, requiredPermission: 'view_reports' },
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true }, { path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true, requiredPermission: 'view_reports' },
{ path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true }, { path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true, requiredPermission: 'view_reports' },
{ path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true }, { path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true, requiredPermission: 'view_reports' },
{ path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true }, { path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true, requiredPermission: 'view_reports' },
{ path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true }, { 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 }, { 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 }, { 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 }, { path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true, requiredPermission: 'view_reports' },
], ],
}; };
@@ -223,7 +269,7 @@ function AppContent() {
label: 'Apps', label: 'Apps',
basePath: '/apps', basePath: '/apps',
items: [ 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', label: 'Instellingen',
basePath: '/settings', basePath: '/settings',
items: [ items: [
{ path: '/settings/fte-config', label: 'FTE Config', exact: true }, { path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-model', label: 'Datamodel', exact: true }, { path: '/settings/data-model', label: 'Datamodel', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true }, { 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 isReportsActive = location.pathname.startsWith('/reports');
const isSettingsActive = location.pathname.startsWith('/settings'); const isSettingsActive = location.pathname.startsWith('/settings');
const isAppsActive = location.pathname.startsWith('/apps'); const isAppsActive = location.pathname.startsWith('/apps');
const isDashboardActive = location.pathname === '/'; const isAdminActive = location.pathname.startsWith('/admin');
return ( return (
<div className="min-h-screen bg-white"> <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" /> <img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
<div> <div>
<h1 className="text-lg font-semibold text-gray-900"> <h1 className="text-lg font-semibold text-gray-900">
Analyse Tool {config?.appName || 'CMDB Insight'}
</h1> </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> </div>
</Link> </Link>
<nav className="hidden md:flex items-center space-x-1"> <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 */} {/* Application Component Dropdown */}
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} /> <NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} hasPermission={hasPermission} />
{/* Apps Dropdown */} {/* Apps Dropdown */}
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} /> <NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
{/* Reports Dropdown */} {/* Reports Dropdown */}
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} /> <NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} hasPermission={hasPermission} />
{/* Settings Dropdown */} {/* Settings Dropdown */}
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} /> <NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} hasPermission={hasPermission} />
{/* Admin Dropdown */}
<NavDropdown dropdown={adminDropdown} isActive={isAdminActive} hasPermission={hasPermission} />
</nav> </nav>
</div> </div>
@@ -297,36 +342,43 @@ function AppContent() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes> <Routes>
{/* Main Dashboard (Search) */} {/* Main Dashboard (Search) */}
<Route path="/" element={<SearchDashboard />} /> <Route path="/" element={<ProtectedRoute><SearchDashboard /></ProtectedRoute>} />
{/* Application routes (new structure) */} {/* Application routes (new structure) - specific routes first, then dynamic */}
<Route path="/application/overview" element={<ApplicationList />} /> <Route path="/application/overview" element={<ProtectedRoute requirePermission="search"><ApplicationList /></ProtectedRoute>} />
<Route path="/application/fte-calculator" element={<FTECalculator />} /> <Route path="/application/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></ProtectedRoute>} />
<Route path="/application/:id" element={<ApplicationInfo />} /> <Route path="/application/:id/edit" element={<ProtectedRoute requirePermission="edit_applications"><GovernanceModelHelper /></ProtectedRoute>} />
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} /> <Route path="/application/:id" element={<ProtectedRoute requirePermission="search"><ApplicationInfo /></ProtectedRoute>} />
{/* Application Component routes */} {/* Application Component routes */}
<Route path="/app-components" element={<Dashboard />} /> <Route path="/app-components" element={<ProtectedRoute requirePermission="search"><Dashboard /></ProtectedRoute>} />
{/* Reports routes */} {/* Reports routes */}
<Route path="/reports" element={<ReportsDashboard />} /> <Route path="/reports" element={<ProtectedRoute requirePermission="view_reports"><ReportsDashboard /></ProtectedRoute>} />
<Route path="/reports/team-dashboard" element={<TeamDashboard />} /> <Route path="/reports/team-dashboard" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} /> <Route path="/reports/governance-analysis" element={<ProtectedRoute requirePermission="view_reports"><GovernanceAnalysis /></ProtectedRoute>} />
<Route path="/reports/technical-debt-heatmap" element={<TechnicalDebtHeatmap />} /> <Route path="/reports/technical-debt-heatmap" element={<ProtectedRoute requirePermission="view_reports"><TechnicalDebtHeatmap /></ProtectedRoute>} />
<Route path="/reports/lifecycle-pipeline" element={<LifecyclePipeline />} /> <Route path="/reports/lifecycle-pipeline" element={<ProtectedRoute requirePermission="view_reports"><LifecyclePipeline /></ProtectedRoute>} />
<Route path="/reports/data-completeness" element={<DataCompletenessScore />} /> <Route path="/reports/data-completeness" element={<ProtectedRoute requirePermission="view_reports"><DataCompletenessScore /></ProtectedRoute>} />
<Route path="/reports/zira-domain-coverage" element={<ZiRADomainCoverage />} /> <Route path="/reports/zira-domain-coverage" element={<ProtectedRoute requirePermission="view_reports"><ZiRADomainCoverage /></ProtectedRoute>} />
<Route path="/reports/fte-per-zira-domain" element={<FTEPerZiRADomain />} /> <Route path="/reports/fte-per-zira-domain" element={<ProtectedRoute requirePermission="view_reports"><FTEPerZiRADomain /></ProtectedRoute>} />
<Route path="/reports/complexity-dynamics-bubble" element={<ComplexityDynamicsBubbleChart />} /> <Route path="/reports/complexity-dynamics-bubble" element={<ProtectedRoute requirePermission="view_reports"><ComplexityDynamicsBubbleChart /></ProtectedRoute>} />
<Route path="/reports/business-importance-comparison" element={<BusinessImportanceComparison />} /> <Route path="/reports/business-importance-comparison" element={<ProtectedRoute requirePermission="view_reports"><BusinessImportanceComparison /></ProtectedRoute>} />
{/* Apps routes */} {/* Apps routes */}
<Route path="/apps/bia-sync" element={<BIASyncDashboard />} /> <Route path="/apps/bia-sync" element={<ProtectedRoute requirePermission="search"><BIASyncDashboard /></ProtectedRoute>} />
{/* Settings routes */} {/* Settings routes */}
<Route path="/settings/fte-config" element={<ConfigurationV25 />} /> <Route path="/settings/fte-config" element={<ProtectedRoute requirePermission="manage_settings"><ConfigurationV25 /></ProtectedRoute>} />
<Route path="/settings/data-model" element={<DataModelDashboard />} /> <Route path="/settings/data-model" element={<ProtectedRoute requirePermission="manage_settings"><DataModelDashboard /></ProtectedRoute>} />
<Route path="/settings/data-completeness-config" element={<DataCompletenessConfig />} /> <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 */} {/* Legacy redirects for bookmarks - redirect old paths to new ones */}
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} /> <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="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/reports/data-model" element={<Navigate to="/settings/data-model" replace />} /> <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="/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 />} /> <Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />
</Routes> </Routes>
</main> </main>
@@ -345,39 +397,180 @@ function AppContent() {
} }
function App() { function App() {
const { isAuthenticated, isLoading, checkAuth, fetchConfig, config } = useAuthStore(); const { isAuthenticated, checkAuth, fetchConfig, config, user, authMethod, isInitialized, setInitialized, setConfig } = useAuthStore();
const location = useLocation();
useEffect(() => { useEffect(() => {
// Fetch auth config first, then check auth status // Use singleton pattern to ensure initialization happens only once
const init = async () => { // This works across React StrictMode remounts
await fetchConfig();
await checkAuth();
};
init();
}, [fetchConfig, checkAuth]);
// Show loading state // Check if already initialized by checking store state
if (isLoading) { 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 ( 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="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> <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-slate-400">Laden...</p> <p className="text-gray-600 font-medium">Laden...</p>
</div> </div>
</div> </div>
); );
} }
// Show login if OAuth is enabled and not authenticated // STRICT AUTHENTICATION CHECK:
if (config?.authMethod === 'oauth' && !isAuthenticated) { // Service accounts are NOT used for application authentication
return <Login />; // 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 not authenticated as a real user, redirect to login
if (config?.authMethod === 'none') { if (!isRealUser) {
return <Login />; 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 // Show main app
return <AppContent />; 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> = { const statusColors: Record<string, string> = {
'Closed': 'badge-dark-red', 'Closed': 'badge-dark-red',
'Deprecated': 'badge-yellow', 'Deprecated': 'badge-yellow',
@@ -582,7 +582,38 @@ export function StatusBadge({ status }: { status: string | null }) {
'Undefined': 'badge-gray', '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 ( return (
<span className={clsx('badge', statusColors[status] || 'badge-gray')}> <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,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getDataCompletenessConfig, type DataCompletenessConfig } from '../services/api'; import type { DataCompletenessConfig } from '../services/api';
interface FieldCompleteness { interface FieldCompleteness {
field: string; field: string;
@@ -79,19 +79,18 @@ export default function DataCompletenessScore() {
setError(null); setError(null);
try { try {
// Fetch config and data in parallel // Fetch data (config is included in the response)
const [configResult, dataResponse] = await Promise.all([ const dataResponse = await fetch(`${API_BASE}/dashboard/data-completeness`);
getDataCompletenessConfig(),
fetch(`${API_BASE}/dashboard/data-completeness`)
]);
setConfig(configResult);
if (!dataResponse.ok) { if (!dataResponse.ok) {
throw new Error('Failed to fetch data completeness data'); throw new Error('Failed to fetch data completeness data');
} }
const result = await dataResponse.json(); const result = await dataResponse.json();
setData(result); setData(result);
// Config is now included in the response
if (result.config) {
setConfig(result.config);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data'); setError(err instanceof Error ? err.message : 'Failed to load data');
} finally { } finally {

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

@@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useMemo } from 'react'; import { useEffect, useState, useRef, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuthStore } from '../stores/authStore';
import { import {
getApplicationForEdit, getApplicationForEdit,
updateApplication, updateApplication,
@@ -268,11 +269,12 @@ export default function GovernanceModelHelper() {
// Set page title // Set page title
useEffect(() => { useEffect(() => {
const appName = useAuthStore.getState().config?.appName || 'CMDB Insight';
if (application) { if (application) {
document.title = `${application.name} - Bewerken | Zuyderland CMDB`; document.title = `${application.name} - Bewerken | ${appName}`;
} }
return () => { return () => {
document.title = 'Zuyderland CMDB'; document.title = appName;
}; };
}, [application]); }, [application]);

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 { useAuthStore, getLoginUrl } from '../stores/authStore';
import AuthLayout from './AuthLayout';
export default function Login() { 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(() => { useEffect(() => {
fetchConfig(); fetchConfig();
@@ -15,59 +22,212 @@ export default function Login() {
if (loginSuccess === 'success') { if (loginSuccess === 'success') {
// Remove query params and check auth // Remove query params and check auth
window.history.replaceState({}, '', window.location.pathname); 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) { if (loginError) {
useAuthStore.getState().setError(decodeURIComponent(loginError)); setError(decodeURIComponent(loginError));
window.history.replaceState({}, '', window.location.pathname); 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 = () => { const handleJiraLogin = () => {
window.location.href = getLoginUrl(); 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) { if (isLoading) {
return ( 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="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> <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-slate-400">Laden...</p> <p className="text-gray-600 font-medium">Laden...</p>
</div> </div>
</div> </div>
); );
} }
return ( const showLocalAuth = config?.localAuthEnabled;
<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"> const showOAuth = config?.oauthEnabled;
<div className="w-full max-w-md"> const showBoth = showLocalAuth && showOAuth;
{/* 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>
{/* Login Card */} // PAT mode should NOT be shown to users - it's only for backend configuration
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl"> // Always show login form by default - local auth should be available even if not explicitly enabled
<h2 className="text-xl font-semibold text-white mb-6 text-center">Inloggen</h2> // (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 && ( {error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg"> <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<p className="text-red-400 text-sm">{error}</p> <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> </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 <button
onClick={handleJiraLogin} 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"> <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"/> <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 Inloggen met Jira
</button> </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 Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
</p> </p>
{showBoth && (
<button
type="button"
onClick={() => setAuthChoice(null)}
className="w-full text-sm text-gray-600 hover:text-gray-900 font-medium transition-colors mt-4"
>
Terug naar keuze
</button>
)}
</> </>
) : 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"> {/* Not Configured - Only show if both auth methods are explicitly disabled */}
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {/* PAT mode should NEVER be shown - it's only for backend Jira API configuration */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> {/* Users always authenticate via local auth or OAuth */}
</svg> {config?.localAuthEnabled === false && config?.oauthEnabled === false && (
</div> <div className="text-center py-6">
<p className="text-slate-300 mb-2">Personal Access Token Modus</p> <div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
<p className="text-slate-500 text-sm"> <svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
</p>
<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"
>
Doorgaan
</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">
<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" /> <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> </svg>
</div> </div>
<p className="text-slate-300 mb-2">Niet geconfigureerd</p> <p className="text-gray-900 font-semibold mb-2">Authenticatie niet geconfigureerd</p>
<p className="text-slate-500 text-sm"> <p className="text-gray-600 text-sm mb-4">
Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren. Lokale authenticatie of OAuth moet worden ingeschakeld om gebruikers in te laten loggen.
Neem contact op met de beheerder om authenticatie te configureren.
</p> </p>
</div> </div>
)} )}
</div>
{/* Footer */} {/* Not Configured */}
<p className="mt-8 text-center text-slate-600 text-sm"> {config?.authMethod === 'none' && (
Zuyderland Medisch Centrum CMDB Editor v1.0 <div className="text-center py-6">
</p> <div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
</div> <svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <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>
)}
</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 { 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'; import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
const ITEMS_PER_PAGE = 25; const ITEMS_PER_PAGE = 25;
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent'; 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 // Helper to strip HTML tags from description
function stripHtml(html: string): string { 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' }; 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() { export default function SearchDashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -90,11 +159,20 @@ export default function SearchDashboard() {
return map; return map;
}, [searchResults]); }, [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(() => { const sortedObjectTypes = useMemo(() => {
if (!searchResults?.objectTypes) return []; if (!searchResults?.objectTypes) return [];
return [...searchResults.objectTypes].sort((a, b) => { 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 countA = resultsByType.get(a.id)?.length || 0;
const countB = resultsByType.get(b.id)?.length || 0; const countB = resultsByType.get(b.id)?.length || 0;
return countB - countA; return countB - countA;
@@ -178,14 +256,25 @@ export default function SearchDashboard() {
setSearchResults(results); setSearchResults(results);
// Auto-select first tab if results exist // 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) { if (results.objectTypes && results.objectTypes.length > 0) {
// Sort by count and select the first one // Find Application Component type if it exists
const sorted = [...results.objectTypes].sort((a, b) => { const appComponentType = results.objectTypes.find(
const countA = results.results.filter(r => r.objectTypeId === a.id).length; ot => ot.name === APPLICATION_COMPONENT_TYPE_NAME || ot.name === APPLICATION_COMPONENT_JIRA_NAME
const countB = results.results.filter(r => r.objectTypeId === b.id).length; );
return countB - countA;
}); if (appComponentType) {
setSelectedTab(sorted[0].id); // 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;
return countB - countA;
});
setSelectedTab(sorted[0].id);
}
} }
}) })
.catch((err) => { .catch((err) => {
@@ -211,9 +300,11 @@ export default function SearchDashboard() {
}; };
// Helper to check if a result is an Application Component (by looking up type name) // 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 isApplicationComponent = useCallback((result: CMDBSearchResult) => {
const objectType = objectTypeMap.get(result.objectTypeId); 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]); }, [objectTypeMap]);
// Handle result click (for Application Components) // Handle result click (for Application Components)
@@ -231,318 +322,419 @@ export default function SearchDashboard() {
}; };
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
{/* Header */} <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
<div className="text-center"> {/* Header Section */}
<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"> <div className="text-center mb-10 lg:mb-12">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <svg className="w-8 h-8 lg:w-10 lg:h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</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.
</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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
<input <h1 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-3 tracking-tight">
type="text" CMDB Zoeken
value={searchQuery} </h1>
onChange={(e) => setSearchQuery(e.target.value)} <p className="text-base lg:text-lg text-gray-600 max-w-4xl mx-auto">
placeholder="Zoek op naam, key, of beschrijving..." Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB van Zuyderland
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" </p>
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"
>
{loading && (
<svg className="animate-spin h-4 w-4" 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>
)}
Zoeken
</button>
</div> </div>
</form>
{/* Error Message */} {/* Search Form */}
{error && ( <form onSubmit={handleSearch} className="max-w-4xl mx-auto mb-8">
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700"> <div className="relative group">
<div className="flex items-center gap-2"> <div className="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{error}
</div>
</div>
)}
{/* Results */}
{hasSearched && searchResults && !loading && (
<div className="space-y-4">
{/* 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
{searchResults.metadata.total !== searchResults.results.length && (
<span className="text-gray-400"> (eerste {searchResults.results.length} getoond)</span>
)}
</p>
</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">
<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> </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> </div>
) : ( <input
<> type="text"
{/* Object Type Tabs */} value={searchQuery}
<div className="border-b border-gray-200"> onChange={(e) => setSearchQuery(e.target.value)}
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs"> placeholder="Zoek op naam, key, beschrijving of andere attributen..."
{sortedObjectTypes.map((objectType) => { 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"
const count = resultsByType.get(objectType.id)?.length || 0; disabled={loading}
const isActive = selectedTab === objectType.id; />
<button
type="submit"
disabled={loading || !searchQuery.trim()}
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-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>
</>
)}
</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>
return ( {/* Loading State */}
<button {loading && (
key={objectType.id} <div className="max-w-4xl mx-auto mb-8">
onClick={() => handleTabChange(objectType.id)} <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
className={` <div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-6">
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors <svg className="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
${isActive <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
? 'border-blue-500 text-blue-600' <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" />
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'} </svg>
`}
>
{jiraHost && objectType.iconUrl && (
<img
src={getAvatarUrl(objectType.iconUrl) || ''}
alt=""
className="w-4 h-4"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span>{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'}
`}>
{count}
</span>
</button>
);
})}
</nav>
</div> </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-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>
</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 Section */}
{hasSearched && searchResults && !loading && (
<div className="max-w-7xl mx-auto">
{/* Results Summary */}
<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-sm text-gray-500">
(eerste {searchResults.results.length} getoond)
</span>
)}
</div>
</div>
{searchResults.results.length === 0 ? (
<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>
</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 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;
{/* Status Filter */}
{statusOptions.length > 0 && (
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600">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"
>
<option value="">Alle statussen ({currentTabResults.length})</option>
{statusOptions.map(status => {
const count = currentTabResults.filter(r => {
const s = getAttributeValue(r, 'Status');
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
return sName === status;
}).length;
return ( return (
<option key={status} value={status}> <button
{status} ({count}) key={objectType.id}
</option> onClick={() => handleTabChange(objectType.id)}
className={`
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-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-5 h-5 flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span className="font-medium">{objectType.name}</span>
<span className={`
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>
); );
})} })}
</select> </nav>
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
className="text-sm text-blue-600 hover:text-blue-700"
>
Wis filter
</button>
)}
</div> </div>
)}
{/* Results List */} {/* Status Filter */}
<div className="space-y-2"> {statusOptions.length > 0 && (
{paginatedResults.map((result) => { <div className="px-4 lg:px-6 py-4 bg-white border-b border-gray-100">
const status = getAttributeValue(result, 'Status'); <div className="flex flex-wrap items-center gap-3">
// Handle status objects with nested structure (null check required because typeof null === 'object') <label className="text-sm font-medium text-gray-700 flex items-center gap-2">
const statusDisplay = status && typeof status === 'object' && (status as any).name <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
? (status as any).name <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" />
: status; </svg>
const statusInfo = getStatusInfo(statusDisplay); Filter op status:
const description = getAttributeValue(result, 'Description'); </label>
const isClickable = isApplicationComponent(result); <select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
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 => {
const count = currentTabResults.filter(r => {
const s = getAttributeValue(r, 'Status');
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
return sName === status;
}).length;
return (
<option key={status} value={status}>
{status} ({count})
</option>
);
})}
</select>
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
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>
)}
return ( {/* Results List */}
<div <div className="divide-y divide-gray-100">
key={result.id} {paginatedResults.map((result) => {
onClick={() => isClickable && handleResultClick(result)} const status = getAttributeValue(result, 'Status');
className={` // Handle status objects with nested structure (null check required because typeof null === 'object')
bg-white border border-gray-200 rounded-lg p-4 const statusDisplay = status && typeof status === 'object' && (status as any).name
${isClickable ? (status as any).name
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all' : status;
: ''} const statusInfo = getStatusInfo(statusDisplay);
`} const description = getAttributeValue(result, 'Description');
> const isClickable = isApplicationComponent(result);
<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>
{/* Content */} return (
<div className="flex-1 min-w-0"> <div
<div className="flex items-center gap-2 flex-wrap"> key={result.id}
<span className="text-xs text-gray-400 font-mono">{result.key}</span> onClick={() => isClickable && handleResultClick(result)}
{statusDisplay && ( className={`
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}> px-4 lg:px-6 py-5 transition-all group
{statusDisplay} ${isClickable
</span> ? 'cursor-pointer hover:bg-blue-50/50 hover:shadow-sm'
)} : ''}
{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"> <div className="flex items-start gap-4">
<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" /> {/* Avatar/Icon */}
</svg> <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">
Klik om te openen {(() => {
</span> 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-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.5 py-1 rounded-full font-semibold ${statusInfo.bg} ${statusInfo.color}`}>
{statusDisplay}
</span>
)}
{isClickable && (
<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="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-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>
<h3 className="font-medium text-gray-900 mt-0.5">{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>
)}
</div> </div>
</div> </div>
</div> );
); })}
})}
</div>
{/* 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)
</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"
>
Vorige
</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"
>
Volgende
</button>
</div>
</div> </div>
)}
</>
)}
</div>
)}
{/* Quick Links (only show when no search has been performed) */} {/* Pagination */}
{!hasSearched && ( {totalPages > 1 && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto"> <div className="px-4 lg:px-6 py-4 bg-gray-50/50 border-t border-gray-200">
<a <div className="flex flex-col sm:flex-row items-center justify-between gap-4">
href="/app-components" <p className="text-sm text-gray-600 font-medium">
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group" 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>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors"> </p>
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="flex items-center gap-2">
<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" /> <button
</svg> onClick={() => handlePageChange(pageForCurrentTab - 1)}
disabled={pageForCurrentTab === 1}
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-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> </div>
<div> )}
<p className="font-medium text-gray-900">Application Components</p>
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
</div>
</a>
<a {/* Quick Links (only show when no search has been performed) */}
href="/reports/team-dashboard" {!hasSearched && (
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="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Link
<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" /> to="/app-components"
</svg> 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> >
<div> <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>
<p className="font-medium text-gray-900">Rapporten</p> <div className="relative">
<p className="text-sm text-gray-500">Team-indeling & analyses</p> <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">
</div> <svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</a> <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>
<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>
</Link>
<a <Link
href="/app-components/fte-config" to="/reports/team-dashboard"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group" 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-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors"> <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>
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="relative">
<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" /> <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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <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>
<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>
</Link>
<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="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>
<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>
</div> </div>
<div> )}
<p className="font-medium text-gray-900">Configuratie</p>
<p className="text-sm text-gray-500">FTE berekening</p>
</div>
</a>
</div> </div>
)}
</div> </div>
); );
} }

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 { @layer components {
.btn { .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; @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 { .badge-lighter-blue {
@apply bg-blue-300 text-white; @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( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>

View File

@@ -2,18 +2,28 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
export interface User { export interface User {
accountId: string; id?: number;
accountId?: string;
email?: string;
username?: string;
displayName: string; displayName: string;
emailAddress?: string; emailAddress?: string;
avatarUrl?: string; avatarUrl?: string;
roles?: string[];
permissions?: string[];
} }
interface AuthConfig { interface AuthConfig {
// Application branding
appName: string;
appTagline: string;
appCopyright: string;
// The configured authentication method // The configured authentication method
authMethod: 'pat' | 'oauth' | 'none'; authMethod: 'pat' | 'oauth' | 'local' | 'none';
// Legacy fields (for backward compatibility) // Legacy fields (for backward compatibility)
oauthEnabled: boolean; oauthEnabled: boolean;
serviceAccountEnabled: boolean; serviceAccountEnabled: boolean;
localAuthEnabled: boolean;
jiraHost: string; jiraHost: string;
} }
@@ -21,26 +31,31 @@ interface AuthState {
// State // State
user: User | null; user: User | null;
isAuthenticated: boolean; isAuthenticated: boolean;
authMethod: 'oauth' | 'service-account' | null; authMethod: 'oauth' | 'local' | 'service-account' | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
config: AuthConfig | null; config: AuthConfig | null;
isInitialized: boolean; // Track if initialization has completed
// Actions // 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; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
setConfig: (config: AuthConfig) => void; setConfig: (config: AuthConfig) => void;
setInitialized: (initialized: boolean) => void;
logout: () => Promise<void>; logout: () => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
fetchConfig: () => 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'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set) => ({ (set, get) => ({
// Initial state // Initial state
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
@@ -48,6 +63,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: true, isLoading: true,
error: null, error: null,
config: null, config: null,
isInitialized: false,
// Actions // Actions
setUser: (user, method) => set({ setUser: (user, method) => set({
@@ -63,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
setConfig: (config) => set({ config }), setConfig: (config) => set({ config }),
setInitialized: (initialized) => set({ isInitialized: initialized }),
logout: async () => { logout: async () => {
try { try {
await fetch(`${API_BASE}/api/auth/logout`, { await fetch(`${API_BASE}/api/auth/logout`, {
@@ -81,19 +99,49 @@ export const useAuthStore = create<AuthState>()(
}, },
checkAuth: async () => { 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 }); set({ isLoading: true });
try { try {
const response = await fetch(`${API_BASE}/api/auth/me`, { const response = await fetch(`${API_BASE}/api/auth/me`, {
credentials: 'include', credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
}); });
if (!response.ok) { 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(); const data = await response.json();
if (data.authenticated) { if (data.authenticated && data.user) {
set({ set({
user: data.user, user: data.user,
isAuthenticated: true, isAuthenticated: true,
@@ -110,7 +158,7 @@ export const useAuthStore = create<AuthState>()(
}); });
} }
} catch (error) { } catch (error) {
console.error('Auth check error:', error); console.error('[checkAuth] Auth check error:', error);
set({ set({
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
@@ -122,18 +170,98 @@ export const useAuthStore = create<AuthState>()(
}, },
fetchConfig: async () => { 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 { try {
const response = await fetch(`${API_BASE}/api/auth/config`, { const response = await fetch(`${API_BASE}/api/auth/config`, {
credentials: 'include', credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
const config = await response.json(); const configData = await response.json();
set({ config }); set({ config: configData });
} else {
// Any non-OK response - set default config
set({ config: defaultConfig });
} }
} catch (error) { } 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 { export function getLoginUrl(): string {
return `${API_BASE}/api/auth/login`; return `${API_BASE}/api/auth/login`;
} }

View File

@@ -88,6 +88,11 @@ export interface ApplicationDetails {
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572) technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
dataCompletenessPercentage?: number; // Data completeness percentage (0-100) 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) _jiraUpdatedAt?: string | null; // Internal field for conflict detection (not exposed in API)
} }

View File

@@ -15,6 +15,30 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
// Ensure cookies are forwarded correctly
configure: (proxy, _options) => {
proxy.on('proxyRes', (proxyRes, req, res) => {
// Rewrite Set-Cookie headers to work with the proxy
// Change domain from backend (localhost:3001) to frontend (localhost:5173)
const setCookieHeaders = proxyRes.headers['set-cookie'];
if (setCookieHeaders) {
const rewritten = Array.isArray(setCookieHeaders)
? setCookieHeaders.map(cookie => {
// Remove domain attribute or rewrite it to work with proxy
return cookie
.replace(/;\s*domain=[^;]+/gi, '') // Remove domain
.replace(/;\s*secure/gi, ''); // Remove secure in dev (if needed)
})
: [setCookieHeaders].map(cookie => {
return cookie
.replace(/;\s*domain=[^;]+/gi, '')
.replace(/;\s*secure/gi, '');
});
proxyRes.headers['set-cookie'] = rewritten;
console.log('[Vite Proxy] Rewritten Set-Cookie headers:', rewritten);
}
});
},
}, },
}, },
}, },

1390
package-lock.json generated

File diff suppressed because it is too large Load Diff

61
scripts/open-database.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/bin/bash
# Script to easily open PostgreSQL database
# Usage: ./scripts/open-database.sh [method]
# Methods: psql (default), docker, url
set -e
# Database connection details (from docker-compose.yml)
DB_NAME="cmdb"
DB_USER="cmdb"
DB_PASSWORD="cmdb-dev"
DB_HOST="localhost"
DB_PORT="5432"
# Check if PostgreSQL is running
if ! docker ps | grep -q "postgres"; then
echo "⚠️ PostgreSQL container is not running!"
echo "Start it with: docker-compose up -d postgres"
exit 1
fi
METHOD=${1:-psql}
case $METHOD in
psql)
echo "🔌 Connecting to PostgreSQL database using psql..."
echo "Database: $DB_NAME | User: $DB_USER | Host: $DB_HOST:$DB_PORT"
echo ""
echo "Useful commands:"
echo " \\dt - List all tables"
echo " \\d table_name - Describe a table"
echo " \\q - Quit"
echo ""
PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME
;;
docker)
echo "🐳 Connecting to PostgreSQL via Docker exec..."
docker exec -it $(docker ps | grep postgres | awk '{print $1}') psql -U $DB_USER -d $DB_NAME
;;
url)
echo "📋 Connection string:"
echo "postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME"
echo ""
echo "Use this with:"
echo " - pgAdmin"
echo " - DBeaver"
echo " - TablePlus"
echo " - DataGrip"
echo " - psql: psql 'postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME'"
;;
*)
echo "Usage: $0 [psql|docker|url]"
echo ""
echo "Methods:"
echo " psql - Connect using psql command (default)"
echo " docker - Connect via Docker exec"
echo " url - Show connection string for GUI tools"
exit 1
;;
esac