Compare commits
12 Commits
a7f8301196
...
1fa424efb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fa424efb9 | |||
| f3637b85e1 | |||
| 408c9f4727 | |||
| df3f6f6899 | |||
| de7b529ffb | |||
| 68518f0193 | |||
| aba16f68de | |||
| f51e9b8574 | |||
| 81d477ec8c | |||
| fb7dd23027 | |||
| 55c8fee3b8 | |||
| 96ed8a9ecf |
93
.env.example
93
.env.example
@@ -1,12 +1,31 @@
|
||||
# Application
|
||||
# =============================================================================
|
||||
# CMDB Insight - Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and update the values according to your environment
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Application Branding
|
||||
APP_NAME=CMDB Insight
|
||||
APP_TAGLINE=Management console for Jira Assets
|
||||
APP_COPYRIGHT=© {year} Zuyderland Medisch Centrum
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Use 'postgres' for PostgreSQL or 'sqlite' for SQLite (default)
|
||||
DATABASE_TYPE=postgres
|
||||
|
||||
# Option 1: Use DATABASE_URL (recommended for PostgreSQL)
|
||||
DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb
|
||||
# Or use individual components:
|
||||
|
||||
# Option 2: Use individual components (alternative to DATABASE_URL)
|
||||
# DATABASE_HOST=localhost
|
||||
# DATABASE_PORT=5432
|
||||
# DATABASE_NAME=cmdb
|
||||
@@ -14,17 +33,71 @@ DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb
|
||||
# DATABASE_PASSWORD=cmdb-dev
|
||||
# DATABASE_SSL=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jira Assets Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
JIRA_HOST=https://jira.zuyderland.nl
|
||||
JIRA_PAT=your_personal_access_token_here
|
||||
JIRA_SCHEMA_ID=your_schema_id
|
||||
JIRA_API_BATCH_SIZE=20
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
# Jira Service Account Token (for read operations: sync, fetching data)
|
||||
# This token is used for all read operations from Jira Assets.
|
||||
# Write operations (saving changes) require users to configure their own PAT in profile settings.
|
||||
JIRA_SERVICE_ACCOUNT_TOKEN=your_service_account_personal_access_token
|
||||
JIRA_API_BATCH_SIZE=15
|
||||
|
||||
# Tavily API Key (verkrijgbaar via https://tavily.com)
|
||||
TAVILY_API_KEY=your_tavily_api_key_here
|
||||
# Jira Authentication Method
|
||||
# Note: User Personal Access Tokens (PAT) are NOT configured here - users configure them in their profile settings
|
||||
# The service account token above is used for read operations, user PATs are used for write operations.
|
||||
|
||||
# OpenAI API
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
# Options: 'pat' (Personal Access Token) or 'oauth' (OAuth 2.0)
|
||||
JIRA_AUTH_METHOD=pat
|
||||
|
||||
|
||||
# Option 2: OAuth 2.0 Authentication
|
||||
# Required when JIRA_AUTH_METHOD=oauth
|
||||
# JIRA_OAUTH_CLIENT_ID=your_oauth_client_id
|
||||
# JIRA_OAUTH_CLIENT_SECRET=your_oauth_client_secret
|
||||
# JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||
# JIRA_OAUTH_SCOPES=READ WRITE
|
||||
|
||||
# Legacy: JIRA_OAUTH_ENABLED (for backward compatibility)
|
||||
# JIRA_OAUTH_ENABLED=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Local Authentication System
|
||||
# -----------------------------------------------------------------------------
|
||||
# Enable local authentication (email/password login)
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
|
||||
# Allow public registration (optional, default: false)
|
||||
REGISTRATION_ENABLED=false
|
||||
|
||||
# Session Configuration
|
||||
SESSION_SECRET=change-this-secret-in-production
|
||||
SESSION_DURATION_HOURS=24
|
||||
|
||||
# Password Requirements
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
PASSWORD_REQUIRE_UPPERCASE=true
|
||||
PASSWORD_REQUIRE_LOWERCASE=true
|
||||
PASSWORD_REQUIRE_NUMBER=true
|
||||
PASSWORD_REQUIRE_SPECIAL=false
|
||||
|
||||
# Email Configuration (for invitations, password resets, etc.)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASSWORD=your-email-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
|
||||
# Encryption Key (for encrypting sensitive user data like API keys)
|
||||
# Generate with: openssl rand -base64 32
|
||||
ENCRYPTION_KEY=your-32-byte-encryption-key-base64
|
||||
|
||||
# Initial Administrator User (optional - created on first migration)
|
||||
# If not set, you'll need to create an admin user manually
|
||||
ADMIN_USERNAME=administrator
|
||||
ADMIN_PASSWORD=SecurePassword123!
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_DISPLAY_NAME=Administrator
|
||||
@@ -193,9 +193,9 @@ JIRA_ATTR_APPLICATION_CLUSTER=<attr_id>
|
||||
JIRA_ATTR_APPLICATION_TYPE=<attr_id>
|
||||
|
||||
# AI Classification
|
||||
ANTHROPIC_API_KEY=<claude_api_key>
|
||||
OPENAI_API_KEY=<openai_api_key> # Optional: alternative to Claude
|
||||
DEFAULT_AI_PROVIDER=claude # 'claude' or 'openai'
|
||||
# Note: AI API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY),
|
||||
# default AI provider, and web search are configured per-user in profile settings,
|
||||
# not in environment variables
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
|
||||
61
azure-pipelines.yml
Normal file
61
azure-pipelines.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# Azure DevOps Pipeline - Build and Push Docker Images
|
||||
# Dit bestand kan gebruikt worden in Azure DevOps Pipelines
|
||||
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
tags:
|
||||
include:
|
||||
- 'v*'
|
||||
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
|
||||
variables:
|
||||
# Azure Container Registry naam - pas aan naar jouw ACR
|
||||
acrName: 'zdlas'
|
||||
repositoryName: 'zuyderland-cmdb-gui'
|
||||
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' # Service connection naam in Azure DevOps
|
||||
imageTag: '$(Build.BuildId)'
|
||||
|
||||
stages:
|
||||
- stage: Build
|
||||
displayName: 'Build and Push Docker Images'
|
||||
jobs:
|
||||
- job: BuildImages
|
||||
displayName: 'Build Docker Images'
|
||||
steps:
|
||||
- task: Docker@2
|
||||
displayName: 'Build and Push Backend Image'
|
||||
inputs:
|
||||
command: buildAndPush
|
||||
repository: '$(repositoryName)/backend'
|
||||
dockerfile: 'backend/Dockerfile.prod'
|
||||
containerRegistry: '$(dockerRegistryServiceConnection)'
|
||||
tags: |
|
||||
$(imageTag)
|
||||
latest
|
||||
|
||||
- task: Docker@2
|
||||
displayName: 'Build and Push Frontend Image'
|
||||
inputs:
|
||||
command: buildAndPush
|
||||
repository: '$(repositoryName)/frontend'
|
||||
dockerfile: 'frontend/Dockerfile.prod'
|
||||
containerRegistry: '$(dockerRegistryServiceConnection)'
|
||||
tags: |
|
||||
$(imageTag)
|
||||
latest
|
||||
|
||||
- task: PowerShell@2
|
||||
displayName: 'Output Image URLs'
|
||||
inputs:
|
||||
targetType: 'inline'
|
||||
script: |
|
||||
$backendImage = "$(acrName).azurecr.io/$(repositoryName)/backend:$(imageTag)"
|
||||
$frontendImage = "$(acrName).azurecr.io/$(repositoryName)/frontend:$(imageTag)"
|
||||
Write-Host "##vso[task.setvariable variable=backendImage]$backendImage"
|
||||
Write-Host "##vso[task.setvariable variable=frontendImage]$frontendImage"
|
||||
Write-Host "Backend Image: $backendImage"
|
||||
Write-Host "Frontend Image: $frontendImage"
|
||||
@@ -2,9 +2,9 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (including dev dependencies for build)
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
RUN npm install && npm cache clean --force
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
@@ -19,7 +19,7 @@ WORKDIR /app
|
||||
|
||||
# Install only production dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -9,10 +9,15 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"generate-schema": "tsx scripts/generate-schema.ts",
|
||||
"migrate": "tsx scripts/run-migrations.ts",
|
||||
"check-admin": "tsx scripts/check-admin-user.ts",
|
||||
"migrate:sqlite-to-postgres": "tsx scripts/migrate-sqlite-to-postgres.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -20,6 +25,7 @@
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"helmet": "^8.0.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"openai": "^6.15.0",
|
||||
"pg": "^8.13.1",
|
||||
"winston": "^3.17.0",
|
||||
|
||||
109
backend/scripts/check-admin-user.ts
Normal file
109
backend/scripts/check-admin-user.ts
Normal 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();
|
||||
27
backend/scripts/run-migrations.ts
Normal file
27
backend/scripts/run-migrations.ts
Normal 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();
|
||||
@@ -30,12 +30,12 @@ interface Config {
|
||||
jiraHost: string;
|
||||
jiraSchemaId: string;
|
||||
|
||||
// Jira Service Account Token (for read operations: sync, fetching data)
|
||||
jiraServiceAccountToken: string;
|
||||
|
||||
// Jira Authentication Method ('pat' or 'oauth')
|
||||
jiraAuthMethod: JiraAuthMethod;
|
||||
|
||||
// Jira Personal Access Token (used when jiraAuthMethod = 'pat')
|
||||
jiraPat: string;
|
||||
|
||||
// Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth')
|
||||
jiraOAuthClientId: string;
|
||||
jiraOAuthClientSecret: string;
|
||||
@@ -45,14 +45,9 @@ interface Config {
|
||||
// Session Configuration
|
||||
sessionSecret: string;
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: string;
|
||||
openaiApiKey: string;
|
||||
defaultAIProvider: 'claude' | 'openai';
|
||||
|
||||
// Web Search API (Tavily)
|
||||
tavilyApiKey: string;
|
||||
enableWebSearch: boolean;
|
||||
// AI Configuration
|
||||
// Note: API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY), default AI provider,
|
||||
// and web search are now configured per-user in their profile settings, not in environment variables
|
||||
|
||||
// Application
|
||||
port: number;
|
||||
@@ -60,6 +55,9 @@ interface Config {
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
frontendUrl: string;
|
||||
appName: string;
|
||||
appTagline: string;
|
||||
appCopyright: string;
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: number;
|
||||
@@ -69,9 +67,9 @@ function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
// Helper to determine auth method with backward compatibility
|
||||
// Helper to determine auth method
|
||||
function getJiraAuthMethod(): JiraAuthMethod {
|
||||
// Check new JIRA_AUTH_METHOD first
|
||||
// Check JIRA_AUTH_METHOD first
|
||||
const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase();
|
||||
if (authMethod === 'oauth') return 'oauth';
|
||||
if (authMethod === 'pat') return 'pat';
|
||||
@@ -80,14 +78,12 @@ function getJiraAuthMethod(): JiraAuthMethod {
|
||||
const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true';
|
||||
if (oauthEnabled) return 'oauth';
|
||||
|
||||
// Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist
|
||||
const hasPat = !!getOptionalEnvVar('JIRA_PAT');
|
||||
// Default to 'oauth' if OAuth credentials exist, otherwise 'pat'
|
||||
const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET');
|
||||
|
||||
if (hasPat) return 'pat';
|
||||
if (hasOAuthCredentials) return 'oauth';
|
||||
|
||||
// Default to 'pat' (will show warning during validation)
|
||||
// Default to 'pat' (users configure PAT in their profile)
|
||||
return 'pat';
|
||||
}
|
||||
|
||||
@@ -96,12 +92,12 @@ export const config: Config = {
|
||||
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
|
||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||
|
||||
// Jira Service Account Token (for read operations: sync, fetching data)
|
||||
jiraServiceAccountToken: getOptionalEnvVar('JIRA_SERVICE_ACCOUNT_TOKEN'),
|
||||
|
||||
// Jira Authentication Method
|
||||
jiraAuthMethod: getJiraAuthMethod(),
|
||||
|
||||
// Jira Personal Access Token (for PAT authentication)
|
||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||
|
||||
// Jira OAuth 2.0 Configuration (for OAuth authentication)
|
||||
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
|
||||
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
||||
@@ -111,21 +107,15 @@ export const config: Config = {
|
||||
// Session Configuration
|
||||
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
|
||||
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
|
||||
defaultAIProvider: (getOptionalEnvVar('DEFAULT_AI_PROVIDER', 'claude') as 'claude' | 'openai'),
|
||||
|
||||
// Web Search API (Tavily)
|
||||
tavilyApiKey: getOptionalEnvVar('TAVILY_API_KEY'),
|
||||
enableWebSearch: getOptionalEnvVar('ENABLE_WEB_SEARCH', 'false').toLowerCase() === 'true',
|
||||
|
||||
// Application
|
||||
port: parseInt(getOptionalEnvVar('PORT', '3001'), 10),
|
||||
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
|
||||
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
|
||||
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
|
||||
frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'),
|
||||
appName: getOptionalEnvVar('APP_NAME', 'CMDB Insight'),
|
||||
appTagline: getOptionalEnvVar('APP_TAGLINE', 'Management console for Jira Assets'),
|
||||
appCopyright: getOptionalEnvVar('APP_COPYRIGHT', '© {year} Zuyderland Medisch Centrum'),
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10),
|
||||
@@ -139,9 +129,8 @@ export function validateConfig(): void {
|
||||
console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`);
|
||||
|
||||
if (config.jiraAuthMethod === 'pat') {
|
||||
if (!config.jiraPat) {
|
||||
missingVars.push('JIRA_PAT (required for PAT authentication)');
|
||||
}
|
||||
// JIRA_PAT is configured in user profiles, not in ENV
|
||||
warnings.push('JIRA_AUTH_METHOD=pat - users must configure PAT in their profile settings');
|
||||
} else if (config.jiraAuthMethod === 'oauth') {
|
||||
if (!config.jiraOAuthClientId) {
|
||||
missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)');
|
||||
@@ -156,7 +145,14 @@ export function validateConfig(): void {
|
||||
|
||||
// General required config
|
||||
if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID');
|
||||
if (!config.anthropicApiKey) warnings.push('ANTHROPIC_API_KEY not set - AI classification disabled');
|
||||
|
||||
// Service account token warning (not required, but recommended for sync operations)
|
||||
if (!config.jiraServiceAccountToken) {
|
||||
warnings.push('JIRA_SERVICE_ACCOUNT_TOKEN not configured - sync and read operations may not work. Users can still use their personal PAT for reads as fallback.');
|
||||
}
|
||||
|
||||
// AI API keys are configured in user profiles, not in ENV
|
||||
warnings.push('AI API keys must be configured in user profile settings');
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warnings.forEach(w => console.warn(`Warning: ${w}`));
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface ApplicationComponent extends BaseCMDBObject {
|
||||
updated: string | null;
|
||||
description: string | null; // * Application description
|
||||
status: string | null; // Application Lifecycle Management
|
||||
confluenceSpace: number | null;
|
||||
confluenceSpace: string | number | null; // Can be URL string (from Confluence link) or number (legacy)
|
||||
zenyaID: number | null;
|
||||
zenyaURL: string | null;
|
||||
customDevelopment: boolean | null; // Is er sprake van eigen programmatuur?
|
||||
|
||||
@@ -14,10 +14,15 @@ import referenceDataRouter from './routes/referenceData.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import configurationRouter from './routes/configuration.js';
|
||||
import authRouter, { authMiddleware } from './routes/auth.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import rolesRouter from './routes/roles.js';
|
||||
import userSettingsRouter from './routes/userSettings.js';
|
||||
import profileRouter from './routes/profile.js';
|
||||
import searchRouter from './routes/search.js';
|
||||
import cacheRouter from './routes/cache.js';
|
||||
import objectsRouter from './routes/objects.js';
|
||||
import schemaRouter from './routes/schema.js';
|
||||
import { runMigrations } from './services/database/migrations.js';
|
||||
|
||||
// Validate configuration
|
||||
validateConfig();
|
||||
@@ -55,13 +60,49 @@ app.use((req, res, next) => {
|
||||
// Auth middleware - extract session info for all requests
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Set user token on CMDBService for each request (for user-specific OAuth)
|
||||
app.use((req, res, next) => {
|
||||
// Set user's OAuth token if available
|
||||
// Set user token and settings on services for each request
|
||||
app.use(async (req, res, next) => {
|
||||
// Set user's OAuth token if available (for OAuth sessions)
|
||||
if (req.accessToken) {
|
||||
cmdbService.setUserToken(req.accessToken);
|
||||
}
|
||||
|
||||
// Set user's Jira PAT and AI keys if user is authenticated and has local account
|
||||
if (req.user && 'id' in req.user) {
|
||||
try {
|
||||
const { userSettingsService } = await import('./services/userSettingsService.js');
|
||||
const settings = await userSettingsService.getUserSettings(req.user.id);
|
||||
|
||||
if (settings?.jira_pat) {
|
||||
// Use user's Jira PAT from profile settings (preferred for writes)
|
||||
cmdbService.setUserToken(settings.jira_pat);
|
||||
} else if (config.jiraServiceAccountToken) {
|
||||
// Fallback to service account token if user doesn't have PAT configured
|
||||
// This allows writes to work when JIRA_SERVICE_ACCOUNT_TOKEN is set in .env
|
||||
cmdbService.setUserToken(config.jiraServiceAccountToken);
|
||||
logger.debug('Using service account token as fallback (user PAT not configured)');
|
||||
} else {
|
||||
// No token available - clear token
|
||||
cmdbService.setUserToken(null);
|
||||
}
|
||||
|
||||
// Store user settings in request for services to access
|
||||
(req as any).userSettings = settings;
|
||||
} catch (error) {
|
||||
// If user settings can't be loaded, try service account token as fallback
|
||||
logger.debug('Failed to load user settings:', error);
|
||||
if (config.jiraServiceAccountToken) {
|
||||
cmdbService.setUserToken(config.jiraServiceAccountToken);
|
||||
logger.debug('Using service account token as fallback (user settings load failed)');
|
||||
} else {
|
||||
cmdbService.setUserToken(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No user authenticated - clear token
|
||||
cmdbService.setUserToken(null);
|
||||
}
|
||||
|
||||
// Clear token after response is sent
|
||||
res.on('finish', () => {
|
||||
cmdbService.clearUserToken();
|
||||
@@ -80,7 +121,7 @@ app.get('/health', async (req, res) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data',
|
||||
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
|
||||
aiConfigured: !!config.anthropicApiKey,
|
||||
aiConfigured: true, // AI is configured per-user in profile settings
|
||||
cache: {
|
||||
isWarm: cacheStatus.isWarm,
|
||||
objectCount: cacheStatus.totalObjects,
|
||||
@@ -98,6 +139,10 @@ app.get('/api/config', (req, res) => {
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/roles', rolesRouter);
|
||||
app.use('/api/user-settings', userSettingsRouter);
|
||||
app.use('/api/profile', profileRouter);
|
||||
app.use('/api/applications', applicationsRouter);
|
||||
app.use('/api/classifications', classificationsRouter);
|
||||
app.use('/api/reference-data', referenceDataRouter);
|
||||
@@ -127,14 +172,24 @@ const PORT = config.port;
|
||||
app.listen(PORT, async () => {
|
||||
logger.info(`Server running on http://localhost:${PORT}`);
|
||||
logger.info(`Environment: ${config.nodeEnv}`);
|
||||
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`);
|
||||
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`);
|
||||
logger.info(`AI Classification: Configured per-user in profile settings`);
|
||||
logger.info(`Jira Assets: ${config.jiraSchemaId ? 'Schema configured - users configure PAT in profile' : 'Schema not configured'}`);
|
||||
|
||||
// Initialize sync engine if using Jira Assets
|
||||
if (config.jiraPat && config.jiraSchemaId) {
|
||||
// Run database migrations
|
||||
try {
|
||||
await runMigrations();
|
||||
logger.info('Database migrations completed');
|
||||
} catch (error) {
|
||||
logger.error('Failed to run database migrations', error);
|
||||
}
|
||||
|
||||
// Initialize sync engine if Jira schema is configured
|
||||
// Note: Sync engine will only sync when users with configured Jira PATs make requests
|
||||
// This prevents unauthorized Jira API calls
|
||||
if (config.jiraSchemaId) {
|
||||
try {
|
||||
await syncEngine.initialize();
|
||||
logger.info('Sync Engine: Initialized and running');
|
||||
logger.info('Sync Engine: Initialized (sync on-demand per user request)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize sync engine', error);
|
||||
}
|
||||
|
||||
115
backend/src/middleware/authorization.ts
Normal file
115
backend/src/middleware/authorization.ts
Normal 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');
|
||||
@@ -6,13 +6,18 @@ import { logger } from '../services/logger.js';
|
||||
import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
|
||||
import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js';
|
||||
import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js';
|
||||
import { getQueryString, getParamString } from '../utils/queryHelpers.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
|
||||
import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Search applications with filters
|
||||
router.post('/search', async (req: Request, res: Response) => {
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Search applications with filters (requires search permission)
|
||||
router.post('/search', requirePermission('search'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filters, page = 1, pageSize = 25 } = req.body as {
|
||||
filters: SearchFilters;
|
||||
@@ -31,7 +36,7 @@ router.post('/search', async (req: Request, res: Response) => {
|
||||
// Get team dashboard data
|
||||
router.get('/team-dashboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const excludedStatusesParam = req.query.excludedStatuses as string | undefined;
|
||||
const excludedStatusesParam = getQueryString(req, 'excludedStatuses');
|
||||
let excludedStatuses: ApplicationStatus[] = [];
|
||||
|
||||
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
|
||||
@@ -56,7 +61,7 @@ router.get('/team-dashboard', async (req: Request, res: Response) => {
|
||||
// Get team portfolio health metrics
|
||||
router.get('/team-portfolio-health', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const excludedStatusesParam = req.query.excludedStatuses as string | undefined;
|
||||
const excludedStatusesParam = getQueryString(req, 'excludedStatuses');
|
||||
let excludedStatuses: ApplicationStatus[] = [];
|
||||
|
||||
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
|
||||
@@ -95,7 +100,7 @@ router.get('/business-importance-comparison', async (req: Request, res: Response
|
||||
// Test BIA data loading (for debugging)
|
||||
router.get('/bia-test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (req.query.clear === 'true') {
|
||||
if (getQueryString(req, 'clear') === 'true') {
|
||||
clearBIACache();
|
||||
}
|
||||
const biaData = loadBIAData();
|
||||
@@ -133,7 +138,7 @@ router.get('/bia-debug', async (req: Request, res: Response) => {
|
||||
|
||||
// Test each sample app
|
||||
for (const app of [...sampleApps, ...testApps]) {
|
||||
const matchResult = findBIAMatch(app.name, app.searchReference);
|
||||
const matchResult = findBIAMatch(app.name, app.searchReference ?? null);
|
||||
|
||||
// Find all potential matches in Excel data for detailed analysis
|
||||
const normalizedAppName = app.name.toLowerCase().trim();
|
||||
@@ -246,7 +251,7 @@ router.get('/bia-comparison', async (req: Request, res: Response) => {
|
||||
|
||||
for (const app of applications) {
|
||||
// Find BIA match in Excel
|
||||
const matchResult = findBIAMatch(app.name, app.searchReference);
|
||||
const matchResult = findBIAMatch(app.name, app.searchReference ?? null);
|
||||
|
||||
// Log first few matches for debugging
|
||||
if (comparisonItems.length < 5) {
|
||||
@@ -322,8 +327,8 @@ router.get('/bia-comparison', async (req: Request, res: Response) => {
|
||||
// - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection)
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const mode = req.query.mode as string | undefined;
|
||||
const id = getParamString(req, 'id');
|
||||
const mode = getQueryString(req, 'mode');
|
||||
|
||||
// Don't treat special routes as application IDs
|
||||
if (id === 'team-dashboard' || id === 'team-portfolio-health' || id === 'business-importance-comparison' || id === 'bia-comparison' || id === 'bia-test' || id === 'calculate-effort' || id === 'search') {
|
||||
@@ -355,10 +360,23 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update application with conflict detection
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
// Update application with conflict detection (requires edit permission)
|
||||
router.put('/:id', requirePermission('edit_applications'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
|
||||
const userSettings = (req as any).userSettings;
|
||||
const { config } = await import('../config/env.js');
|
||||
|
||||
// Allow writes if user has PAT OR service account token is configured
|
||||
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
|
||||
res.status(403).json({
|
||||
error: 'Jira PAT not configured',
|
||||
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = getParamString(req, 'id');
|
||||
const { updates, _jiraUpdatedAt } = req.body as {
|
||||
updates?: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
@@ -467,10 +485,23 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Force update (ignore conflicts)
|
||||
router.put('/:id/force', async (req: Request, res: Response) => {
|
||||
// Force update (ignore conflicts) (requires edit permission)
|
||||
router.put('/:id/force', requirePermission('edit_applications'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
|
||||
const userSettings = (req as any).userSettings;
|
||||
const { config } = await import('../config/env.js');
|
||||
|
||||
// Allow writes if user has PAT OR service account token is configured
|
||||
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
|
||||
res.status(403).json({
|
||||
error: 'Jira PAT not configured',
|
||||
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = getParamString(req, 'id');
|
||||
const updates = req.body;
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
@@ -551,7 +582,7 @@ router.post('/calculate-effort', async (req: Request, res: Response) => {
|
||||
// Get application classification history
|
||||
router.get('/:id/history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const id = getParamString(req, 'id');
|
||||
const history = await databaseService.getClassificationsByApplicationId(id);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
@@ -563,7 +594,8 @@ router.get('/:id/history', async (req: Request, res: Response) => {
|
||||
// Get related objects for an application (from cache)
|
||||
router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, objectType } = req.params;
|
||||
const id = getParamString(req, 'id');
|
||||
const objectType = getParamString(req, 'objectType');
|
||||
|
||||
// Map object type string to CMDBObjectTypeName
|
||||
const typeMap: Record<string, CMDBObjectTypeName> = {
|
||||
@@ -617,8 +649,9 @@ router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
// Get requested attributes from query string
|
||||
const requestedAttrs = req.query.attributes
|
||||
? String(req.query.attributes).split(',').map(a => a.trim())
|
||||
const attributesParam = getQueryString(req, 'attributes');
|
||||
const requestedAttrs = attributesParam
|
||||
? attributesParam.split(',').map(a => a.trim())
|
||||
: [];
|
||||
|
||||
// Format response - must match RelatedObjectsResponse type expected by frontend
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authService, JiraUser } from '../services/authService.js';
|
||||
import { authService, type SessionUser, type JiraUser } from '../services/authService.js';
|
||||
import { userService } from '../services/userService.js';
|
||||
import { roleService } from '../services/roleService.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { getAuthDatabase } from '../services/database/migrations.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,55 +13,179 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
sessionId?: string;
|
||||
user?: JiraUser;
|
||||
user?: SessionUser | JiraUser;
|
||||
accessToken?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get auth configuration
|
||||
router.get('/config', (req: Request, res: Response) => {
|
||||
const authMethod = authService.getAuthMethod();
|
||||
router.get('/config', async (req: Request, res: Response) => {
|
||||
// JIRA_AUTH_METHOD is only for backend Jira API configuration, NOT for application authentication
|
||||
// Application authentication is ALWAYS via local auth or OAuth
|
||||
// Users authenticate to the application, then their PAT/OAuth token is used for Jira API writes
|
||||
// JIRA_SERVICE_ACCOUNT_TOKEN is used for Jira API reads
|
||||
|
||||
// Check if users exist in database (if migrations have run)
|
||||
let hasUsers = false;
|
||||
try {
|
||||
const db = getAuthDatabase();
|
||||
const userCount = await db.queryOne<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM users'
|
||||
);
|
||||
hasUsers = (userCount?.count || 0) > 0;
|
||||
await db.close();
|
||||
} catch (error) {
|
||||
// If table doesn't exist yet, hasUsers stays false
|
||||
}
|
||||
|
||||
// Local auth is ALWAYS enabled for application authentication
|
||||
// (unless explicitly disabled via LOCAL_AUTH_ENABLED=false)
|
||||
// This allows users to create accounts and log in
|
||||
const localAuthEnabled = process.env.LOCAL_AUTH_ENABLED !== 'false';
|
||||
|
||||
// OAuth is enabled if configured
|
||||
const oauthEnabled = authService.isOAuthEnabled();
|
||||
|
||||
// Service accounts are NOT used for application authentication
|
||||
// They are only for Jira API read access (JIRA_SERVICE_ACCOUNT_TOKEN in .env)
|
||||
// serviceAccountEnabled should always be false for authentication purposes
|
||||
|
||||
// authMethod is 'local' if local auth is enabled, 'oauth' if only OAuth, or 'none' if both disabled
|
||||
let authMethod: 'local' | 'oauth' | 'none' = 'none';
|
||||
if (localAuthEnabled && oauthEnabled) {
|
||||
authMethod = 'local'; // Default to local, user can choose
|
||||
} else if (localAuthEnabled) {
|
||||
authMethod = 'local';
|
||||
} else if (oauthEnabled) {
|
||||
authMethod = 'oauth';
|
||||
}
|
||||
|
||||
res.json({
|
||||
// Configured authentication method ('pat', 'oauth', or 'none')
|
||||
// Application branding
|
||||
appName: config.appName,
|
||||
appTagline: config.appTagline,
|
||||
appCopyright: config.appCopyright,
|
||||
// Application authentication method (always 'local' or 'oauth', never 'pat')
|
||||
// 'pat' is only for backend Jira API configuration, not user authentication
|
||||
authMethod,
|
||||
// Legacy fields for backward compatibility
|
||||
oauthEnabled: authService.isOAuthEnabled(),
|
||||
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
||||
// Authentication options
|
||||
oauthEnabled,
|
||||
serviceAccountEnabled: false, // Service accounts are NOT for app authentication
|
||||
localAuthEnabled,
|
||||
// Jira host for display purposes
|
||||
jiraHost: config.jiraHost,
|
||||
});
|
||||
});
|
||||
|
||||
// Get current user (check if logged in)
|
||||
router.get('/me', (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
router.get('/me', async (req: Request, res: Response) => {
|
||||
// The sessionId should already be set by authMiddleware from cookies
|
||||
const sessionId = req.sessionId || req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
logger.debug(`[GET /me] SessionId: ${sessionId ? sessionId.substring(0, 8) + '...' : 'none'}, Cookies: ${JSON.stringify(req.cookies)}`);
|
||||
|
||||
// Service accounts are NOT used for application authentication
|
||||
// They are only used for Jira API access (configured in .env as JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
// Application authentication requires a real user session (local or OAuth)
|
||||
|
||||
if (!sessionId) {
|
||||
// If OAuth not enabled, allow anonymous access with service account
|
||||
if (authService.isUsingServiceAccount() && !authService.isOAuthEnabled()) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
authMethod: 'service-account',
|
||||
user: {
|
||||
accountId: 'service-account',
|
||||
displayName: 'Service Account',
|
||||
},
|
||||
});
|
||||
// No session = not authenticated
|
||||
// Service account mode is NOT a valid authentication method for the application
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await authService.getSession(sessionId);
|
||||
if (!session) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
// Determine auth method from session
|
||||
let authMethod = 'local';
|
||||
if ('accountId' in session.user) {
|
||||
authMethod = 'oauth';
|
||||
} else if ('id' in session.user) {
|
||||
authMethod = 'local';
|
||||
}
|
||||
|
||||
// For local users, ensure we have all required fields
|
||||
let userData = session.user;
|
||||
if ('id' in session.user) {
|
||||
// Local user - ensure proper format
|
||||
userData = {
|
||||
id: session.user.id,
|
||||
email: session.user.email || session.user.emailAddress,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
emailAddress: session.user.email || session.user.emailAddress,
|
||||
roles: session.user.roles || [],
|
||||
permissions: session.user.permissions || [],
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
user: userData,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting session:', error);
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
const user = authService.getUser(sessionId);
|
||||
if (!user) {
|
||||
return res.json({ authenticated: false });
|
||||
// Local login (email/password)
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
if (!authService.isLocalAuthEnabled()) {
|
||||
return res.status(400).json({ error: 'Local authentication is not enabled' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
user,
|
||||
});
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const ipAddress = req.ip || req.socket.remoteAddress || undefined;
|
||||
const userAgent = req.get('user-agent') || undefined;
|
||||
|
||||
const { sessionId, user } = await authService.localLogin(email, password, ipAddress, userAgent);
|
||||
|
||||
// Set session cookie
|
||||
// Note: When using Vite proxy, cookies work correctly as the proxy forwards them
|
||||
// In development, use 'lax' for same-site requests (localhost:5173 -> localhost:3001 via proxy)
|
||||
// In production, use 'lax' for security
|
||||
const cookieOptions: any = {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/', // Make cookie available for all paths
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
|
||||
// In development, don't set domain (defaults to current host)
|
||||
// This allows the cookie to work with the Vite proxy
|
||||
if (!config.isDevelopment) {
|
||||
// In production, you might want to set domain explicitly if needed
|
||||
// cookieOptions.domain = '.yourdomain.com';
|
||||
}
|
||||
|
||||
res.cookie('sessionId', sessionId, cookieOptions);
|
||||
|
||||
logger.debug(`[Local Login] Session cookie set: sessionId=${sessionId.substring(0, 8)}..., path=/, maxAge=24h, sameSite=lax`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
user,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Local login error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Login failed';
|
||||
res.status(401).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Initiate OAuth login
|
||||
@@ -102,21 +229,41 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const ipAddress = req.ip || req.socket.remoteAddress || undefined;
|
||||
const userAgent = req.get('user-agent') || undefined;
|
||||
|
||||
// Exchange code for tokens
|
||||
const { sessionId, user } = await authService.exchangeCodeForTokens(
|
||||
String(code),
|
||||
String(state)
|
||||
String(state),
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
logger.info(`OAuth login successful for: ${user.displayName}`);
|
||||
|
||||
// Set session cookie
|
||||
res.cookie('sessionId', sessionId, {
|
||||
// Note: When using Vite proxy, cookies work correctly as the proxy forwards them
|
||||
// In development, use 'lax' for same-site requests (localhost:5173 -> localhost:3001 via proxy)
|
||||
// In production, use 'lax' for security
|
||||
const cookieOptions: any = {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'lax',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/', // Make cookie available for all paths
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
};
|
||||
|
||||
// In development, don't set domain (defaults to current host)
|
||||
// This allows the cookie to work with the Vite proxy
|
||||
if (!config.isDevelopment) {
|
||||
// In production, you might want to set domain explicitly if needed
|
||||
// cookieOptions.domain = '.yourdomain.com';
|
||||
}
|
||||
|
||||
res.cookie('sessionId', sessionId, cookieOptions);
|
||||
|
||||
logger.debug(`[OAuth Login] Session cookie set: sessionId=${sessionId.substring(0, 8)}..., path=/, maxAge=24h, sameSite=lax`);
|
||||
|
||||
// Redirect to frontend with session info
|
||||
res.redirect(`${config.frontendUrl}?login=success`);
|
||||
@@ -128,16 +275,16 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
router.post('/logout', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (sessionId) {
|
||||
authService.logout(sessionId);
|
||||
await authService.logout(sessionId);
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
res.clearCookie('sessionId');
|
||||
res.clearCookie('oauth_state');
|
||||
// Clear cookies (must use same path as when setting)
|
||||
res.clearCookie('sessionId', { path: '/' });
|
||||
res.clearCookie('oauth_state', { path: '/' });
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -159,37 +306,183 @@ router.post('/refresh', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Forgot password
|
||||
router.post('/forgot-password', async (req: Request, res: Response) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.generatePasswordResetToken(email);
|
||||
// Always return success to prevent email enumeration
|
||||
res.json({ success: true, message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
} catch (error) {
|
||||
logger.error('Forgot password error:', error);
|
||||
// Still return success to prevent email enumeration
|
||||
res.json({ success: true, message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset password
|
||||
router.post('/reset-password', async (req: Request, res: Response) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: 'Token and password are required' });
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10);
|
||||
const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true';
|
||||
const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true';
|
||||
const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true';
|
||||
|
||||
if (password.length < minLength) {
|
||||
return res.status(400).json({ error: `Password must be at least ${minLength} characters long` });
|
||||
}
|
||||
if (requireUppercase && !/[A-Z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one uppercase letter' });
|
||||
}
|
||||
if (requireLowercase && !/[a-z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one lowercase letter' });
|
||||
}
|
||||
if (requireNumber && !/[0-9]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one number' });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await userService.resetPasswordWithToken(token, password);
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Password reset successfully' });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Reset password error:', error);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify email
|
||||
router.post('/verify-email', async (req: Request, res: Response) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Token is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await userService.verifyEmail(token);
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Email verified successfully' });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Verify email error:', error);
|
||||
res.status(500).json({ error: 'Failed to verify email' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get invitation token info
|
||||
router.get('/invitation/:token', async (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
|
||||
try {
|
||||
const user = await userService.validateInvitationToken(token);
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: 'Invalid or expired invitation token' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Validate invitation error:', error);
|
||||
res.status(500).json({ error: 'Failed to validate invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Accept invitation
|
||||
router.post('/accept-invitation', async (req: Request, res: Response) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: 'Token and password are required' });
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10);
|
||||
const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true';
|
||||
const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true';
|
||||
const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true';
|
||||
|
||||
if (password.length < minLength) {
|
||||
return res.status(400).json({ error: `Password must be at least ${minLength} characters long` });
|
||||
}
|
||||
if (requireUppercase && !/[A-Z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one uppercase letter' });
|
||||
}
|
||||
if (requireLowercase && !/[a-z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one lowercase letter' });
|
||||
}
|
||||
if (requireNumber && !/[0-9]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one number' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userService.acceptInvitation(token, password);
|
||||
if (user) {
|
||||
res.json({ success: true, message: 'Invitation accepted successfully', user });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or expired invitation token' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Accept invitation error:', error);
|
||||
res.status(500).json({ error: 'Failed to accept invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to extract session and attach user to request
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
// Debug logging for cookie issues
|
||||
if (req.path === '/api/auth/me') {
|
||||
logger.debug(`[authMiddleware] Path: ${req.path}, Cookies: ${JSON.stringify(req.cookies)}, SessionId from cookie: ${req.cookies?.sessionId}, SessionId from header: ${req.headers['x-session-id']}`);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const session = authService.getSession(sessionId);
|
||||
if (session) {
|
||||
req.sessionId = sessionId;
|
||||
req.user = session.user;
|
||||
req.accessToken = session.accessToken;
|
||||
try {
|
||||
const session = await authService.getSession(sessionId);
|
||||
if (session) {
|
||||
req.sessionId = sessionId;
|
||||
req.user = session.user;
|
||||
req.accessToken = session.accessToken;
|
||||
} else {
|
||||
logger.debug(`[authMiddleware] Session not found for sessionId: ${sessionId.substring(0, 8)}...`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error:', error);
|
||||
}
|
||||
} else {
|
||||
if (req.path === '/api/auth/me') {
|
||||
logger.debug(`[authMiddleware] No sessionId found in cookies or headers for ${req.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware to require authentication
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
// If OAuth is enabled, require a valid session
|
||||
if (authService.isOAuthEnabled()) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
}
|
||||
// If only service account is configured, allow through
|
||||
else if (!authService.isUsingServiceAccount()) {
|
||||
return res.status(503).json({ error: 'No authentication method configured' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
// Re-export authorization middleware for convenience
|
||||
export { requireAuth, requireRole, requirePermission, requireAdmin } from '../middleware/authorization.js';
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ import { Router, Request, Response } from 'express';
|
||||
import { cacheStore } from '../services/cacheStore.js';
|
||||
import { syncEngine } from '../services/syncEngine.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import { getQueryString, getParamString } from '../utils/queryHelpers.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and manage_settings permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('manage_settings'));
|
||||
|
||||
// Get cache status
|
||||
router.get('/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -76,7 +82,7 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
// Trigger sync for a specific object type
|
||||
router.post('/sync/:objectType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { objectType } = req.params;
|
||||
const objectType = getParamString(req, 'objectType');
|
||||
|
||||
// Validate object type
|
||||
if (!OBJECT_TYPES[objectType]) {
|
||||
@@ -93,18 +99,19 @@ router.post('/sync/:objectType', async (req: Request, res: Response) => {
|
||||
|
||||
res.json({
|
||||
status: 'completed',
|
||||
objectType,
|
||||
objectType: objectType,
|
||||
stats: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const objectType = getParamString(req, 'objectType');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to sync object type';
|
||||
logger.error(`Failed to sync object type ${req.params.objectType}`, error);
|
||||
logger.error(`Failed to sync object type ${objectType}`, error);
|
||||
|
||||
// Return 409 (Conflict) if sync is already in progress, otherwise 500
|
||||
const statusCode = errorMessage.includes('already in progress') ? 409 : 500;
|
||||
res.status(statusCode).json({
|
||||
error: errorMessage,
|
||||
objectType: req.params.objectType,
|
||||
objectType: objectType,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -112,7 +119,7 @@ router.post('/sync/:objectType', async (req: Request, res: Response) => {
|
||||
// Clear cache for a specific type
|
||||
router.delete('/clear/:objectType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { objectType } = req.params;
|
||||
const objectType = getParamString(req, 'objectType');
|
||||
|
||||
if (!OBJECT_TYPES[objectType]) {
|
||||
res.status(400).json({
|
||||
@@ -132,7 +139,8 @@ router.delete('/clear/:objectType', async (req: Request, res: Response) => {
|
||||
deletedCount: deleted,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to clear cache for ${req.params.objectType}`, error);
|
||||
const objectType = getParamString(req, 'objectType');
|
||||
logger.error(`Failed to clear cache for ${objectType}`, error);
|
||||
res.status(500).json({ error: 'Failed to clear cache' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,20 +4,50 @@ import { dataService } from '../services/dataService.js';
|
||||
import { databaseService } from '../services/database.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get AI classification for an application
|
||||
router.post('/suggest/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Get provider from query parameter or request body, default to config
|
||||
const provider = (req.query.provider as AIProvider) || (req.body.provider as AIProvider) || config.defaultAIProvider;
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
if (!aiService.isConfigured(provider)) {
|
||||
// Get AI classification for an application (requires search permission)
|
||||
router.post('/suggest/:id', requirePermission('search'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = getParamString(req, 'id');
|
||||
// Get provider from query parameter, request body, or user settings (default to 'claude')
|
||||
const userSettings = (req as any).userSettings;
|
||||
|
||||
// Check if AI is enabled for this user
|
||||
if (!userSettings?.ai_enabled) {
|
||||
res.status(403).json({
|
||||
error: 'AI functionality is disabled',
|
||||
message: 'AI functionality is not enabled in your profile settings. Please enable it in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has selected an AI provider
|
||||
if (!userSettings?.ai_provider) {
|
||||
res.status(403).json({
|
||||
error: 'AI provider not configured',
|
||||
message: 'Please select an AI provider (Claude or OpenAI) in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userDefaultProvider = userSettings.ai_provider === 'anthropic' ? 'claude' : userSettings.ai_provider === 'openai' ? 'openai' : 'claude';
|
||||
const provider = (getQueryString(req, 'provider') as AIProvider) || (req.body.provider as AIProvider) || (userDefaultProvider as AIProvider);
|
||||
|
||||
// Check if user has API key for the selected provider
|
||||
const hasApiKey = (provider === 'claude' && userSettings.ai_provider === 'anthropic' && !!userSettings.ai_api_key) ||
|
||||
(provider === 'openai' && userSettings.ai_provider === 'openai' && !!userSettings.ai_api_key);
|
||||
|
||||
if (!hasApiKey) {
|
||||
res.status(503).json({
|
||||
error: 'AI classification not available',
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured. Please set ${provider === 'claude' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'}.`
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API key is not configured. Please configure the API key in your user settings.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -28,8 +58,15 @@ router.post('/suggest/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user API keys from user settings (already loaded above)
|
||||
const userApiKeys = userSettings ? {
|
||||
anthropic: userSettings.ai_provider === 'anthropic' ? userSettings.ai_api_key : undefined,
|
||||
openai: userSettings.ai_provider === 'openai' ? userSettings.ai_api_key : undefined,
|
||||
tavily: userSettings.tavily_api_key,
|
||||
} : undefined;
|
||||
|
||||
logger.info(`Generating AI classification for: ${application.name} using ${provider}`);
|
||||
const suggestion = await aiService.classifyApplication(application, provider);
|
||||
const suggestion = await aiService.classifyApplication(application, provider, userApiKeys);
|
||||
|
||||
res.json(suggestion);
|
||||
} catch (error) {
|
||||
@@ -52,7 +89,7 @@ router.get('/taxonomy', (req: Request, res: Response) => {
|
||||
// Get function by code
|
||||
router.get('/function/:code', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const code = getParamString(req, 'code');
|
||||
const func = aiService.getFunctionByCode(code);
|
||||
|
||||
if (!func) {
|
||||
@@ -70,7 +107,7 @@ router.get('/function/:code', (req: Request, res: Response) => {
|
||||
// Get classification history
|
||||
router.get('/history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const limit = getQueryNumber(req, 'limit', 50);
|
||||
const history = await databaseService.getClassificationHistory(limit);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
@@ -91,12 +128,16 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Check if AI is available - returns available providers
|
||||
router.get('/ai-status', (req: Request, res: Response) => {
|
||||
router.get('/ai-status', requireAuth, (req: Request, res: Response) => {
|
||||
const availableProviders = aiService.getAvailableProviders();
|
||||
// Get user's default provider from settings (default to 'claude')
|
||||
const userSettings = (req as any).userSettings;
|
||||
const userDefaultProvider = userSettings?.ai_provider === 'anthropic' ? 'claude' : userSettings?.ai_provider === 'openai' ? 'openai' : 'claude';
|
||||
|
||||
res.json({
|
||||
available: availableProviders.length > 0,
|
||||
providers: availableProviders,
|
||||
defaultProvider: config.defaultAIProvider,
|
||||
defaultProvider: userDefaultProvider,
|
||||
claude: {
|
||||
available: aiService.isProviderConfigured('claude'),
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
@@ -111,7 +152,7 @@ router.get('/ai-status', (req: Request, res: Response) => {
|
||||
// Get the AI prompt for an application (for debugging/copy-paste)
|
||||
router.get('/prompt/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const id = getParamString(req, 'id');
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
@@ -127,10 +168,10 @@ router.get('/prompt/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Chat with AI about an application
|
||||
router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
// Chat with AI about an application (requires search permission)
|
||||
router.post('/chat/:id', requirePermission('search'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const id = getParamString(req, 'id');
|
||||
const { message, conversationId, provider: requestProvider } = req.body;
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
@@ -138,12 +179,38 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = (requestProvider as AIProvider) || config.defaultAIProvider;
|
||||
// Get provider from request or user settings (default to 'claude')
|
||||
const userSettings = (req as any).userSettings;
|
||||
|
||||
// Check if AI is enabled for this user
|
||||
if (!userSettings?.ai_enabled) {
|
||||
res.status(403).json({
|
||||
error: 'AI functionality is disabled',
|
||||
message: 'AI functionality is not enabled in your profile settings. Please enable it in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has selected an AI provider
|
||||
if (!userSettings?.ai_provider) {
|
||||
res.status(403).json({
|
||||
error: 'AI provider not configured',
|
||||
message: 'Please select an AI provider (Claude or OpenAI) in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userDefaultProvider = userSettings.ai_provider === 'anthropic' ? 'claude' : userSettings.ai_provider === 'openai' ? 'openai' : 'claude';
|
||||
const provider = (requestProvider as AIProvider) || (userDefaultProvider as AIProvider);
|
||||
|
||||
if (!aiService.isConfigured(provider)) {
|
||||
// Check if user has API key for the selected provider
|
||||
const hasApiKey = (provider === 'claude' && userSettings.ai_provider === 'anthropic' && !!userSettings.ai_api_key) ||
|
||||
(provider === 'openai' && userSettings.ai_provider === 'openai' && !!userSettings.ai_api_key);
|
||||
|
||||
if (!hasApiKey) {
|
||||
res.status(503).json({
|
||||
error: 'AI chat not available',
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured.`
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API key is not configured. Please configure the API key in your user settings.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -154,8 +221,15 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user API keys from user settings (already loaded above)
|
||||
const userApiKeys = userSettings ? {
|
||||
anthropic: userSettings.ai_provider === 'anthropic' ? userSettings.ai_api_key : undefined,
|
||||
openai: userSettings.ai_provider === 'openai' ? userSettings.ai_api_key : undefined,
|
||||
tavily: userSettings.tavily_api_key,
|
||||
} : undefined;
|
||||
|
||||
logger.info(`Chat message for: ${application.name} using ${provider}`);
|
||||
const response = await aiService.chat(application, message.trim(), conversationId, provider);
|
||||
const response = await aiService.chat(application, message.trim(), conversationId, provider, userApiKeys);
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
@@ -167,7 +241,7 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
// Get conversation history
|
||||
router.get('/chat/conversation/:conversationId', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conversationId } = req.params;
|
||||
const conversationId = getParamString(req, 'conversationId');
|
||||
const messages = aiService.getConversationHistory(conversationId);
|
||||
|
||||
if (messages.length === 0) {
|
||||
@@ -185,7 +259,7 @@ router.get('/chat/conversation/:conversationId', (req: Request, res: Response) =
|
||||
// Clear a conversation
|
||||
router.delete('/chat/conversation/:conversationId', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conversationId } = req.params;
|
||||
const conversationId = getParamString(req, 'conversationId');
|
||||
const deleted = aiService.clearConversation(conversationId);
|
||||
|
||||
if (!deleted) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js';
|
||||
import type { DataCompletenessConfig } from '../types/index.js';
|
||||
|
||||
@@ -13,9 +14,13 @@ const __dirname = dirname(__filename);
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and manage_settings permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('manage_settings'));
|
||||
|
||||
// Path to the configuration files
|
||||
const CONFIG_FILE_PATH = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
const COMPLETENESS_CONFIG_FILE_PATH = join(__dirname, '../../data/data-completeness-config.json');
|
||||
|
||||
/**
|
||||
@@ -142,37 +147,39 @@ router.get('/data-completeness', async (req: Request, res: Response) => {
|
||||
description: 'Configuration for Data Completeness Score fields',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
categories: {
|
||||
general: {
|
||||
categories: [
|
||||
{
|
||||
id: 'general',
|
||||
name: 'General',
|
||||
description: 'General application information fields',
|
||||
fields: [
|
||||
{ name: 'Organisation', fieldPath: 'organisation', enabled: true },
|
||||
{ name: 'ApplicationFunction', fieldPath: 'applicationFunctions', enabled: true },
|
||||
{ name: 'Status', fieldPath: 'status', enabled: true },
|
||||
{ name: 'Business Impact Analyse', fieldPath: 'businessImpactAnalyse', enabled: true },
|
||||
{ name: 'Application Component Hosting Type', fieldPath: 'hostingType', enabled: true },
|
||||
{ name: 'Supplier Product', fieldPath: 'supplierProduct', enabled: true },
|
||||
{ name: 'Business Owner', fieldPath: 'businessOwner', enabled: true },
|
||||
{ name: 'System Owner', fieldPath: 'systemOwner', enabled: true },
|
||||
{ name: 'Functional Application Management', fieldPath: 'functionalApplicationManagement', enabled: true },
|
||||
{ name: 'Technical Application Management', fieldPath: 'technicalApplicationManagement', enabled: true },
|
||||
{ id: 'organisation', name: 'Organisation', fieldPath: 'organisation', enabled: true },
|
||||
{ id: 'applicationFunctions', name: 'ApplicationFunction', fieldPath: 'applicationFunctions', enabled: true },
|
||||
{ id: 'status', name: 'Status', fieldPath: 'status', enabled: true },
|
||||
{ id: 'businessImpactAnalyse', name: 'Business Impact Analyse', fieldPath: 'businessImpactAnalyse', enabled: true },
|
||||
{ id: 'hostingType', name: 'Application Component Hosting Type', fieldPath: 'hostingType', enabled: true },
|
||||
{ id: 'supplierProduct', name: 'Supplier Product', fieldPath: 'supplierProduct', enabled: true },
|
||||
{ id: 'businessOwner', name: 'Business Owner', fieldPath: 'businessOwner', enabled: true },
|
||||
{ id: 'systemOwner', name: 'System Owner', fieldPath: 'systemOwner', enabled: true },
|
||||
{ id: 'functionalApplicationManagement', name: 'Functional Application Management', fieldPath: 'functionalApplicationManagement', enabled: true },
|
||||
{ id: 'technicalApplicationManagement', name: 'Technical Application Management', fieldPath: 'technicalApplicationManagement', enabled: true },
|
||||
],
|
||||
},
|
||||
applicationManagement: {
|
||||
{
|
||||
id: 'applicationManagement',
|
||||
name: 'Application Management',
|
||||
description: 'Application management classification fields',
|
||||
fields: [
|
||||
{ name: 'ICT Governance Model', fieldPath: 'governanceModel', enabled: true },
|
||||
{ name: 'Application Management - Application Type', fieldPath: 'applicationType', enabled: true },
|
||||
{ name: 'Application Management - Hosting', fieldPath: 'applicationManagementHosting', enabled: true },
|
||||
{ name: 'Application Management - TAM', fieldPath: 'applicationManagementTAM', enabled: true },
|
||||
{ name: 'Application Management - Dynamics Factor', fieldPath: 'dynamicsFactor', enabled: true },
|
||||
{ name: 'Application Management - Complexity Factor', fieldPath: 'complexityFactor', enabled: true },
|
||||
{ name: 'Application Management - Number of Users', fieldPath: 'numberOfUsers', enabled: true },
|
||||
{ id: 'governanceModel', name: 'ICT Governance Model', fieldPath: 'governanceModel', enabled: true },
|
||||
{ id: 'applicationType', name: 'Application Management - Application Type', fieldPath: 'applicationType', enabled: true },
|
||||
{ id: 'applicationManagementHosting', name: 'Application Management - Hosting', fieldPath: 'applicationManagementHosting', enabled: true },
|
||||
{ id: 'applicationManagementTAM', name: 'Application Management - TAM', fieldPath: 'applicationManagementTAM', enabled: true },
|
||||
{ id: 'dynamicsFactor', name: 'Application Management - Dynamics Factor', fieldPath: 'dynamicsFactor', enabled: true },
|
||||
{ id: 'complexityFactor', name: 'Application Management - Complexity Factor', fieldPath: 'complexityFactor', enabled: true },
|
||||
{ id: 'numberOfUsers', name: 'Application Management - Number of Users', fieldPath: 'numberOfUsers', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
res.json(defaultConfig);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { databaseService } from '../services/database.js';
|
||||
import { syncEngine } from '../services/syncEngine.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { validateApplicationConfiguration, calculateRequiredEffortWithMinMax } from '../services/effortCalculation.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
@@ -11,6 +12,10 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and view_reports permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('view_reports'));
|
||||
|
||||
// Simple in-memory cache for dashboard stats
|
||||
interface CachedStats {
|
||||
data: unknown;
|
||||
@@ -778,6 +783,7 @@ router.get('/data-completeness', async (req: Request, res: Response) => {
|
||||
byField: byFieldArray,
|
||||
byApplication,
|
||||
byTeam: byTeamArray,
|
||||
config: completenessConfig, // Include config so frontend doesn't need to fetch it separately
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get data completeness', error);
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and search permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('search'));
|
||||
|
||||
// Get list of supported object types
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({
|
||||
@@ -31,10 +37,10 @@ router.get('/', (req: Request, res: Response) => {
|
||||
// Get all objects of a type
|
||||
router.get('/:type', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 1000;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const search = req.query.search as string | undefined;
|
||||
const type = getParamString(req, 'type');
|
||||
const limit = getQueryNumber(req, 'limit', 1000);
|
||||
const offset = getQueryNumber(req, 'offset', 0);
|
||||
const search = getQueryString(req, 'search');
|
||||
|
||||
// Validate type
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
@@ -70,8 +76,9 @@ router.get('/:type', async (req: Request, res: Response) => {
|
||||
// Get a specific object by ID
|
||||
router.get('/:type/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const type = getParamString(req, 'type');
|
||||
const id = getParamString(req, 'id');
|
||||
const forceRefresh = getQueryString(req, 'refresh') === 'true';
|
||||
|
||||
// Validate type
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
@@ -101,8 +108,10 @@ router.get('/:type/:id', async (req: Request, res: Response) => {
|
||||
// Get related objects
|
||||
router.get('/:type/:id/related/:relationType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id, relationType } = req.params;
|
||||
const attribute = req.query.attribute as string | undefined;
|
||||
const type = getParamString(req, 'type');
|
||||
const id = getParamString(req, 'id');
|
||||
const relationType = getParamString(req, 'relationType');
|
||||
const attribute = getQueryString(req, 'attribute');
|
||||
|
||||
// Validate types
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
@@ -138,8 +147,10 @@ router.get('/:type/:id/related/:relationType', async (req: Request, res: Respons
|
||||
// Get objects referencing this object (inbound references)
|
||||
router.get('/:type/:id/referenced-by/:sourceType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id, sourceType } = req.params;
|
||||
const attribute = req.query.attribute as string | undefined;
|
||||
const type = getParamString(req, 'type');
|
||||
const id = getParamString(req, 'id');
|
||||
const sourceType = getParamString(req, 'sourceType');
|
||||
const attribute = getQueryString(req, 'attribute');
|
||||
|
||||
// Validate types
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
|
||||
117
backend/src/routes/profile.ts
Normal file
117
backend/src/routes/profile.ts
Normal 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;
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { requireAuth } from '../middleware/authorization.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get all reference data
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
196
backend/src/routes/roles.ts
Normal file
196
backend/src/routes/roles.ts
Normal 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;
|
||||
@@ -4,10 +4,15 @@ import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jir
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { jiraAssetsClient } from '../services/jiraAssetsClient.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and search permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('search'));
|
||||
|
||||
// Extended types for API response
|
||||
interface ObjectTypeWithLinks extends ObjectTypeDefinition {
|
||||
incomingLinks: Array<{
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { jiraAssetsService } from '../services/jiraAssets.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// CMDB free-text search endpoint (from cache)
|
||||
// All routes require authentication and search permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('search'));
|
||||
|
||||
// CMDB free-text search endpoint (using Jira API)
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
@@ -18,53 +23,37 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
|
||||
logger.info(`CMDB search request: query="${query}", limit=${limit}`);
|
||||
|
||||
// Search all types in cache
|
||||
const results = await cmdbService.searchAllTypes(query.trim(), { limit });
|
||||
|
||||
// Group results by object type
|
||||
const objectTypeMap = new Map<string, { id: number; name: string; iconUrl: string }>();
|
||||
const formattedResults = results.map(obj => {
|
||||
const typeName = obj._objectType || 'Unknown';
|
||||
|
||||
// Track unique object types
|
||||
if (!objectTypeMap.has(typeName)) {
|
||||
objectTypeMap.set(typeName, {
|
||||
id: objectTypeMap.size + 1,
|
||||
name: typeName,
|
||||
iconUrl: '', // Can be enhanced to include actual icons
|
||||
});
|
||||
// Set user token on jiraAssetsService (same logic as middleware)
|
||||
// Use OAuth token if available, otherwise user's PAT, otherwise service account token
|
||||
if (req.accessToken) {
|
||||
jiraAssetsService.setRequestToken(req.accessToken);
|
||||
} else if (req.user && 'id' in req.user) {
|
||||
const userSettings = (req as any).userSettings;
|
||||
if (userSettings?.jira_pat) {
|
||||
jiraAssetsService.setRequestToken(userSettings.jira_pat);
|
||||
} else if (config.jiraServiceAccountToken) {
|
||||
jiraAssetsService.setRequestToken(config.jiraServiceAccountToken);
|
||||
} else {
|
||||
jiraAssetsService.setRequestToken(null);
|
||||
}
|
||||
} else {
|
||||
jiraAssetsService.setRequestToken(config.jiraServiceAccountToken || null);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Jira API search (searches Key, Object Type, Label, Name, Description, Status)
|
||||
// The URL will be logged automatically by jiraAssetsService.searchCMDB()
|
||||
const response = await jiraAssetsService.searchCMDB(query.trim(), limit);
|
||||
|
||||
const objectType = objectTypeMap.get(typeName)!;
|
||||
// Clear token after request
|
||||
jiraAssetsService.clearRequestToken();
|
||||
|
||||
return {
|
||||
id: parseInt(obj.id, 10) || 0,
|
||||
key: obj.objectKey,
|
||||
label: obj.label,
|
||||
objectTypeId: objectType.id,
|
||||
avatarUrl: '',
|
||||
attributes: [], // Can be enhanced to include attributes
|
||||
};
|
||||
});
|
||||
|
||||
// Build response matching CMDBSearchResponse interface
|
||||
const response = {
|
||||
metadata: {
|
||||
count: formattedResults.length,
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
total: formattedResults.length,
|
||||
criteria: {
|
||||
query: query,
|
||||
type: 'global',
|
||||
schema: parseInt(config.jiraSchemaId, 10) || 0,
|
||||
},
|
||||
},
|
||||
objectTypes: Array.from(objectTypeMap.values()),
|
||||
results: formattedResults,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
// Clear token on error
|
||||
jiraAssetsService.clearRequestToken();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CMDB search failed', error);
|
||||
res.status(500).json({ error: 'Failed to search CMDB' });
|
||||
|
||||
105
backend/src/routes/userSettings.ts
Normal file
105
backend/src/routes/userSettings.ts
Normal 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
309
backend/src/routes/users.ts
Normal 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;
|
||||
@@ -1,13 +1,20 @@
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { getAuthDatabase } from './database/migrations.js';
|
||||
import { userService, type User } from './userService.js';
|
||||
import { roleService } from './roleService.js';
|
||||
|
||||
// Token storage (in production, use Redis or similar)
|
||||
interface UserSession {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: number;
|
||||
user: JiraUser;
|
||||
// Extended user interface for sessions
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
emailAddress?: string;
|
||||
avatarUrl?: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface JiraUser {
|
||||
@@ -17,19 +24,21 @@ export interface JiraUser {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// In-memory session store (replace with Redis in production)
|
||||
const sessionStore = new Map<string, UserSession>();
|
||||
interface DatabaseSession {
|
||||
id: string;
|
||||
user_id: number | null;
|
||||
auth_method: string;
|
||||
access_token: string | null;
|
||||
refresh_token: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
// Session cleanup interval (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of sessionStore.entries()) {
|
||||
if (session.expiresAt < now) {
|
||||
sessionStore.delete(sessionId);
|
||||
logger.debug(`Cleaned up expired session: ${sessionId.substring(0, 8)}...`);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
const isPostgres = (): boolean => {
|
||||
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
|
||||
};
|
||||
|
||||
// PKCE helpers for OAuth 2.0
|
||||
export function generateCodeVerifier(): string {
|
||||
@@ -59,8 +68,192 @@ setInterval(() => {
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
// Clean up expired sessions from database
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
await db.execute(
|
||||
'DELETE FROM sessions WHERE expires_at < ?',
|
||||
[now]
|
||||
);
|
||||
await db.close();
|
||||
} catch (error) {
|
||||
logger.error('Failed to clean up expired sessions:', error);
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
|
||||
class AuthService {
|
||||
// Get OAuth authorization URL
|
||||
/**
|
||||
* Get session duration in milliseconds
|
||||
*/
|
||||
private getSessionDuration(): number {
|
||||
const hours = parseInt(process.env.SESSION_DURATION_HOURS || '24', 10);
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session in the database
|
||||
*/
|
||||
private async createSession(
|
||||
userId: number | null,
|
||||
authMethod: 'local' | 'oauth' | 'jira-oauth',
|
||||
accessToken?: string,
|
||||
refreshToken?: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<string> {
|
||||
const db = getAuthDatabase();
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const now = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() + this.getSessionDuration()).toISOString();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
`INSERT INTO sessions (
|
||||
id, user_id, auth_method, access_token, refresh_token,
|
||||
expires_at, created_at, ip_address, user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
sessionId,
|
||||
userId,
|
||||
authMethod,
|
||||
accessToken || null,
|
||||
refreshToken || null,
|
||||
expiresAt,
|
||||
now,
|
||||
ipAddress || null,
|
||||
userAgent || null,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`Session created: ${sessionId.substring(0, 8)}... (${authMethod})`);
|
||||
return sessionId;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session from database
|
||||
*/
|
||||
private async getSessionFromDb(sessionId: string): Promise<DatabaseSession | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const session = await db.queryOne<DatabaseSession>(
|
||||
'SELECT * FROM sessions WHERE id = ?',
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date(session.expires_at) < new Date()) {
|
||||
await db.execute('DELETE FROM sessions WHERE id = ?', [sessionId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user from session (local auth)
|
||||
*/
|
||||
async getSessionUser(sessionId: string): Promise<SessionUser | null> {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
if (!session || !session.user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userService.getUserById(session.user_id);
|
||||
if (!user || !user.is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roles = await roleService.getUserRoles(session.user_id);
|
||||
const permissions = await roleService.getUserPermissions(session.user_id);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name || user.username,
|
||||
emailAddress: user.email,
|
||||
roles: roles.map(r => r.name),
|
||||
permissions: permissions.map(p => p.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local login (email or username/password)
|
||||
*/
|
||||
async localLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<{ sessionId: string; user: SessionUser }> {
|
||||
logger.debug(`[localLogin] Attempting login with identifier: ${email}`);
|
||||
|
||||
// Try email first, then username if email lookup fails
|
||||
let user = await userService.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
logger.debug(`[localLogin] Email lookup failed, trying username: ${email}`);
|
||||
// If email lookup failed, try username
|
||||
user = await userService.getUserByUsername(email);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[localLogin] User not found: ${email}`);
|
||||
throw new Error('Invalid email/username or password');
|
||||
}
|
||||
|
||||
logger.debug(`[localLogin] User found: ${user.email} (${user.username}), active: ${user.is_active}, verified: ${user.email_verified}`);
|
||||
|
||||
if (!user.is_active) {
|
||||
logger.warn(`[localLogin] Account is deactivated: ${user.email}`);
|
||||
throw new Error('Account is deactivated');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await userService.verifyPassword(password, user.password_hash);
|
||||
if (!isValid) {
|
||||
logger.warn(`[localLogin] Invalid password for user: ${user.email}`);
|
||||
throw new Error('Invalid email/username or password');
|
||||
}
|
||||
|
||||
logger.info(`[localLogin] Successful login: ${user.email} (${user.username})`);
|
||||
|
||||
// Update last login
|
||||
await userService.updateLastLogin(user.id);
|
||||
|
||||
// Create session
|
||||
const sessionId = await this.createSession(
|
||||
user.id,
|
||||
'local',
|
||||
undefined,
|
||||
undefined,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
const sessionUser = await this.getSessionUser(sessionId);
|
||||
if (!sessionUser) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
return { sessionId, user: sessionUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
*/
|
||||
getAuthorizationUrl(): { url: string; state: string } {
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
@@ -86,8 +279,15 @@ class AuthService {
|
||||
return { url: authUrl, state };
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
async exchangeCodeForTokens(code: string, state: string): Promise<{ sessionId: string; user: JiraUser }> {
|
||||
/**
|
||||
* Exchange authorization code for tokens (Jira OAuth)
|
||||
*/
|
||||
async exchangeCodeForTokens(
|
||||
code: string,
|
||||
state: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<{ sessionId: string; user: SessionUser | JiraUser }> {
|
||||
// Retrieve and validate state
|
||||
const flowData = authFlowStore.get(state);
|
||||
if (!flowData) {
|
||||
@@ -129,25 +329,52 @@ class AuthService {
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
const user = await this.fetchUserInfo(tokenData.access_token);
|
||||
// Fetch user info from Jira
|
||||
const jiraUser = await this.fetchUserInfo(tokenData.access_token);
|
||||
|
||||
// Create session
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const session: UserSession = {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: Date.now() + (tokenData.expires_in * 1000),
|
||||
user,
|
||||
};
|
||||
// Try to find local user by email
|
||||
let localUser: User | null = null;
|
||||
if (jiraUser.emailAddress) {
|
||||
localUser = await userService.getUserByEmail(jiraUser.emailAddress);
|
||||
}
|
||||
|
||||
sessionStore.set(sessionId, session);
|
||||
logger.info(`Created session for user: ${user.displayName}`);
|
||||
if (localUser) {
|
||||
// Link OAuth to existing local user
|
||||
const sessionId = await this.createSession(
|
||||
localUser.id,
|
||||
'jira-oauth',
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return { sessionId, user };
|
||||
const sessionUser = await this.getSessionUser(sessionId);
|
||||
if (!sessionUser) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
logger.info(`OAuth login successful for local user: ${localUser.email}`);
|
||||
return { sessionId, user: sessionUser };
|
||||
} else {
|
||||
// Create session without local user (OAuth-only)
|
||||
const sessionId = await this.createSession(
|
||||
null,
|
||||
'jira-oauth',
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
logger.info(`OAuth login successful for Jira user: ${jiraUser.displayName}`);
|
||||
return { sessionId, user: jiraUser };
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch current user info from Jira
|
||||
/**
|
||||
* Fetch current user info from Jira
|
||||
*/
|
||||
async fetchUserInfo(accessToken: string): Promise<JiraUser> {
|
||||
const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, {
|
||||
headers: {
|
||||
@@ -177,38 +404,54 @@ class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
// Get session by ID
|
||||
getSession(sessionId: string): UserSession | null {
|
||||
const session = sessionStore.get(sessionId);
|
||||
/**
|
||||
* Get session by ID
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<{ user: SessionUser | JiraUser; accessToken?: string } | null> {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (session.expiresAt < Date.now()) {
|
||||
sessionStore.delete(sessionId);
|
||||
return null;
|
||||
if (session.user_id) {
|
||||
// Local user session
|
||||
const user = await this.getSessionUser(sessionId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return { user };
|
||||
} else if (session.access_token) {
|
||||
// OAuth-only session
|
||||
const user = await this.fetchUserInfo(session.access_token);
|
||||
return { user, accessToken: session.access_token };
|
||||
}
|
||||
|
||||
return session;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get access token for a session
|
||||
getAccessToken(sessionId: string): string | null {
|
||||
const session = this.getSession(sessionId);
|
||||
return session?.accessToken || null;
|
||||
/**
|
||||
* Get access token for a session
|
||||
*/
|
||||
async getAccessToken(sessionId: string): Promise<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 {
|
||||
const session = this.getSession(sessionId);
|
||||
return session?.user || null;
|
||||
// This is a legacy method - use getSessionUser or getSession instead
|
||||
// For now, return null to maintain compatibility
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async refreshAccessToken(sessionId: string): Promise<boolean> {
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.refreshToken) {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
if (!session?.refresh_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -218,7 +461,7 @@ class AuthService {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.jiraOAuthClientId,
|
||||
client_secret: config.jiraOAuthClientSecret,
|
||||
refresh_token: session.refreshToken,
|
||||
refresh_token: session.refresh_token,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -241,16 +484,23 @@ class AuthService {
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Update session
|
||||
session.accessToken = tokenData.access_token;
|
||||
if (tokenData.refresh_token) {
|
||||
session.refreshToken = tokenData.refresh_token;
|
||||
// Update session in database
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
await db.execute(
|
||||
'UPDATE sessions SET access_token = ?, refresh_token = ?, expires_at = ? WHERE id = ?',
|
||||
[
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token || session.refresh_token,
|
||||
new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString(),
|
||||
sessionId,
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
session.expiresAt = Date.now() + (tokenData.expires_in * 1000);
|
||||
|
||||
sessionStore.set(sessionId, session);
|
||||
|
||||
logger.info(`Refreshed token for session: ${sessionId.substring(0, 8)}...`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Token refresh error:', error);
|
||||
@@ -258,28 +508,55 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Logout / destroy session
|
||||
logout(sessionId: string): boolean {
|
||||
const existed = sessionStore.has(sessionId);
|
||||
sessionStore.delete(sessionId);
|
||||
if (existed) {
|
||||
logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`);
|
||||
/**
|
||||
* Logout / destroy session
|
||||
*/
|
||||
async logout(sessionId: string): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
'DELETE FROM sessions WHERE id = ?',
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
if (result > 0) {
|
||||
logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
// Check if OAuth is enabled (jiraAuthMethod = 'oauth')
|
||||
/**
|
||||
* Check if OAuth is enabled (jiraAuthMethod = 'oauth')
|
||||
*/
|
||||
isOAuthEnabled(): boolean {
|
||||
return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
|
||||
}
|
||||
|
||||
// Check if using service account (PAT) mode (jiraAuthMethod = 'pat')
|
||||
/**
|
||||
* Check if using service account (PAT) mode (jiraAuthMethod = 'pat')
|
||||
*/
|
||||
isUsingServiceAccount(): boolean {
|
||||
return config.jiraAuthMethod === 'pat' && !!config.jiraPat;
|
||||
// Service account mode is when auth method is PAT but no local auth is enabled
|
||||
// and no users exist (checked elsewhere)
|
||||
return config.jiraAuthMethod === 'pat';
|
||||
}
|
||||
|
||||
// Get the configured authentication method
|
||||
getAuthMethod(): 'pat' | 'oauth' | 'none' {
|
||||
/**
|
||||
* Check if local auth is enabled
|
||||
*/
|
||||
isLocalAuthEnabled(): boolean {
|
||||
return process.env.LOCAL_AUTH_ENABLED === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured authentication method
|
||||
*/
|
||||
getAuthMethod(): 'pat' | 'oauth' | 'local' | 'none' {
|
||||
if (this.isLocalAuthEnabled()) return 'local';
|
||||
if (this.isOAuthEnabled()) return 'oauth';
|
||||
if (this.isUsingServiceAccount()) return 'pat';
|
||||
return 'none';
|
||||
@@ -287,4 +564,3 @@ class AuthService {
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
||||
|
||||
@@ -337,8 +337,9 @@ interface TavilySearchResponse {
|
||||
}
|
||||
|
||||
// Perform web search using Tavily API
|
||||
async function performWebSearch(query: string): Promise<string | null> {
|
||||
if (!config.enableWebSearch || !config.tavilyApiKey) {
|
||||
async function performWebSearch(query: string, tavilyApiKey?: string): Promise<string | null> {
|
||||
// Tavily API key must be provided - it's configured in user profile settings
|
||||
if (!tavilyApiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -349,7 +350,7 @@ async function performWebSearch(query: string): Promise<string | null> {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: config.tavilyApiKey,
|
||||
api_key: apiKey,
|
||||
query: query,
|
||||
search_depth: 'basic',
|
||||
include_answer: true,
|
||||
@@ -610,49 +611,56 @@ class AIService {
|
||||
private openaiClient: OpenAI | null = null;
|
||||
|
||||
constructor() {
|
||||
if (config.anthropicApiKey) {
|
||||
this.anthropicClient = new Anthropic({
|
||||
apiKey: config.anthropicApiKey,
|
||||
});
|
||||
logger.info('Anthropic (Claude) API configured');
|
||||
} else {
|
||||
logger.warn('Anthropic API key not configured. Claude classification will not work.');
|
||||
}
|
||||
|
||||
if (config.openaiApiKey) {
|
||||
this.openaiClient = new OpenAI({
|
||||
apiKey: config.openaiApiKey,
|
||||
});
|
||||
logger.info('OpenAI API configured');
|
||||
} else {
|
||||
logger.warn('OpenAI API key not configured. OpenAI classification will not work.');
|
||||
}
|
||||
// AI API keys are now configured per-user in their profile settings
|
||||
// Global clients are not initialized - clients are created per-request with user keys
|
||||
logger.info('AI service initialized - API keys must be configured in user profile settings');
|
||||
}
|
||||
|
||||
// Check if a specific provider is configured
|
||||
// Note: This now checks if user has configured the provider in their settings
|
||||
// The actual check should be done per-request with user API keys
|
||||
isProviderConfigured(provider: AIProvider): boolean {
|
||||
if (provider === 'claude') {
|
||||
return this.anthropicClient !== null;
|
||||
} else {
|
||||
return this.openaiClient !== null;
|
||||
}
|
||||
// Always return true - configuration is checked per-request with user keys
|
||||
// This maintains backward compatibility for the isConfigured() method
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get available providers
|
||||
getAvailableProviders(): AIProvider[] {
|
||||
const providers: AIProvider[] = [];
|
||||
if (this.anthropicClient) providers.push('claude');
|
||||
if (this.openaiClient) providers.push('openai');
|
||||
return providers;
|
||||
// Providers are available if users have configured API keys in their settings
|
||||
// This method is kept for backward compatibility but always returns both providers
|
||||
// The actual availability is checked per-request with user API keys
|
||||
return ['claude', 'openai'];
|
||||
}
|
||||
|
||||
async classifyApplication(application: ApplicationDetails, provider: AIProvider = config.defaultAIProvider): Promise<AISuggestion> {
|
||||
// Validate provider
|
||||
if (provider === 'claude' && !this.anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.');
|
||||
async classifyApplication(
|
||||
application: ApplicationDetails,
|
||||
provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings
|
||||
userApiKeys?: { anthropic?: string; openai?: string; tavily?: string }
|
||||
): Promise<AISuggestion> {
|
||||
// Use user API keys if provided, otherwise use global config
|
||||
// API keys must be provided via userApiKeys - they're configured in user profile settings
|
||||
const anthropicKey = userApiKeys?.anthropic;
|
||||
const openaiKey = userApiKeys?.openai;
|
||||
const tavilyKey = userApiKeys?.tavily;
|
||||
|
||||
// Create clients with user keys - API keys must be provided via userApiKeys
|
||||
let anthropicClient: Anthropic | null = null;
|
||||
let openaiClient: OpenAI | null = null;
|
||||
|
||||
if (anthropicKey) {
|
||||
anthropicClient = new Anthropic({ apiKey: anthropicKey });
|
||||
}
|
||||
if (provider === 'openai' && !this.openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.');
|
||||
if (openaiKey) {
|
||||
openaiClient = new OpenAI({ apiKey: openaiKey });
|
||||
}
|
||||
|
||||
// Validate provider - API keys must be provided via userApiKeys
|
||||
if (provider === 'claude' && !anthropicKey) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
if (provider === 'openai' && !openaiKey) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
// Check if web search is needed
|
||||
@@ -661,7 +669,7 @@ class AIService {
|
||||
logger.info(`Insufficient information detected for ${application.name}, performing web search...`);
|
||||
const supplierPart = application.supplierProduct ? `${application.supplierProduct} ` : '';
|
||||
const searchQuery = `${application.name} ${supplierPart}healthcare software`.trim();
|
||||
webSearchResults = await performWebSearch(searchQuery);
|
||||
webSearchResults = await performWebSearch(searchQuery, tavilyKey);
|
||||
if (webSearchResults) {
|
||||
logger.info(`Web search completed for ${application.name}`);
|
||||
} else {
|
||||
@@ -719,8 +727,12 @@ class AIService {
|
||||
let responseText: string;
|
||||
|
||||
if (provider === 'claude') {
|
||||
// Use Claude (Anthropic)
|
||||
const message = await this.anthropicClient!.messages.create({
|
||||
// Use Claude (Anthropic) - client created from user API key
|
||||
if (!anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
const client = anthropicClient;
|
||||
const message = await client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
@@ -737,8 +749,12 @@ class AIService {
|
||||
}
|
||||
responseText = textBlock.text.trim();
|
||||
} else {
|
||||
// Use OpenAI
|
||||
const completion = await this.openaiClient!.chat.completions.create({
|
||||
// Use OpenAI - client created from user API key
|
||||
if (!openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
const client = openaiClient;
|
||||
const completion = await client.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
@@ -884,7 +900,7 @@ class AIService {
|
||||
async classifyBatch(
|
||||
applications: ApplicationDetails[],
|
||||
onProgress?: (completed: number, total: number) => void,
|
||||
provider: AIProvider = config.defaultAIProvider
|
||||
provider: AIProvider = 'claude' // Default to 'claude', but should be provided from user settings
|
||||
): Promise<Map<string, AISuggestion>> {
|
||||
const results = new Map<string, AISuggestion>();
|
||||
const total = applications.length;
|
||||
@@ -936,8 +952,9 @@ class AIService {
|
||||
if (provider) {
|
||||
return this.isProviderConfigured(provider);
|
||||
}
|
||||
// Return true if at least one provider is configured
|
||||
return this.anthropicClient !== null || this.openaiClient !== null;
|
||||
// Configuration is checked per-request with user API keys
|
||||
// This method is kept for backward compatibility
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the prompt that would be sent to the AI for a given application
|
||||
@@ -1011,14 +1028,30 @@ class AIService {
|
||||
application: ApplicationDetails,
|
||||
userMessage: string,
|
||||
conversationId?: string,
|
||||
provider: AIProvider = config.defaultAIProvider
|
||||
provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings
|
||||
userApiKeys?: { anthropic?: string; openai?: string; tavily?: string }
|
||||
): Promise<ChatResponse> {
|
||||
// Validate provider
|
||||
if (provider === 'claude' && !this.anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.');
|
||||
// API keys must be provided via userApiKeys - they're configured in user profile settings
|
||||
const anthropicKey = userApiKeys?.anthropic;
|
||||
const openaiKey = userApiKeys?.openai;
|
||||
|
||||
// Create clients with user keys
|
||||
let anthropicClient: Anthropic | null = null;
|
||||
let openaiClient: OpenAI | null = null;
|
||||
|
||||
if (anthropicKey) {
|
||||
anthropicClient = new Anthropic({ apiKey: anthropicKey });
|
||||
}
|
||||
if (provider === 'openai' && !this.openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.');
|
||||
if (openaiKey) {
|
||||
openaiClient = new OpenAI({ apiKey: openaiKey });
|
||||
}
|
||||
|
||||
// Validate provider - API keys must be provided via userApiKeys
|
||||
if (provider === 'claude' && !anthropicKey) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
if (provider === 'openai' && !openaiKey) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
// Get or create conversation
|
||||
@@ -1062,7 +1095,11 @@ class AIService {
|
||||
const systemMessage = aiMessages.find(m => m.role === 'system');
|
||||
const otherMessages = aiMessages.filter(m => m.role !== 'system');
|
||||
|
||||
const response = await this.anthropicClient!.messages.create({
|
||||
if (!anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
const response = await anthropicClient.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
system: systemMessage?.content || '',
|
||||
@@ -1075,7 +1112,11 @@ class AIService {
|
||||
assistantContent = response.content[0].type === 'text' ? response.content[0].text : '';
|
||||
} else {
|
||||
// OpenAI
|
||||
const response = await this.openaiClient!.chat.completions.create({
|
||||
if (!openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
const response = await openaiClient.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 4096,
|
||||
messages: aiMessages.map(m => ({
|
||||
|
||||
@@ -79,6 +79,13 @@ class CMDBService {
|
||||
): Promise<T | null> {
|
||||
// Force refresh: search Jira by key
|
||||
if (options?.forceRefresh) {
|
||||
// Check if Jira token is configured before making API call
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
logger.debug(`CMDBService: Jira PAT not configured, cannot search for ${typeName} with key ${objectKey}`);
|
||||
// Return cached version if available
|
||||
return await cacheStore.getObjectByKey<T>(typeName, objectKey) || null;
|
||||
}
|
||||
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) return null;
|
||||
|
||||
@@ -235,7 +242,15 @@ class CMDBService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 3. Send update to Jira
|
||||
// 3. Check if user PAT is configured before sending update (write operations require user PAT)
|
||||
if (!jiraAssetsClient.hasUserToken()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Send update to Jira
|
||||
const success = await jiraAssetsClient.updateObject(id, payload);
|
||||
|
||||
if (!success) {
|
||||
@@ -271,6 +286,14 @@ class CMDBService {
|
||||
id: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<UpdateResult> {
|
||||
// Check if user PAT is configured before sending update (write operations require user PAT)
|
||||
if (!jiraAssetsClient.hasUserToken()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.buildUpdatePayload(typeName, updates);
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ import { calculateRequiredEffortWithMinMax } from './effortCalculation.js';
|
||||
import { calculateApplicationCompleteness } from './dataCompletenessConfig.js';
|
||||
|
||||
// Determine if we should use real Jira Assets or mock data
|
||||
const useJiraAssets = !!(config.jiraPat && config.jiraSchemaId);
|
||||
// Jira PAT is now configured per-user, so we check if schema is configured
|
||||
// The actual PAT is provided per-request via middleware
|
||||
const useJiraAssets = !!config.jiraSchemaId;
|
||||
|
||||
if (useJiraAssets) {
|
||||
logger.info('DataService: Using CMDB cache layer with Jira Assets API');
|
||||
@@ -121,9 +123,40 @@ async function lookupReferences<T extends CMDBObject>(
|
||||
|
||||
/**
|
||||
* Convert ObjectReference to ReferenceValue format used by frontend
|
||||
* Try to enrich with description from jiraAssetsService cache if available
|
||||
* If not in cache or cache entry has no description, fetch it async
|
||||
*/
|
||||
function toReferenceValue(ref: ObjectReference | null | undefined): ReferenceValue | null {
|
||||
async function toReferenceValue(ref: ObjectReference | null | undefined): Promise<ReferenceValue | null> {
|
||||
if (!ref) return null;
|
||||
|
||||
// Try to get enriched ReferenceValue from jiraAssetsService cache (includes description if available)
|
||||
const enriched = useJiraAssets ? jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId) : null;
|
||||
|
||||
if (enriched && enriched.description) {
|
||||
// Use enriched value with description
|
||||
return enriched;
|
||||
}
|
||||
|
||||
// Cache miss or no description - fetch it async if using Jira Assets
|
||||
if (useJiraAssets && enriched && !enriched.description) {
|
||||
// We have a cached value but it lacks description - fetch it
|
||||
const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId);
|
||||
if (fetched) {
|
||||
return fetched;
|
||||
}
|
||||
// If fetch failed, return the cached value anyway
|
||||
return enriched;
|
||||
}
|
||||
|
||||
if (useJiraAssets) {
|
||||
// Cache miss - fetch it
|
||||
const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId);
|
||||
if (fetched) {
|
||||
return fetched;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to basic conversion without description (if fetch failed or not using Jira Assets)
|
||||
return {
|
||||
objectId: ref.objectId,
|
||||
key: ref.objectKey,
|
||||
@@ -188,25 +221,49 @@ function extractDisplayValue(value: unknown): string | null {
|
||||
* References are now stored as ObjectReference objects directly (not IDs)
|
||||
*/
|
||||
async function toApplicationDetails(app: ApplicationComponent): Promise<ApplicationDetails> {
|
||||
// Debug logging for confluenceSpace from cache
|
||||
logger.info(`[toApplicationDetails] Converting cached object ${app.objectKey || app.id} to ApplicationDetails`);
|
||||
logger.info(`[toApplicationDetails] confluenceSpace from cache: ${app.confluenceSpace} (type: ${typeof app.confluenceSpace})`);
|
||||
|
||||
// Handle confluenceSpace - it can be a string (URL) or number (legacy), convert to string
|
||||
const confluenceSpaceValue = app.confluenceSpace !== null && app.confluenceSpace !== undefined
|
||||
? (typeof app.confluenceSpace === 'string' ? app.confluenceSpace : String(app.confluenceSpace))
|
||||
: null;
|
||||
|
||||
// Ensure factor caches are loaded for factor value lookup
|
||||
await ensureFactorCaches();
|
||||
|
||||
// Convert ObjectReference to ReferenceValue format
|
||||
const governanceModel = toReferenceValue(app.ictGovernanceModel);
|
||||
// Note: applicationManagementSubteam and applicationManagementTeam are not in the generated schema
|
||||
// They are only available when fetching directly from Jira API (via jiraAssetsClient)
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM);
|
||||
const hostingType = toReferenceValue(app.applicationComponentHostingType);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
const platform = toReferenceValue(app.platform);
|
||||
const organisation = toReferenceValue(app.organisation);
|
||||
const businessImportance = toReferenceValue(app.businessImportance);
|
||||
// Fetch descriptions async if not in cache
|
||||
// Use Promise.all to fetch all reference values in parallel for better performance
|
||||
const [
|
||||
governanceModel,
|
||||
applicationSubteam,
|
||||
applicationTeam,
|
||||
applicationType,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
hostingType,
|
||||
businessImpactAnalyse,
|
||||
platform,
|
||||
organisation,
|
||||
businessImportance,
|
||||
] = await Promise.all([
|
||||
toReferenceValue(app.ictGovernanceModel),
|
||||
toReferenceValue((app as any).applicationManagementSubteam),
|
||||
toReferenceValue((app as any).applicationManagementTeam),
|
||||
toReferenceValue(app.applicationManagementApplicationType),
|
||||
toReferenceValue(app.applicationManagementHosting),
|
||||
toReferenceValue(app.applicationManagementTAM),
|
||||
toReferenceValue(app.applicationComponentHostingType),
|
||||
toReferenceValue(app.businessImpactAnalyse),
|
||||
toReferenceValue(app.platform),
|
||||
toReferenceValue(app.organisation),
|
||||
toReferenceValue(app.businessImportance),
|
||||
]);
|
||||
|
||||
// Look up factor values from cached factor objects (same as toMinimalDetailsForEffort)
|
||||
// Also include descriptions from cache if available
|
||||
let dynamicsFactor: ReferenceValue | null = null;
|
||||
if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') {
|
||||
const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId);
|
||||
@@ -215,6 +272,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
key: app.applicationManagementDynamicsFactor.objectKey,
|
||||
name: app.applicationManagementDynamicsFactor.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: factorObj?.description ?? undefined, // Include description from cache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +284,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
key: app.applicationManagementComplexityFactor.objectKey,
|
||||
name: app.applicationManagementComplexityFactor.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: factorObj?.description ?? undefined, // Include description from cache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +296,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
key: app.applicationManagementNumberOfUsers.objectKey,
|
||||
name: app.applicationManagementNumberOfUsers.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,22 +346,27 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
// Override
|
||||
overrideFTE: app.applicationManagementOverrideFTE ?? null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
|
||||
// Enterprise Architect reference
|
||||
reference: app.reference || null,
|
||||
|
||||
// Confluence Space (URL string)
|
||||
confluenceSpace: confluenceSpaceValue,
|
||||
};
|
||||
|
||||
// Calculate data completeness percentage
|
||||
// Convert ApplicationListItem-like structure to format expected by completeness calculator
|
||||
// Convert ApplicationDetails-like structure to format expected by completeness calculator
|
||||
const appForCompleteness = {
|
||||
...result,
|
||||
organisation: organisation?.name || null,
|
||||
applicationFunctions: result.applicationFunctions,
|
||||
status: result.status,
|
||||
applicationFunctions: applicationFunctions,
|
||||
status: (app.status || 'In Production') as ApplicationStatus,
|
||||
businessImpactAnalyse: businessImpactAnalyse,
|
||||
hostingType: hostingType,
|
||||
supplierProduct: result.supplierProduct,
|
||||
businessOwner: result.businessOwner,
|
||||
systemOwner: result.systemOwner,
|
||||
functionalApplicationManagement: result.functionalApplicationManagement,
|
||||
technicalApplicationManagement: result.technicalApplicationManagement,
|
||||
supplierProduct: extractLabel(app.supplierProduct),
|
||||
businessOwner: extractLabel(app.businessOwner),
|
||||
systemOwner: extractLabel(app.systemOwner),
|
||||
functionalApplicationManagement: app.functionalApplicationManagement || null,
|
||||
technicalApplicationManagement: extractLabel(app.technicalApplicationManagement),
|
||||
governanceModel: governanceModel,
|
||||
applicationType: applicationType,
|
||||
applicationManagementHosting: applicationManagementHosting,
|
||||
@@ -314,8 +379,55 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
|
||||
|
||||
return {
|
||||
...result,
|
||||
id: app.id,
|
||||
key: app.objectKey,
|
||||
name: app.label,
|
||||
description: app.description || null,
|
||||
status: (app.status || 'In Production') as ApplicationStatus,
|
||||
searchReference: app.searchReference || null,
|
||||
|
||||
// Organization info
|
||||
organisation: organisation?.name || null,
|
||||
businessOwner: extractLabel(app.businessOwner),
|
||||
systemOwner: extractLabel(app.systemOwner),
|
||||
functionalApplicationManagement: app.functionalApplicationManagement || null,
|
||||
technicalApplicationManagement: extractLabel(app.technicalApplicationManagement),
|
||||
technicalApplicationManagementPrimary: extractDisplayValue(app.technicalApplicationManagementPrimary),
|
||||
technicalApplicationManagementSecondary: extractDisplayValue(app.technicalApplicationManagementSecondary),
|
||||
|
||||
// Technical info
|
||||
medischeTechniek: app.medischeTechniek || false,
|
||||
technischeArchitectuur: app.technischeArchitectuurTA || null,
|
||||
supplierProduct: extractLabel(app.supplierProduct),
|
||||
|
||||
// Classification
|
||||
applicationFunctions,
|
||||
businessImportance: businessImportance?.name || null,
|
||||
businessImpactAnalyse,
|
||||
hostingType,
|
||||
|
||||
// Application Management
|
||||
governanceModel,
|
||||
applicationType,
|
||||
applicationSubteam,
|
||||
applicationTeam,
|
||||
dynamicsFactor,
|
||||
complexityFactor,
|
||||
numberOfUsers,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
platform,
|
||||
|
||||
// Override
|
||||
overrideFTE: app.applicationManagementOverrideFTE ?? null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
|
||||
|
||||
// Enterprise Architect reference
|
||||
reference: app.reference || null,
|
||||
|
||||
// Confluence Space (URL string)
|
||||
confluenceSpace: confluenceSpaceValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,11 +469,18 @@ function clearFactorCaches(): void {
|
||||
* This avoids the overhead of toApplicationDetails while providing enough data for effort calculation
|
||||
* Note: ensureFactorCaches() must be called before using this function
|
||||
*/
|
||||
function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetails {
|
||||
const governanceModel = toReferenceValue(app.ictGovernanceModel);
|
||||
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
async function toMinimalDetailsForEffort(app: ApplicationComponent): Promise<ApplicationDetails> {
|
||||
const [
|
||||
governanceModel,
|
||||
applicationType,
|
||||
businessImpactAnalyse,
|
||||
applicationManagementHosting,
|
||||
] = await Promise.all([
|
||||
toReferenceValue(app.ictGovernanceModel),
|
||||
toReferenceValue(app.applicationManagementApplicationType),
|
||||
toReferenceValue(app.businessImpactAnalyse),
|
||||
toReferenceValue(app.applicationManagementHosting),
|
||||
]);
|
||||
|
||||
// Look up factor values from cached factor objects
|
||||
let dynamicsFactor: ReferenceValue | null = null;
|
||||
@@ -394,6 +513,7 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
|
||||
key: app.applicationManagementNumberOfUsers.objectKey,
|
||||
name: app.applicationManagementNumberOfUsers.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,29 +554,45 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
|
||||
/**
|
||||
* Convert ApplicationComponent to ApplicationListItem (lighter weight, for lists)
|
||||
*/
|
||||
function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
async function toApplicationListItem(app: ApplicationComponent): Promise<ApplicationListItem> {
|
||||
// Use direct ObjectReference conversion instead of lookups
|
||||
const governanceModel = toReferenceValue(app.ictGovernanceModel);
|
||||
const dynamicsFactor = toReferenceValue(app.applicationManagementDynamicsFactor);
|
||||
const complexityFactor = toReferenceValue(app.applicationManagementComplexityFactor);
|
||||
// Note: Team/Subteam fields are not in generated schema, use type assertion
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
|
||||
const platform = toReferenceValue(app.platform);
|
||||
// Fetch all reference values in parallel
|
||||
const [
|
||||
governanceModel,
|
||||
dynamicsFactor,
|
||||
complexityFactor,
|
||||
applicationSubteam,
|
||||
applicationTeam,
|
||||
applicationType,
|
||||
platform,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
businessImpactAnalyse,
|
||||
minimalDetails,
|
||||
] = await Promise.all([
|
||||
toReferenceValue(app.ictGovernanceModel),
|
||||
toReferenceValue(app.applicationManagementDynamicsFactor),
|
||||
toReferenceValue(app.applicationManagementComplexityFactor),
|
||||
toReferenceValue((app as any).applicationManagementSubteam),
|
||||
toReferenceValue((app as any).applicationManagementTeam),
|
||||
toReferenceValue(app.applicationManagementApplicationType),
|
||||
toReferenceValue(app.platform),
|
||||
toReferenceValue(app.applicationManagementHosting),
|
||||
toReferenceValue(app.applicationManagementTAM),
|
||||
toReferenceValue(app.businessImpactAnalyse),
|
||||
toMinimalDetailsForEffort(app),
|
||||
]);
|
||||
|
||||
const applicationFunctions = toReferenceValues(app.applicationFunction);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
|
||||
// Calculate effort using minimal details
|
||||
const minimalDetails = toMinimalDetailsForEffort(app);
|
||||
const effortResult = calculateRequiredEffortWithMinMax(minimalDetails);
|
||||
|
||||
const result: ApplicationListItem = {
|
||||
id: app.id,
|
||||
key: app.objectKey,
|
||||
name: app.label,
|
||||
searchReference: app.searchReference || null,
|
||||
status: app.status as ApplicationStatus | null,
|
||||
applicationFunctions,
|
||||
governanceModel,
|
||||
@@ -477,12 +613,17 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
|
||||
// Calculate data completeness percentage
|
||||
// Convert ApplicationListItem to format expected by completeness calculator
|
||||
const [organisationRef, hostingTypeRef] = await Promise.all([
|
||||
toReferenceValue(app.organisation),
|
||||
toReferenceValue(app.applicationComponentHostingType),
|
||||
]);
|
||||
|
||||
const appForCompleteness = {
|
||||
organisation: toReferenceValue(app.organisation)?.name || null,
|
||||
organisation: organisationRef?.name || null,
|
||||
applicationFunctions: result.applicationFunctions,
|
||||
status: result.status,
|
||||
businessImpactAnalyse: result.businessImpactAnalyse,
|
||||
hostingType: toReferenceValue(app.applicationComponentHostingType),
|
||||
hostingType: hostingTypeRef,
|
||||
supplierProduct: app.supplierProduct?.label || null,
|
||||
businessOwner: app.businessOwner?.label || null,
|
||||
systemOwner: app.systemOwner?.label || null,
|
||||
@@ -494,7 +635,7 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
applicationManagementTAM: result.applicationManagementTAM,
|
||||
dynamicsFactor: result.dynamicsFactor,
|
||||
complexityFactor: result.complexityFactor,
|
||||
numberOfUsers: toReferenceValue(app.applicationManagementNumberOfUsers),
|
||||
numberOfUsers: await toReferenceValue(app.applicationManagementNumberOfUsers),
|
||||
};
|
||||
|
||||
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
|
||||
@@ -677,8 +818,8 @@ export const dataService = {
|
||||
// Ensure factor caches are loaded for effort calculation
|
||||
await ensureFactorCaches();
|
||||
|
||||
// Convert to list items (synchronous now)
|
||||
const applications = paginatedApps.map(toApplicationListItem);
|
||||
// Convert to list items (async now to fetch descriptions)
|
||||
const applications = await Promise.all(paginatedApps.map(toApplicationListItem));
|
||||
|
||||
return {
|
||||
applications,
|
||||
@@ -1180,8 +1321,8 @@ export const dataService = {
|
||||
for (const app of apps) {
|
||||
// Get team from application (via subteam lookup if needed)
|
||||
let team: ReferenceValue | null = null;
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
const applicationSubteam = await toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = await toReferenceValue((app as any).applicationManagementTeam);
|
||||
|
||||
// Prefer direct team assignment, otherwise try to get from subteam
|
||||
if (applicationTeam) {
|
||||
@@ -1209,7 +1350,7 @@ export const dataService = {
|
||||
// Get complexity factor value
|
||||
if (app.applicationManagementComplexityFactor && typeof app.applicationManagementComplexityFactor === 'object') {
|
||||
const factorObj = complexityFactorCache?.get(app.applicationManagementComplexityFactor.objectId);
|
||||
if (factorObj?.factor !== undefined) {
|
||||
if (factorObj?.factor !== undefined && factorObj.factor !== null) {
|
||||
metrics.complexityValues.push(factorObj.factor);
|
||||
}
|
||||
}
|
||||
@@ -1217,14 +1358,14 @@ export const dataService = {
|
||||
// Get dynamics factor value
|
||||
if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') {
|
||||
const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId);
|
||||
if (factorObj?.factor !== undefined) {
|
||||
if (factorObj?.factor !== undefined && factorObj.factor !== null) {
|
||||
metrics.dynamicsValues.push(factorObj.factor);
|
||||
}
|
||||
}
|
||||
|
||||
// Get BIA value
|
||||
if (app.businessImpactAnalyse) {
|
||||
const biaRef = toReferenceValue(app.businessImpactAnalyse);
|
||||
const biaRef = await toReferenceValue(app.businessImpactAnalyse);
|
||||
if (biaRef) {
|
||||
const biaNum = biaToNumeric(biaRef.name);
|
||||
if (biaNum !== null) metrics.biaValues.push(biaNum);
|
||||
@@ -1233,7 +1374,7 @@ export const dataService = {
|
||||
|
||||
// Get governance maturity
|
||||
if (app.ictGovernanceModel) {
|
||||
const govRef = toReferenceValue(app.ictGovernanceModel);
|
||||
const govRef = await toReferenceValue(app.ictGovernanceModel);
|
||||
if (govRef) {
|
||||
const maturity = governanceToMaturity(govRef.name);
|
||||
if (maturity !== null) metrics.governanceValues.push(maturity);
|
||||
@@ -1286,6 +1427,10 @@ export const dataService = {
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
if (!useJiraAssets) return true;
|
||||
// Only test connection if token is configured
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
return false;
|
||||
}
|
||||
return jiraAssetsClient.testConnection();
|
||||
},
|
||||
|
||||
@@ -1372,7 +1517,7 @@ export const dataService = {
|
||||
if (!app.id || !app.label) continue;
|
||||
|
||||
// Extract Business Importance from app object
|
||||
const businessImportanceRef = toReferenceValue(app.businessImportance);
|
||||
const businessImportanceRef = await toReferenceValue(app.businessImportance);
|
||||
const businessImportanceName = businessImportanceRef?.name || null;
|
||||
|
||||
// Normalize Business Importance
|
||||
@@ -1395,7 +1540,7 @@ export const dataService = {
|
||||
}
|
||||
|
||||
// Extract BIA from app object
|
||||
const businessImpactAnalyseRef = toReferenceValue(app.businessImpactAnalyse);
|
||||
const businessImpactAnalyseRef = await toReferenceValue(app.businessImpactAnalyse);
|
||||
|
||||
// Normalize BIA Class
|
||||
let biaClass: string | null = null;
|
||||
|
||||
@@ -16,8 +16,9 @@ const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Create a database adapter based on environment variables
|
||||
* @param allowClose - If false, the adapter won't be closed when close() is called (for singletons)
|
||||
*/
|
||||
export function createDatabaseAdapter(dbType?: string, dbPath?: string): DatabaseAdapter {
|
||||
export function createDatabaseAdapter(dbType?: string, dbPath?: string, allowClose: boolean = true): DatabaseAdapter {
|
||||
const type = dbType || process.env.DATABASE_TYPE || 'sqlite';
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
@@ -33,11 +34,11 @@ export function createDatabaseAdapter(dbType?: string, dbPath?: string): Databas
|
||||
|
||||
const constructedUrl = `postgresql://${user}:${password}@${host}:${port}/${name}${ssl}`;
|
||||
logger.info('Creating PostgreSQL adapter with constructed connection string');
|
||||
return new PostgresAdapter(constructedUrl);
|
||||
return new PostgresAdapter(constructedUrl, allowClose);
|
||||
}
|
||||
|
||||
logger.info('Creating PostgreSQL adapter');
|
||||
return new PostgresAdapter(databaseUrl);
|
||||
return new PostgresAdapter(databaseUrl, allowClose);
|
||||
}
|
||||
|
||||
// Default to SQLite
|
||||
|
||||
532
backend/src/services/database/migrations.ts
Normal file
532
backend/src/services/database/migrations.ts
Normal 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;
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import type { DatabaseAdapter } from './interface.js';
|
||||
export class PostgresAdapter implements DatabaseAdapter {
|
||||
private pool: Pool;
|
||||
private connectionString: string;
|
||||
private isClosed: boolean = false;
|
||||
private allowClose: boolean = true; // Set to false for singleton instances
|
||||
|
||||
constructor(connectionString: string) {
|
||||
constructor(connectionString: string, allowClose: boolean = true) {
|
||||
this.connectionString = connectionString;
|
||||
this.allowClose = allowClose;
|
||||
this.pool = new Pool({
|
||||
connectionString,
|
||||
max: 20, // Maximum number of clients in the pool
|
||||
@@ -124,7 +127,23 @@ export class PostgresAdapter implements DatabaseAdapter {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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> {
|
||||
|
||||
@@ -18,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Path to the configuration file (v25)
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
|
||||
// Cache for loaded configuration
|
||||
let cachedConfigV25: EffortCalculationConfigV25 | null = null;
|
||||
@@ -275,12 +275,6 @@ export function calculateRequiredEffortApplicationManagementV25(
|
||||
breakdown.businessImpactAnalyse = biaClass;
|
||||
breakdown.applicationManagementHosting = applicationManagementHosting;
|
||||
|
||||
logger.debug(`=== Effort Calculation v25 ===`);
|
||||
logger.debug(`Regiemodel: ${regieModelCode} (${governanceModelRaw})`);
|
||||
logger.debug(`Application Type: ${applicationType}`);
|
||||
logger.debug(`BIA: ${biaClass} (${businessImpactAnalyseRaw})`);
|
||||
logger.debug(`Hosting: ${applicationManagementHosting}`);
|
||||
|
||||
// Level 1: Find Regiemodel configuration
|
||||
if (!regieModelCode || !config.regiemodellen[regieModelCode]) {
|
||||
breakdown.errors.push(`Geen configuratie gevonden voor regiemodel: ${governanceModelRaw || 'niet ingesteld'}`);
|
||||
@@ -413,10 +407,6 @@ export function calculateRequiredEffortApplicationManagementV25(
|
||||
breakdown.hoursPerMonth = breakdown.hoursPerYear / 12;
|
||||
breakdown.hoursPerWeek = breakdown.hoursPerYear / NET_WORK_WEEKS;
|
||||
|
||||
logger.debug(`Base FTE: ${breakdown.baseEffort} (${breakdown.baseEffortMin} - ${breakdown.baseEffortMax})`);
|
||||
logger.debug(`Final FTE: ${finalEffort}`);
|
||||
logger.debug(`Hours/year: ${breakdown.hoursPerYear}`);
|
||||
|
||||
return { finalEffort, breakdown };
|
||||
|
||||
} catch (error) {
|
||||
|
||||
289
backend/src/services/emailService.ts
Normal file
289
backend/src/services/emailService.ts
Normal 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();
|
||||
115
backend/src/services/encryptionService.ts
Normal file
115
backend/src/services/encryptionService.ts
Normal 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();
|
||||
@@ -50,6 +50,11 @@ const ATTRIBUTE_NAMES = {
|
||||
APPLICATION_MANAGEMENT_HOSTING: 'Application Management - Hosting',
|
||||
APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM',
|
||||
TECHNISCHE_ARCHITECTUUR: 'Technische Architectuur (TA)',
|
||||
REFERENCE: 'Reference',
|
||||
CONFLUENCE_SPACE: 'Confluence Space',
|
||||
SUPPLIER_TECHNICAL: 'Supplier Technical',
|
||||
SUPPLIER_IMPLEMENTATION: 'Supplier Implementation',
|
||||
SUPPLIER_CONSULTANCY: 'Supplier Consultancy',
|
||||
};
|
||||
|
||||
// Jira Data Center (Insight) uses different API endpoints than Jira Cloud (Assets)
|
||||
@@ -99,6 +104,8 @@ class JiraAssetsService {
|
||||
private numberOfUsersCache: Map<string, ReferenceValue> | null = null;
|
||||
// Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue)
|
||||
private referenceObjectCache: Map<string, ReferenceValue> = new Map();
|
||||
// Pending requests cache: prevents duplicate API calls for the same object (key: objectId -> Promise<ReferenceValue>)
|
||||
private pendingReferenceRequests: Map<string, Promise<ReferenceValue | null>> = new Map();
|
||||
// Cache: Team dashboard data
|
||||
private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null;
|
||||
private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -119,8 +126,8 @@ class JiraAssetsService {
|
||||
// Try both API paths - Insight (Data Center) and Assets (Cloud)
|
||||
this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||
this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`;
|
||||
// Jira PAT is now configured per-user - default headers will use request token
|
||||
this.defaultHeaders = {
|
||||
Authorization: `Bearer ${config.jiraPat}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
@@ -136,9 +143,14 @@ class JiraAssetsService {
|
||||
this.requestToken = null;
|
||||
}
|
||||
|
||||
// Get headers with the appropriate token (user token takes precedence)
|
||||
// Get headers with the appropriate token (user token from middleware, fallback to service account)
|
||||
private get headers(): Record<string, string> {
|
||||
const token = this.requestToken || config.jiraPat;
|
||||
// Token must be provided via setRequestToken() from middleware
|
||||
// It comes from user's profile settings, OAuth session, or service account token (fallback)
|
||||
const token = this.requestToken || config.jiraServiceAccountToken;
|
||||
if (!token) {
|
||||
throw new Error('Jira PAT not configured. Please configure it in your user settings or set JIRA_SERVICE_ACCOUNT_TOKEN in .env.');
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -153,12 +165,15 @@ class JiraAssetsService {
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
retryCount: number = 0
|
||||
): Promise<T> {
|
||||
const url = `${this.getBaseUrl()}${endpoint}`;
|
||||
const maxRetries = 3;
|
||||
const retryableStatusCodes = [502, 503, 504]; // Bad Gateway, Service Unavailable, Gateway Timeout
|
||||
|
||||
try {
|
||||
logger.debug(`Jira API request: ${options.method || 'GET'} ${url}`);
|
||||
logger.debug(`Jira API request: ${options.method || 'GET'} ${url}${retryCount > 0 ? ` (retry ${retryCount}/${maxRetries})` : ''}`);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -169,11 +184,29 @@ class JiraAssetsService {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Jira API error: ${response.status} - ${errorText}`);
|
||||
const error = new Error(`Jira API error: ${response.status} - ${errorText}`);
|
||||
|
||||
// Retry on temporary gateway errors
|
||||
if (retryableStatusCodes.includes(response.status) && retryCount < maxRetries) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
|
||||
logger.warn(`Jira API temporary error ${response.status}, retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.request<T>(endpoint, options, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} catch (error) {
|
||||
// Retry on network errors (timeouts, connection errors) if we haven't exceeded max retries
|
||||
if (retryCount < maxRetries && error instanceof TypeError && error.message.includes('fetch')) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);
|
||||
logger.warn(`Jira API network error, retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.request<T>(endpoint, options, retryCount + 1);
|
||||
}
|
||||
|
||||
logger.error(`Jira API request failed: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -285,19 +318,117 @@ class JiraAssetsService {
|
||||
attrSchema?: Map<number, string>
|
||||
): string | null {
|
||||
const attr = this.getAttributeByName(obj, attributeName, attrSchema);
|
||||
if (!attr || attr.objectAttributeValues.length === 0) {
|
||||
return null;
|
||||
|
||||
// Enhanced logging for Reference field
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
if (!attr) {
|
||||
// Log all available attributes with their names and IDs for debugging
|
||||
const availableAttrs = obj.attributes?.map(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`;
|
||||
}).join(', ') || 'none';
|
||||
logger.warn(`Reference attribute "${ATTRIBUTE_NAMES.REFERENCE}" not found for object ${obj.objectKey}.`);
|
||||
logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`);
|
||||
|
||||
// Try to find similar attribute names (case-insensitive, partial matches)
|
||||
const similarAttrs = obj.attributes?.filter(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || '';
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
return lowerAttrName.includes('reference') || lowerAttrName.includes('enterprise') || lowerAttrName.includes('architect');
|
||||
});
|
||||
if (similarAttrs && similarAttrs.length > 0) {
|
||||
logger.warn(`Found similar attributes that might be the Reference field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (attr.objectAttributeValues.length === 0) {
|
||||
logger.warn(`Reference attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`);
|
||||
return null;
|
||||
}
|
||||
logger.info(`Reference attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`);
|
||||
} else if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) {
|
||||
// Enhanced logging for Confluence Space field
|
||||
if (!attr) {
|
||||
// Log all available attributes with their names and IDs for debugging
|
||||
const availableAttrs = obj.attributes?.map(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`;
|
||||
}).join(', ') || 'none';
|
||||
logger.warn(`Confluence Space attribute "${ATTRIBUTE_NAMES.CONFLUENCE_SPACE}" not found for object ${obj.objectKey}.`);
|
||||
logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`);
|
||||
|
||||
// Try to find similar attribute names (case-insensitive, partial matches)
|
||||
const similarAttrs = obj.attributes?.filter(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || '';
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
return lowerAttrName.includes('confluence') || lowerAttrName.includes('space');
|
||||
});
|
||||
if (similarAttrs && similarAttrs.length > 0) {
|
||||
logger.warn(`Found similar attributes that might be the Confluence Space field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (attr.objectAttributeValues.length === 0) {
|
||||
logger.warn(`Confluence Space attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`);
|
||||
return null;
|
||||
}
|
||||
logger.info(`Confluence Space attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`);
|
||||
} else {
|
||||
if (!attr || attr.objectAttributeValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const value = attr.objectAttributeValues[0];
|
||||
// For select/status fields, use displayValue; for text fields, use value
|
||||
let result: string | null = null;
|
||||
if (value.displayValue !== undefined && value.displayValue !== null) {
|
||||
return value.displayValue;
|
||||
result = String(value.displayValue); // Ensure it's a string
|
||||
} else if (value.value !== undefined && value.value !== null) {
|
||||
result = String(value.value); // Ensure it's a string
|
||||
}
|
||||
if (value.value !== undefined && value.value !== null) {
|
||||
return value.value;
|
||||
|
||||
// Enhanced logging for Reference field
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
logger.info(`Reference field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`);
|
||||
}
|
||||
return null;
|
||||
|
||||
// Enhanced logging for Confluence Space field
|
||||
if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) {
|
||||
logger.info(`Confluence Space field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`);
|
||||
logger.info(`Confluence Space raw attribute: ${JSON.stringify(attr, null, 2)}`);
|
||||
}
|
||||
|
||||
// Check if result is the string "undefined" (which shouldn't happen but could)
|
||||
if (result === 'undefined') {
|
||||
logger.warn(`Reference field has string value "undefined" for object ${obj.objectKey}. This indicates a problem with the data.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize empty/whitespace-only strings to null
|
||||
// This handles: empty strings, whitespace-only, Unicode whitespace, zero-width chars
|
||||
if (result !== null && typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
// Check if empty after trim, or only whitespace (including Unicode whitespace)
|
||||
if (trimmed === '' || /^\s*$/.test(result) || trimmed.replace(/[\u200B-\u200D\uFEFF]/g, '') === '') {
|
||||
// Log for Reference field to help debug
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
logger.debug(`Normalizing empty Reference field to null for object ${obj.objectKey}. Original value: "${result}" (length: ${result.length})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Final logging for Reference field
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
logger.info(`Reference field final result for object ${obj.objectKey}: "${result}"`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get attribute value by attribute ID (useful when we know the ID but not the name)
|
||||
@@ -416,6 +547,146 @@ class JiraAssetsService {
|
||||
}
|
||||
|
||||
// Get reference value with schema fallback for attribute lookup
|
||||
// Helper to extract description from a JiraAssetsObject (same logic as getReferenceObjects)
|
||||
private getDescriptionFromObject(refObj: JiraAssetsObject, refObjSchema?: Map<number, string>): string | undefined {
|
||||
if (!refObj) return undefined;
|
||||
|
||||
if (!refObj.attributes || refObj.attributes.length === 0) {
|
||||
logger.error(`getDescriptionFromObject: Object ${refObj.objectKey} has no attributes array`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// First try: Extract Description attribute using schema lookup (try multiple possible attribute names)
|
||||
// Note: For Description fields, we need to extract the 'value' property from the attribute value object
|
||||
let rawDescription: string | null = null;
|
||||
|
||||
// Try getAttributeValueWithSchema first (handles value.value and value.displayValue)
|
||||
rawDescription = this.getAttributeValueWithSchema(refObj, 'Description', refObjSchema)
|
||||
|| this.getAttributeValueWithSchema(refObj, 'Omschrijving', refObjSchema)
|
||||
|| this.getAttributeValueWithSchema(refObj, 'Beschrijving', refObjSchema);
|
||||
|
||||
// Second try: If not found via schema, search directly in attributes by name
|
||||
// Also check for partial matches and alternative names
|
||||
if (!rawDescription && refObj.attributes && refObj.attributes.length > 0) {
|
||||
for (const attr of refObj.attributes) {
|
||||
// Get attribute name from schema if available, otherwise from objectTypeAttribute
|
||||
let attrName = '';
|
||||
if (refObjSchema && refObjSchema.has(attr.objectTypeAttributeId)) {
|
||||
attrName = refObjSchema.get(attr.objectTypeAttributeId)!;
|
||||
} else if (attr.objectTypeAttribute?.name) {
|
||||
attrName = attr.objectTypeAttribute.name;
|
||||
}
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
|
||||
// Check if this attribute name matches description-related names (exact and partial)
|
||||
const isDescriptionAttr =
|
||||
lowerAttrName === 'description' ||
|
||||
lowerAttrName === 'omschrijving' ||
|
||||
lowerAttrName === 'beschrijving' ||
|
||||
lowerAttrName.includes('description') ||
|
||||
lowerAttrName.includes('omschrijving') ||
|
||||
lowerAttrName.includes('beschrijving');
|
||||
|
||||
if (isDescriptionAttr) {
|
||||
// Found description attribute - extract value
|
||||
if (attr.objectAttributeValues && attr.objectAttributeValues.length > 0) {
|
||||
const attrValue = attr.objectAttributeValues[0];
|
||||
if (typeof attrValue === 'string') {
|
||||
rawDescription = attrValue as string;
|
||||
break;
|
||||
} else if (attrValue && typeof attrValue === 'object') {
|
||||
// Try value property first (most common for text fields)
|
||||
if ('value' in attrValue && typeof attrValue.value === 'string' && attrValue.value.trim().length > 0) {
|
||||
rawDescription = attrValue.value as string;
|
||||
break;
|
||||
}
|
||||
// Try displayValue as fallback (for select fields)
|
||||
if ('displayValue' in attrValue && typeof attrValue.displayValue === 'string' && attrValue.displayValue.trim().length > 0) {
|
||||
rawDescription = attrValue.displayValue as string;
|
||||
break;
|
||||
}
|
||||
// Try other possible property names
|
||||
const attrValueObj = attrValue as Record<string, unknown>;
|
||||
for (const key of ['text', 'content', 'html', 'markup']) {
|
||||
const value = attrValueObj[key];
|
||||
if (value && typeof value === 'string') {
|
||||
const strValue = value as string;
|
||||
if (strValue.trim().length > 0) {
|
||||
rawDescription = strValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rawDescription) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third try: Check ALL attributes for any long text values (might be description stored elsewhere)
|
||||
// Only do this if we still haven't found a description and there are attributes
|
||||
if (!rawDescription && refObj.attributes && refObj.attributes.length > 0) {
|
||||
for (const attr of refObj.attributes) {
|
||||
// Skip attributes we already checked
|
||||
let attrName = '';
|
||||
if (refObjSchema && refObjSchema.has(attr.objectTypeAttributeId)) {
|
||||
attrName = refObjSchema.get(attr.objectTypeAttributeId)!;
|
||||
} else if (attr.objectTypeAttribute?.name) {
|
||||
attrName = attr.objectTypeAttribute.name;
|
||||
}
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
|
||||
// Skip standard fields (Key, Name, Created, Updated, etc.)
|
||||
if (['key', 'name', 'label', 'created', 'updated', 'id'].includes(lowerAttrName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.objectAttributeValues && attr.objectAttributeValues.length > 0) {
|
||||
const attrValue: unknown = attr.objectAttributeValues[0];
|
||||
let potentialDescription: string | null = null;
|
||||
|
||||
if (typeof attrValue === 'string') {
|
||||
if (attrValue.trim().length > 50) {
|
||||
// Long string might be a description
|
||||
potentialDescription = attrValue;
|
||||
}
|
||||
} else if (attrValue !== null && attrValue !== undefined && typeof attrValue === 'object') {
|
||||
// Check value property
|
||||
const attrValueObj = attrValue as Record<string, unknown>;
|
||||
if ('value' in attrValueObj && typeof attrValueObj.value === 'string') {
|
||||
if (attrValueObj.value.trim().length > 50) {
|
||||
potentialDescription = attrValueObj.value;
|
||||
}
|
||||
} else if ('displayValue' in attrValueObj && typeof attrValueObj.displayValue === 'string') {
|
||||
if (attrValueObj.displayValue.trim().length > 50) {
|
||||
potentialDescription = attrValueObj.displayValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a long text and it looks like a description (not just a short label or ID)
|
||||
if (potentialDescription && potentialDescription.trim().length > 50 && !potentialDescription.match(/^[A-Z0-9-_]+$/)) {
|
||||
rawDescription = potentialDescription;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawDescription) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Strip HTML tags from description (same as getReferenceObjects)
|
||||
if (typeof rawDescription === 'string') {
|
||||
const description = stripHtmlTags(rawDescription);
|
||||
return description || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getReferenceValueWithSchema(
|
||||
obj: JiraAssetsObject,
|
||||
attributeName: string,
|
||||
@@ -428,53 +699,188 @@ class JiraAssetsService {
|
||||
|
||||
const value = attr.objectAttributeValues[0];
|
||||
if (value.referencedObject) {
|
||||
return {
|
||||
objectId: value.referencedObject.id.toString(),
|
||||
key: value.referencedObject.objectKey,
|
||||
name: value.referencedObject.label,
|
||||
};
|
||||
// Try to get description from the embedded referenced object
|
||||
// Embedded referenced objects might not have all attributes, so we might need to fetch separately
|
||||
const embeddedRefObj = value.referencedObject;
|
||||
|
||||
// Check factor caches first (they always have descriptions if available)
|
||||
const objectId = embeddedRefObj.id.toString();
|
||||
if (this.dynamicsFactorsCache?.has(objectId)) {
|
||||
return this.dynamicsFactorsCache.get(objectId)!;
|
||||
}
|
||||
if (this.complexityFactorsCache?.has(objectId)) {
|
||||
return this.complexityFactorsCache.get(objectId)!;
|
||||
}
|
||||
if (this.numberOfUsersCache?.has(objectId)) {
|
||||
return this.numberOfUsersCache.get(objectId)!;
|
||||
}
|
||||
if (this.applicationFunctionsCache?.has(objectId)) {
|
||||
return this.applicationFunctionsCache.get(objectId)!;
|
||||
}
|
||||
|
||||
// Check cache - only use if it has description
|
||||
const cached = this.referenceObjectCache.get(embeddedRefObj.objectKey);
|
||||
if (cached && cached.description) {
|
||||
return cached;
|
||||
} else if (cached && !cached.description) {
|
||||
// Remove from cache so we fetch it again
|
||||
this.referenceObjectCache.delete(embeddedRefObj.objectKey);
|
||||
this.referenceObjectCache.delete(embeddedRefObj.id.toString());
|
||||
}
|
||||
|
||||
// Check if there's already a pending request for this object
|
||||
const pendingRequest = this.pendingReferenceRequests.get(objectId);
|
||||
if (pendingRequest) {
|
||||
// Wait for the existing request instead of creating a duplicate
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Create a new request and store it in pending requests
|
||||
const fetchPromise = (async (): Promise<ReferenceValue | null> => {
|
||||
// For embedded referenced objects, we need to fetch the full object to get description
|
||||
let description: string | undefined = undefined;
|
||||
|
||||
try {
|
||||
await this.detectApiType();
|
||||
const url = `/object/${embeddedRefObj.id}?includeAttributes=true&includeAttributesDeep=1`;
|
||||
const refObj = await this.request<JiraAssetsObject>(url);
|
||||
|
||||
if (refObj) {
|
||||
if (!refObj.attributes || refObj.attributes.length === 0) {
|
||||
logger.error(`getReferenceValueWithSchema: Object ${refObj.objectKey} has NO ATTRIBUTES despite includeAttributes=true!`);
|
||||
} else {
|
||||
// Fetch attribute schema for the referenced object type
|
||||
let refObjSchema: Map<number, string> | undefined;
|
||||
const refObjectTypeId = refObj.objectType?.id;
|
||||
const refObjectTypeName = refObj.objectType?.name || '';
|
||||
|
||||
if (refObjectTypeId) {
|
||||
try {
|
||||
if (this.attributeSchemaCache.has(refObjectTypeName)) {
|
||||
refObjSchema = this.attributeSchemaCache.get(refObjectTypeName);
|
||||
} else {
|
||||
refObjSchema = await this.fetchAttributeSchema(refObjectTypeId);
|
||||
if (refObjSchema) {
|
||||
this.attributeSchemaCache.set(refObjectTypeName, refObjSchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Schema fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description from the full object
|
||||
description = this.getDescriptionFromObject(refObj, refObjSchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`getReferenceValueWithSchema: Could not fetch full object for ${embeddedRefObj.objectKey} (id: ${embeddedRefObj.id})`, error);
|
||||
}
|
||||
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: embeddedRefObj.id.toString(),
|
||||
key: embeddedRefObj.objectKey,
|
||||
name: embeddedRefObj.label,
|
||||
...(description && { description }),
|
||||
};
|
||||
|
||||
// Always cache it for future use (even if description is undefined, so we don't fetch again)
|
||||
this.referenceObjectCache.set(embeddedRefObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(embeddedRefObj.id.toString(), refValue);
|
||||
|
||||
return refValue;
|
||||
})();
|
||||
|
||||
// Store the pending request
|
||||
this.pendingReferenceRequests.set(objectId, fetchPromise);
|
||||
|
||||
try {
|
||||
const result = await fetchPromise;
|
||||
return result;
|
||||
} finally {
|
||||
// Remove from pending requests when done (success or failure)
|
||||
this.pendingReferenceRequests.delete(objectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if referencedObject is missing but we have a value, try to fetch it separately
|
||||
// Note: value.value might be an object key (e.g., "GOV-A") or an object ID
|
||||
if (value.value && !value.referencedObject) {
|
||||
// Check cache first
|
||||
// Check cache first - only use if it has description
|
||||
const cached = this.referenceObjectCache.get(value.value);
|
||||
if (cached) {
|
||||
if (cached && cached.description) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch the referenced object by its key or ID
|
||||
// First try as object key (most common)
|
||||
let refObj: JiraAssetsObject | null = null;
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${value.value}`);
|
||||
} catch (keyError) {
|
||||
// If that fails, try as object ID
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${parseInt(value.value, 10)}`);
|
||||
} catch (idError) {
|
||||
// Both failed, log and continue
|
||||
logger.debug(`getReferenceValueWithSchema: Could not fetch referenced object for value "${value.value}" (tried as key and ID) for attribute "${attributeName}" on object ${obj.objectKey}`);
|
||||
}
|
||||
// Check if there's already a pending request for this value
|
||||
const pendingRequest = this.pendingReferenceRequests.get(value.value);
|
||||
if (pendingRequest) {
|
||||
// Wait for the existing request instead of creating a duplicate
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Create a new request and store it in pending requests
|
||||
const fetchPromise = (async (): Promise<ReferenceValue | null> => {
|
||||
if (!value.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (refObj) {
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: refObj.id.toString(),
|
||||
key: refObj.objectKey,
|
||||
name: refObj.label,
|
||||
};
|
||||
// Cache it for future use
|
||||
this.referenceObjectCache.set(value.value, refValue);
|
||||
this.referenceObjectCache.set(refObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(refObj.id.toString(), refValue);
|
||||
return refValue;
|
||||
try {
|
||||
// Try to fetch the referenced object by its key or ID
|
||||
// First try as object key (most common)
|
||||
let refObj: JiraAssetsObject | null = null;
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${value.value}`);
|
||||
} catch (keyError) {
|
||||
// If that fails, try as object ID
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${parseInt(value.value, 10)}`);
|
||||
} catch (idError) {
|
||||
// Both failed, continue
|
||||
}
|
||||
}
|
||||
|
||||
if (refObj) {
|
||||
// Fetch attribute schema for the referenced object type to get description
|
||||
let refObjSchema: Map<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) {
|
||||
// If fetching fails, log but don't throw - just return null
|
||||
logger.debug(`getReferenceValueWithSchema: Failed to fetch referenced object ${value.value} for attribute "${attributeName}" on object ${obj.objectKey}`, error);
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Store the pending request
|
||||
this.pendingReferenceRequests.set(value.value, fetchPromise);
|
||||
|
||||
try {
|
||||
const result = await fetchPromise;
|
||||
return result;
|
||||
} finally {
|
||||
// Remove from pending requests when done (success or failure)
|
||||
this.pendingReferenceRequests.delete(value.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,10 +977,6 @@ class JiraAssetsService {
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema),
|
||||
]);
|
||||
|
||||
if (!governanceModel && obj.objectKey) {
|
||||
logger.debug(`parseJiraObject: No governanceModel found for ${obj.objectKey}. Attribute name: ${ATTRIBUTE_NAMES.GOVERNANCE_MODEL}`);
|
||||
}
|
||||
|
||||
const dynamicsFactor = this.enrichWithFactor(dynamicsFactorRaw, this.dynamicsFactorsCache);
|
||||
const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache);
|
||||
const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache);
|
||||
@@ -651,6 +1053,9 @@ class JiraAssetsService {
|
||||
|
||||
// Parse Jira object for detail view (full details) with optional schema for attribute lookup
|
||||
private async parseJiraObjectDetails(obj: JiraAssetsObject, attrSchema?: Map<number, string>): Promise<ApplicationDetails> {
|
||||
logger.info(`[parseJiraObjectDetails] Parsing object ${obj.objectKey || obj.id} - this is called when fetching directly from Jira API`);
|
||||
logger.info(`[parseJiraObjectDetails] Object has ${obj.attributes?.length || 0} attributes`);
|
||||
|
||||
const appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema);
|
||||
const rawDescription = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.DESCRIPTION, attrSchema);
|
||||
|
||||
@@ -670,6 +1075,9 @@ class JiraAssetsService {
|
||||
platform,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
supplierTechnical,
|
||||
supplierImplementation,
|
||||
supplierConsultancy,
|
||||
] = await Promise.all([
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema),
|
||||
@@ -682,6 +1090,9 @@ class JiraAssetsService {
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_TECHNICAL, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_IMPLEMENTATION, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_CONSULTANCY, attrSchema),
|
||||
]);
|
||||
|
||||
// Enrich with factors
|
||||
@@ -689,6 +1100,19 @@ class JiraAssetsService {
|
||||
const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache);
|
||||
const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache);
|
||||
|
||||
// Get Team via Subteam reference if Subteam exists
|
||||
let applicationTeam: ReferenceValue | null = null;
|
||||
if (applicationSubteam?.objectId) {
|
||||
try {
|
||||
// Use the subteam-to-team mapping cache
|
||||
const subteamToTeamMapping = await this.getSubteamToTeamMapping();
|
||||
applicationTeam = subteamToTeamMapping.get(applicationSubteam.objectId) || null;
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to fetch Team via Subteam ${applicationSubteam.objectId}:`, error);
|
||||
// Continue without Team if lookup fails
|
||||
}
|
||||
}
|
||||
|
||||
const applicationDetails: ApplicationDetails = {
|
||||
id: obj.id.toString(),
|
||||
key: obj.objectKey,
|
||||
@@ -716,7 +1140,7 @@ class JiraAssetsService {
|
||||
governanceModel,
|
||||
// "Application Management - Subteam" on ApplicationComponent references Subteam objects
|
||||
applicationSubteam,
|
||||
applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent
|
||||
applicationTeam, // Team is looked up via Subteam
|
||||
applicationType,
|
||||
platform,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -729,6 +1153,97 @@ class JiraAssetsService {
|
||||
})(),
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
reference: (() => {
|
||||
// Try multiple possible attribute names for Reference field
|
||||
const possibleNames = [
|
||||
ATTRIBUTE_NAMES.REFERENCE, // 'Reference'
|
||||
'Enterprise Architect Reference',
|
||||
'EA Reference',
|
||||
'Enterprise Architect',
|
||||
'EA GUID',
|
||||
'GUID',
|
||||
'Reference (EA)',
|
||||
];
|
||||
|
||||
let refValue: string | null = null;
|
||||
let foundAttrName: string | null = null;
|
||||
|
||||
// Try each possible name
|
||||
for (const attrName of possibleNames) {
|
||||
const value = this.getAttributeValueWithSchema(obj, attrName, attrSchema);
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
refValue = value;
|
||||
foundAttrName = attrName;
|
||||
logger.info(`Reference field found for object ${obj.objectKey} using attribute name "${attrName}": "${refValue}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, try manual search through all attributes
|
||||
if (refValue === null || refValue === undefined) {
|
||||
logger.warn(`Reference field not found using standard names for object ${obj.objectKey}. Searching all attributes...`);
|
||||
const allAttrs = obj.attributes || [];
|
||||
|
||||
// Try to find by partial name match
|
||||
let refAttr = allAttrs.find(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name?.toLowerCase() || '';
|
||||
const schemaLower = schemaName?.toLowerCase() || '';
|
||||
|
||||
return attrName.includes('reference') ||
|
||||
schemaLower.includes('reference') ||
|
||||
attrName.includes('enterprise') ||
|
||||
attrName.includes('architect') ||
|
||||
attrName.includes('guid') ||
|
||||
attrName.includes('ea');
|
||||
});
|
||||
|
||||
if (refAttr) {
|
||||
foundAttrName = refAttr.objectTypeAttribute?.name || 'unknown';
|
||||
logger.warn(`Reference attribute found manually: "${foundAttrName}" (ID: ${refAttr.objectTypeAttributeId})`);
|
||||
logger.warn(`Attribute values: ${JSON.stringify(refAttr.objectAttributeValues, null, 2)}`);
|
||||
|
||||
// Try to extract value manually
|
||||
if (refAttr.objectAttributeValues.length > 0) {
|
||||
const value = refAttr.objectAttributeValues[0];
|
||||
const manualValue = value.displayValue !== undefined && value.displayValue !== null
|
||||
? String(value.displayValue)
|
||||
: value.value !== undefined && value.value !== null
|
||||
? String(value.value)
|
||||
: null;
|
||||
|
||||
if (manualValue && manualValue.trim() !== '' && manualValue !== 'undefined') {
|
||||
refValue = manualValue.trim();
|
||||
logger.warn(`Manual extraction found value: "${refValue}" from attribute "${foundAttrName}"`);
|
||||
} else {
|
||||
logger.warn(`Manual extraction found empty/invalid value: "${manualValue}" (type: ${typeof manualValue})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log all available attributes for debugging
|
||||
logger.warn(`Reference attribute not found in object ${obj.objectKey}.`);
|
||||
logger.warn(`Available attributes (${allAttrs.length}):`);
|
||||
allAttrs.forEach(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
const hasValues = a.objectAttributeValues?.length > 0;
|
||||
logger.warn(` - ${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'}, hasValues: ${hasValues})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (refValue) {
|
||||
logger.info(`Reference field final result for object ${obj.objectKey}: "${refValue}" (from attribute: ${foundAttrName || 'standard'})`);
|
||||
} else {
|
||||
logger.warn(`Reference field is null/undefined for object ${obj.objectKey} after all attempts.`);
|
||||
}
|
||||
|
||||
return refValue;
|
||||
})(),
|
||||
confluenceSpace: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.CONFLUENCE_SPACE, attrSchema),
|
||||
supplierTechnical,
|
||||
supplierImplementation,
|
||||
supplierConsultancy,
|
||||
};
|
||||
|
||||
// Calculate required effort application management
|
||||
@@ -1201,34 +1716,6 @@ class JiraAssetsService {
|
||||
logger.info(`Cached attribute schema for ${objectType}: ${attrSchema.size} attributes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log raw API response for first object to debug attribute structure
|
||||
logger.info(`=== Debug: Reference data for ${objectType} ===`);
|
||||
logger.info(`Object: id=${firstObj.id}, key=${firstObj.objectKey}, label=${firstObj.label}`);
|
||||
logger.info(`ObjectType: id=${firstObj.objectType?.id}, name=${firstObj.objectType?.name}`);
|
||||
logger.info(`Attributes count: ${firstObj.attributes?.length || 0}`);
|
||||
if (firstObj.attributes && firstObj.attributes.length > 0) {
|
||||
firstObj.attributes.forEach((attr, idx) => {
|
||||
let attrInfo: string;
|
||||
if (attr.objectTypeAttribute) {
|
||||
attrInfo = `name="${attr.objectTypeAttribute.name}", typeAttrId=${attr.objectTypeAttribute.id}`;
|
||||
} else {
|
||||
// Try to get name from schema
|
||||
const schemaName = attrSchema?.get(attr.objectTypeAttributeId);
|
||||
attrInfo = `(objectTypeAttribute MISSING, attrId=${attr.objectTypeAttributeId}, schemaName="${schemaName || 'unknown'}")`;
|
||||
}
|
||||
const values = attr.objectAttributeValues.map(v => {
|
||||
if (v.displayValue) return `displayValue="${v.displayValue}"`;
|
||||
if (v.value) return `value="${v.value}"`;
|
||||
if (v.referencedObject) return `ref:${v.referencedObject.label}`;
|
||||
return 'empty';
|
||||
}).join(', ');
|
||||
logger.info(` Attr[${idx}]: ${attrInfo} = [${values}]`);
|
||||
});
|
||||
} else {
|
||||
logger.info(` No attributes array or empty!`);
|
||||
}
|
||||
logger.info(`=== End Debug ===`);
|
||||
}
|
||||
|
||||
const results = response.objectEntries.map((obj) => {
|
||||
@@ -1314,11 +1801,6 @@ class JiraAssetsService {
|
||||
return result;
|
||||
});
|
||||
|
||||
// Log first result for debugging
|
||||
if (results.length > 0) {
|
||||
logger.debug(`Reference data for ${objectType}: first item = ${JSON.stringify(results[0])}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get reference objects for type: ${objectType}`, error);
|
||||
@@ -1546,6 +2028,118 @@ class JiraAssetsService {
|
||||
return this.getReferenceObjects('Application Management - TAM');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enriched ReferenceValue with description from cache (if available)
|
||||
* This allows other services to enrich ObjectReferences with descriptions
|
||||
*/
|
||||
getEnrichedReferenceValue(objectKey: string, objectId?: string): ReferenceValue | null {
|
||||
// Try by objectKey first (most common)
|
||||
const cachedByKey = this.referenceObjectCache.get(objectKey);
|
||||
if (cachedByKey) {
|
||||
return cachedByKey;
|
||||
}
|
||||
|
||||
// Try by objectId if provided
|
||||
if (objectId) {
|
||||
const cachedById = this.referenceObjectCache.get(objectId);
|
||||
if (cachedById) {
|
||||
return cachedById;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and enrich ReferenceValue with description (async version that fetches if needed)
|
||||
* This method will:
|
||||
* 1. Check cache first - if found WITH description, return it
|
||||
* 2. If cache miss OR no description, fetch the full object from Jira
|
||||
* 3. Extract description and cache the result
|
||||
* 4. Return the enriched ReferenceValue
|
||||
*/
|
||||
async fetchEnrichedReferenceValue(objectKey: string, objectId?: string): Promise<ReferenceValue | null> {
|
||||
// Check cache first - if we have a cached value WITH description, return it immediately
|
||||
const cachedByKey = this.referenceObjectCache.get(objectKey);
|
||||
let cachedById: ReferenceValue | undefined = undefined;
|
||||
|
||||
if (cachedByKey && cachedByKey.description) {
|
||||
return cachedByKey;
|
||||
}
|
||||
|
||||
if (objectId) {
|
||||
cachedById = this.referenceObjectCache.get(objectId);
|
||||
if (cachedById && cachedById.description) {
|
||||
return cachedById;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or no description - fetch the full object
|
||||
const objectIdToFetch = objectId || objectKey;
|
||||
if (!objectIdToFetch) {
|
||||
logger.warn(`fetchEnrichedReferenceValue: No objectId or objectKey provided`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/object/${objectIdToFetch}?includeAttributes=true&includeAttributesDeep=1`;
|
||||
const refObj = await this.request<JiraAssetsObject>(url);
|
||||
|
||||
if (!refObj) {
|
||||
logger.warn(`fetchEnrichedReferenceValue: No object returned for ${objectKey}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch attribute schema for the referenced object type
|
||||
let refObjSchema: Map<number, string> | undefined;
|
||||
const refObjectTypeId = refObj.objectType?.id;
|
||||
const refObjectTypeName = refObj.objectType?.name || '';
|
||||
|
||||
if (refObjectTypeId) {
|
||||
try {
|
||||
if (this.attributeSchemaCache.has(refObjectTypeName)) {
|
||||
refObjSchema = this.attributeSchemaCache.get(refObjectTypeName);
|
||||
} else {
|
||||
refObjSchema = await this.fetchAttributeSchema(refObjectTypeId);
|
||||
if (refObjSchema) {
|
||||
this.attributeSchemaCache.set(refObjectTypeName, refObjSchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Schema fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description from the full object
|
||||
const description = this.getDescriptionFromObject(refObj, refObjSchema);
|
||||
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: refObj.id.toString(),
|
||||
key: refObj.objectKey,
|
||||
name: refObj.label,
|
||||
...(description && { description }),
|
||||
};
|
||||
|
||||
// Cache it for future use (by both key and ID)
|
||||
this.referenceObjectCache.set(refObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(refObj.id.toString(), refValue);
|
||||
|
||||
return refValue;
|
||||
} catch (error) {
|
||||
logger.warn(`fetchEnrichedReferenceValue: Could not fetch object ${objectKey} (id: ${objectIdToFetch})`, error);
|
||||
|
||||
// If we had a cached value without description, return it anyway
|
||||
if (cachedByKey) {
|
||||
return cachedByKey;
|
||||
}
|
||||
if (objectId && cachedById) {
|
||||
return cachedById;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.detectApiType();
|
||||
@@ -2396,7 +2990,7 @@ class JiraAssetsService {
|
||||
`attributes=Key,Object+Type,Label,Name,Description,Status&` +
|
||||
`offset=0&limit=${limit}`;
|
||||
|
||||
logger.info(`CMDB search: ${searchUrl}`);
|
||||
logger.info(`CMDB search API call - Query: "${query}", URL: ${searchUrl}`);
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
|
||||
@@ -49,7 +49,8 @@ class JiraAssetsClient {
|
||||
private baseUrl: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
private isDataCenter: boolean | null = null;
|
||||
private requestToken: string | null = null;
|
||||
private serviceAccountToken: string | null = null; // Service account token from .env (for read operations)
|
||||
private requestToken: string | null = null; // User PAT from profile settings (for write operations)
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||
@@ -58,17 +59,18 @@ class JiraAssetsClient {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// Add PAT authentication if configured
|
||||
if (config.jiraAuthMethod === 'pat' && config.jiraPat) {
|
||||
this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`;
|
||||
}
|
||||
// Initialize service account token from config (for read operations)
|
||||
this.serviceAccountToken = config.jiraServiceAccountToken || null;
|
||||
|
||||
// User PAT is configured per-user in profile settings
|
||||
// Authorization header is set per-request via setRequestToken()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Request Token Management (for user-context requests)
|
||||
// ==========================================================================
|
||||
|
||||
setRequestToken(token: string): void {
|
||||
setRequestToken(token: string | null): void {
|
||||
this.requestToken = token;
|
||||
}
|
||||
|
||||
@@ -76,6 +78,21 @@ class JiraAssetsClient {
|
||||
this.requestToken = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is configured for read operations
|
||||
* Uses service account token (primary) or user PAT (fallback)
|
||||
*/
|
||||
hasToken(): boolean {
|
||||
return !!(this.serviceAccountToken || this.requestToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user PAT is configured for write operations
|
||||
*/
|
||||
hasUserToken(): boolean {
|
||||
return !!this.requestToken;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// API Detection
|
||||
// ==========================================================================
|
||||
@@ -95,12 +112,26 @@ class JiraAssetsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
/**
|
||||
* Get headers for API requests
|
||||
* @param forWrite - If true, requires user PAT. If false, uses service account token (or user PAT as fallback)
|
||||
*/
|
||||
private getHeaders(forWrite: boolean = false): Record<string, string> {
|
||||
const headers = { ...this.defaultHeaders };
|
||||
|
||||
// Use request-scoped token if available (for user context)
|
||||
if (this.requestToken) {
|
||||
if (forWrite) {
|
||||
// Write operations require user PAT
|
||||
if (!this.requestToken) {
|
||||
throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.');
|
||||
}
|
||||
headers['Authorization'] = `Bearer ${this.requestToken}`;
|
||||
} else {
|
||||
// Read operations: use service account token (primary) or user PAT (fallback)
|
||||
const token = this.serviceAccountToken || this.requestToken;
|
||||
if (!token) {
|
||||
throw new Error('Jira token not configured. Please configure JIRA_SERVICE_ACCOUNT_TOKEN in .env or a Personal Access Token in your user settings.');
|
||||
}
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
@@ -110,15 +141,21 @@ class JiraAssetsClient {
|
||||
// Core API Methods
|
||||
// ==========================================================================
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
/**
|
||||
* Make a request to Jira API
|
||||
* @param endpoint - API endpoint
|
||||
* @param options - Request options
|
||||
* @param forWrite - If true, requires user PAT for write operations
|
||||
*/
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`);
|
||||
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url} (forWrite: ${forWrite})`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...this.getHeaders(forWrite),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
@@ -136,10 +173,16 @@ class JiraAssetsClient {
|
||||
// ==========================================================================
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
// Don't test connection if no token is configured
|
||||
if (!this.hasToken()) {
|
||||
logger.debug('JiraAssetsClient: No token configured, skipping connection test');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.detectApiType();
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, {
|
||||
headers: this.getHeaders(),
|
||||
headers: this.getHeaders(false), // Read operation - uses service account token
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
@@ -150,7 +193,9 @@ class JiraAssetsClient {
|
||||
|
||||
async getObject(objectId: string): Promise<JiraAssetsObject | null> {
|
||||
try {
|
||||
return await this.request<JiraAssetsObject>(`/object/${objectId}`);
|
||||
// Include attributes and deep attributes to get full details of referenced objects (including descriptions)
|
||||
const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=1`;
|
||||
return await this.request<JiraAssetsObject>(url, {}, false); // Read operation
|
||||
} catch (error) {
|
||||
// Check if this is a 404 (object not found / deleted)
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
@@ -182,7 +227,7 @@ class JiraAssetsClient {
|
||||
includeAttributesDeep: '1',
|
||||
objectSchemaId: config.jiraSchemaId,
|
||||
});
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`);
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`, {}, false); // Read operation
|
||||
} catch (error) {
|
||||
// Fallback to deprecated IQL endpoint
|
||||
logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`);
|
||||
@@ -194,7 +239,7 @@ class JiraAssetsClient {
|
||||
includeAttributesDeep: '1',
|
||||
objectSchemaId: config.jiraSchemaId,
|
||||
});
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`);
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`, {}, false); // Read operation
|
||||
}
|
||||
} else {
|
||||
// Jira Cloud uses POST for AQL
|
||||
@@ -205,8 +250,9 @@ class JiraAssetsClient {
|
||||
page,
|
||||
resultPerPage: pageSize,
|
||||
includeAttributes: true,
|
||||
includeAttributesDeep: 1, // Include attributes of referenced objects (e.g., descriptions)
|
||||
}),
|
||||
});
|
||||
}, false); // Read operation
|
||||
}
|
||||
|
||||
const totalCount = response.totalFilterCount || response.totalCount || 0;
|
||||
@@ -287,6 +333,11 @@ class JiraAssetsClient {
|
||||
}
|
||||
|
||||
async updateObject(objectId: string, payload: JiraUpdatePayload): Promise<boolean> {
|
||||
// Write operations require user PAT
|
||||
if (!this.hasUserToken()) {
|
||||
throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.');
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, {
|
||||
attributeCount: payload.attributes.length,
|
||||
@@ -296,7 +347,7 @@ class JiraAssetsClient {
|
||||
await this.request(`/object/${objectId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}, true); // Write operation - requires user PAT
|
||||
|
||||
logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`);
|
||||
return true;
|
||||
@@ -337,7 +388,36 @@ class JiraAssetsClient {
|
||||
// Parse each attribute based on schema
|
||||
for (const attrDef of typeDef.attributes) {
|
||||
const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name);
|
||||
result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef);
|
||||
const parsedValue = this.parseAttributeValue(jiraAttr, attrDef);
|
||||
result[attrDef.fieldName] = parsedValue;
|
||||
|
||||
// Debug logging for Confluence Space field
|
||||
if (attrDef.fieldName === 'confluenceSpace') {
|
||||
logger.info(`[Confluence Space Debug] Object ${jiraObj.objectKey || jiraObj.id}:`);
|
||||
logger.info(` - Attribute definition: name="${attrDef.name}", jiraId=${attrDef.jiraId}, type="${attrDef.type}"`);
|
||||
logger.info(` - Found attribute: ${jiraAttr ? 'yes' : 'no'}`);
|
||||
if (!jiraAttr) {
|
||||
// Log all available attributes to help debug
|
||||
const availableAttrs = jiraObj.attributes?.map(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
return `${attrName} (ID: ${a.objectTypeAttributeId})`;
|
||||
}).join(', ') || 'none';
|
||||
logger.warn(` - Available attributes (${jiraObj.attributes?.length || 0}): ${availableAttrs}`);
|
||||
|
||||
// Try to find similar attributes
|
||||
const similarAttrs = jiraObj.attributes?.filter(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || '';
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
return lowerAttrName.includes('confluence') || lowerAttrName.includes('space');
|
||||
});
|
||||
if (similarAttrs && similarAttrs.length > 0) {
|
||||
logger.warn(` - Found similar attributes: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(` - Raw attribute: ${JSON.stringify(jiraAttr, null, 2)}`);
|
||||
logger.info(` - Parsed value: ${parsedValue} (type: ${typeof parsedValue})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
@@ -363,7 +443,7 @@ class JiraAssetsClient {
|
||||
|
||||
private parseAttributeValue(
|
||||
jiraAttr: JiraAssetsAttribute | undefined,
|
||||
attrDef: { type: string; isMultiple: boolean }
|
||||
attrDef: { type: string; isMultiple: boolean; fieldName?: string }
|
||||
): unknown {
|
||||
if (!jiraAttr?.objectAttributeValues?.length) {
|
||||
return attrDef.isMultiple ? [] : null;
|
||||
@@ -371,6 +451,30 @@ class JiraAssetsClient {
|
||||
|
||||
const values = jiraAttr.objectAttributeValues;
|
||||
|
||||
// Generic Confluence field detection: check if any value has a confluencePage
|
||||
// This works for all Confluence fields regardless of their declared type (float, text, etc.)
|
||||
const hasConfluencePage = values.some(v => v.confluencePage);
|
||||
if (hasConfluencePage) {
|
||||
const confluencePage = values[0]?.confluencePage;
|
||||
if (confluencePage?.url) {
|
||||
logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluencePage.url}`);
|
||||
// For multiple values, return array of URLs; for single, return the URL string
|
||||
if (attrDef.isMultiple) {
|
||||
return values
|
||||
.filter(v => v.confluencePage?.url)
|
||||
.map(v => v.confluencePage!.url);
|
||||
}
|
||||
return confluencePage.url;
|
||||
}
|
||||
// Fallback to displayValue if no URL
|
||||
const displayVal = values[0]?.displayValue;
|
||||
if (displayVal) {
|
||||
logger.info(`[Confluence Field Parse] Using displayValue as fallback for field "${attrDef.fieldName || 'unknown'}": ${displayVal}`);
|
||||
return String(displayVal);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (attrDef.type) {
|
||||
case 'reference': {
|
||||
const refs = values
|
||||
@@ -403,8 +507,19 @@ class JiraAssetsClient {
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
// Regular float parsing
|
||||
const val = values[0]?.value;
|
||||
return val ? parseFloat(val) : null;
|
||||
const displayVal = values[0]?.displayValue;
|
||||
// Try displayValue first, then value
|
||||
if (displayVal !== undefined && displayVal !== null) {
|
||||
const parsed = typeof displayVal === 'string' ? parseFloat(displayVal) : Number(displayVal);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
if (val !== undefined && val !== null) {
|
||||
const parsed = typeof val === 'string' ? parseFloat(val) : Number(val);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
|
||||
385
backend/src/services/roleService.ts
Normal file
385
backend/src/services/roleService.ts
Normal 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();
|
||||
@@ -80,6 +80,8 @@ class SyncEngine {
|
||||
/**
|
||||
* Initialize the sync engine
|
||||
* Performs initial sync if cache is cold, then starts incremental sync
|
||||
* Note: Sync engine uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
* for all read operations. Write operations require user PAT from profile settings.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
@@ -88,27 +90,11 @@ class SyncEngine {
|
||||
}
|
||||
|
||||
logger.info('SyncEngine: Initializing...');
|
||||
logger.info('SyncEngine: Sync uses service account token (JIRA_SERVICE_ACCOUNT_TOKEN) from .env');
|
||||
this.isRunning = true;
|
||||
|
||||
// Check if we need a full sync
|
||||
const stats = await cacheStore.getStats();
|
||||
const lastFullSync = stats.lastFullSync;
|
||||
const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000);
|
||||
|
||||
if (needsFullSync) {
|
||||
logger.info('SyncEngine: Cache is cold or stale, starting full sync in background...');
|
||||
// Run full sync in background (non-blocking)
|
||||
this.fullSync().catch(err => {
|
||||
logger.error('SyncEngine: Background full sync failed', err);
|
||||
});
|
||||
} else {
|
||||
logger.info('SyncEngine: Cache is warm, skipping initial full sync');
|
||||
}
|
||||
|
||||
// Start incremental sync scheduler
|
||||
this.startIncrementalSyncScheduler();
|
||||
|
||||
logger.info('SyncEngine: Initialized');
|
||||
// Sync can run automatically using service account token
|
||||
logger.info('SyncEngine: Initialized (using service account token for sync operations)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,8 +126,22 @@ class SyncEngine {
|
||||
|
||||
/**
|
||||
* Perform a full sync of all object types
|
||||
* Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
*/
|
||||
async fullSync(): Promise<SyncResult> {
|
||||
// Check if service account token is configured (sync uses service account token)
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
logger.warn('SyncEngine: Jira service account token not configured, cannot perform sync');
|
||||
return {
|
||||
success: false,
|
||||
stats: [],
|
||||
totalObjects: 0,
|
||||
totalRelations: 0,
|
||||
duration: 0,
|
||||
error: 'Jira service account token (JIRA_SERVICE_ACCOUNT_TOKEN) not configured in .env. Please configure it to enable sync operations.',
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
logger.warn('SyncEngine: Sync already in progress');
|
||||
return {
|
||||
@@ -312,11 +312,18 @@ class SyncEngine {
|
||||
|
||||
/**
|
||||
* Perform an incremental sync (only updated objects)
|
||||
* Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
*
|
||||
* Note: On Jira Data Center, IQL-based incremental sync is not supported.
|
||||
* We instead check if a periodic full sync is needed.
|
||||
*/
|
||||
async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> {
|
||||
// Check if service account token is configured (sync uses service account token)
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
logger.debug('SyncEngine: Jira service account token not configured, skipping incremental sync');
|
||||
return { success: false, updatedCount: 0 };
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
return { success: false, updatedCount: 0 };
|
||||
}
|
||||
|
||||
616
backend/src/services/userService.ts
Normal file
616
backend/src/services/userService.ts
Normal 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();
|
||||
298
backend/src/services/userSettingsService.ts
Normal file
298
backend/src/services/userSettingsService.ts
Normal 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();
|
||||
@@ -34,6 +34,7 @@ export interface ApplicationListItem {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
searchReference?: string | null; // Search reference for matching
|
||||
status: ApplicationStatus | null;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
governanceModel: ReferenceValue | null;
|
||||
@@ -88,6 +89,11 @@ export interface ApplicationDetails {
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
dataCompletenessPercentage?: number; // Data completeness percentage (0-100)
|
||||
reference?: string | null; // Reference field (Enterprise Architect GUID)
|
||||
confluenceSpace?: string | null; // Confluence Space URL
|
||||
supplierTechnical?: ReferenceValue | null; // Supplier Technical
|
||||
supplierImplementation?: ReferenceValue | null; // Supplier Implementation
|
||||
supplierConsultancy?: ReferenceValue | null; // Supplier Consultancy
|
||||
}
|
||||
|
||||
// Search filters
|
||||
|
||||
43
backend/src/utils/queryHelpers.ts
Normal file
43
backend/src/utils/queryHelpers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Helper functions for Express request query and params
|
||||
*/
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Get a query parameter as a string, handling both string and string[] types
|
||||
*/
|
||||
export function getQueryString(req: Request, key: string): string | undefined {
|
||||
const value = req.query[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (Array.isArray(value)) return value[0] as string;
|
||||
return value as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query parameter as a number, handling both string and string[] types
|
||||
*/
|
||||
export function getQueryNumber(req: Request, key: string, defaultValue?: number): number {
|
||||
const value = getQueryString(req, key);
|
||||
if (value === undefined) return defaultValue ?? 0;
|
||||
const parsed = parseInt(value, 10);
|
||||
return isNaN(parsed) ? (defaultValue ?? 0) : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query parameter as a boolean
|
||||
*/
|
||||
export function getQueryBoolean(req: Request, key: string, defaultValue = false): boolean {
|
||||
const value = getQueryString(req, key);
|
||||
if (value === undefined) return defaultValue;
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a route parameter as a string, handling both string and string[] types
|
||||
*/
|
||||
export function getParamString(req: Request, key: string): string {
|
||||
const value = req.params[key];
|
||||
if (Array.isArray(value)) return value[0] as string;
|
||||
return value as string;
|
||||
}
|
||||
58
docker-compose.prod.acr.yml
Normal file
58
docker-compose.prod.acr.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
image: zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- nginx_cache:/var/cache/nginx
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
nginx_cache:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
141
docs/AUTHENTICATION-ENV-VARS.md
Normal file
141
docs/AUTHENTICATION-ENV-VARS.md
Normal 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
|
||||
119
docs/AUTHENTICATION-IMPLEMENTATION-STATUS.md
Normal file
119
docs/AUTHENTICATION-IMPLEMENTATION-STATUS.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Authentication System Implementation Status
|
||||
|
||||
## ✅ Completed Features
|
||||
|
||||
### Backend
|
||||
- ✅ Database schema with users, roles, permissions, sessions, user_settings, email_tokens tables
|
||||
- ✅ User service (CRUD, password hashing, email verification, password reset)
|
||||
- ✅ Role service (dynamic role and permission management)
|
||||
- ✅ Auth service (local auth + OAuth with database-backed sessions)
|
||||
- ✅ Email service (Nodemailer with SMTP)
|
||||
- ✅ Encryption service (AES-256-GCM for sensitive data)
|
||||
- ✅ User settings service (Jira PAT, AI features, API keys)
|
||||
- ✅ Authorization middleware (requireAuth, requireRole, requirePermission)
|
||||
- ✅ All API routes protected with authentication
|
||||
- ✅ Auth routes (login, logout, password reset, email verification, invitations)
|
||||
- ✅ User management routes (admin only)
|
||||
- ✅ Role management routes
|
||||
- ✅ User settings routes
|
||||
- ✅ Profile routes
|
||||
|
||||
### Frontend
|
||||
- ✅ Auth store extended with roles, permissions, local auth support
|
||||
- ✅ Permission hooks (useHasPermission, useHasRole, usePermissions)
|
||||
- ✅ ProtectedRoute component
|
||||
- ✅ Login component (local login + OAuth choice)
|
||||
- ✅ ForgotPassword component
|
||||
- ✅ ResetPassword component
|
||||
- ✅ AcceptInvitation component
|
||||
- ✅ UserManagement component (admin)
|
||||
- ✅ RoleManagement component (admin)
|
||||
- ✅ UserSettings component
|
||||
- ✅ Profile component
|
||||
- ✅ UserMenu with logout and profile/settings links
|
||||
- ✅ Feature gating based on permissions
|
||||
|
||||
## 🔧 Configuration Required
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Required for local authentication:**
|
||||
```env
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
```
|
||||
|
||||
**Required for email functionality:**
|
||||
```env
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASSWORD=your-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
```
|
||||
|
||||
**Required for encryption:**
|
||||
```env
|
||||
ENCRYPTION_KEY=your-32-byte-encryption-key-base64
|
||||
```
|
||||
|
||||
**Optional - Initial admin user:**
|
||||
```env
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=SecurePassword123!
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_DISPLAY_NAME=Administrator
|
||||
```
|
||||
|
||||
**Password requirements:**
|
||||
```env
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
PASSWORD_REQUIRE_UPPERCASE=true
|
||||
PASSWORD_REQUIRE_LOWERCASE=true
|
||||
PASSWORD_REQUIRE_NUMBER=true
|
||||
PASSWORD_REQUIRE_SPECIAL=false
|
||||
```
|
||||
|
||||
**Session duration:**
|
||||
```env
|
||||
SESSION_DURATION_HOURS=24
|
||||
```
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
### JIRA_AUTH Settings
|
||||
- `JIRA_PAT` can be removed from global env - users configure their own PAT in settings
|
||||
- `JIRA_OAUTH_CLIENT_ID` and `JIRA_OAUTH_CLIENT_SECRET` are still needed for OAuth flow
|
||||
- `JIRA_HOST` and `JIRA_SCHEMA_ID` are still needed (infrastructure settings)
|
||||
|
||||
### AI API Keys
|
||||
- `ANTHROPIC_API_KEY` can be removed from global env - users configure their own keys
|
||||
- `OPENAI_API_KEY` can be removed from global env - users configure their own keys
|
||||
- `TAVILY_API_KEY` can be removed from global env - users configure their own keys
|
||||
- These are now stored per-user in the `user_settings` table (encrypted)
|
||||
|
||||
### Authentication Flow
|
||||
1. On first run, migrations create database tables
|
||||
2. If `ADMIN_EMAIL` and `ADMIN_PASSWORD` are set, initial admin user is created
|
||||
3. Once users exist, authentication is automatically required
|
||||
4. Users can log in with email/password (local auth) or OAuth (if configured)
|
||||
5. User menu shows logged-in user with links to Profile and Settings
|
||||
6. Logout is available for all authenticated users
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. Set `LOCAL_AUTH_ENABLED=true` in environment
|
||||
2. Configure SMTP settings for email functionality
|
||||
3. Generate encryption key: `openssl rand -base64 32`
|
||||
4. Set initial admin credentials (optional)
|
||||
5. Run the application - migrations will run automatically
|
||||
6. Log in with admin account
|
||||
7. Create additional users via User Management
|
||||
8. Configure roles and permissions as needed
|
||||
|
||||
## ⚠️ Important
|
||||
|
||||
- Once users exist in the database, authentication is **automatically required**
|
||||
- Service account mode only works if no users exist AND local auth is not enabled
|
||||
- All API routes are protected - unauthenticated requests return 401
|
||||
- User-specific settings (Jira PAT, AI keys) are encrypted at rest
|
||||
207
docs/AZURE-ACR-DNL-SCOPE.md
Normal file
207
docs/AZURE-ACR-DNL-SCOPE.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Azure Container Registry - Domain Name Label Scope
|
||||
|
||||
## Wat is Domain Name Label Scope?
|
||||
|
||||
**Domain Name Label (DNL) Scope** is een security feature van Azure Container Registry die voorkomt dat iemand anders dezelfde DNS naam kan gebruiken als je registry wordt verwijderd (subdomain takeover prevention).
|
||||
|
||||
## Opties
|
||||
|
||||
### 1. **Unsecure** (Aanbevolen voor simpele setup) ⭐
|
||||
|
||||
**DNS Format:** `registryname.azurecr.io`
|
||||
|
||||
**Voorbeeld:**
|
||||
- Registry naam: `zuyderlandcmdbacr`
|
||||
- DNS naam: `zuyderlandcmdbacr.azurecr.io`
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Eenvoudig en voorspelbaar
|
||||
- ✅ Geen hash in de naam
|
||||
- ✅ Makkelijk te onthouden en configureren
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Minder security (maar meestal voldoende voor interne tools)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Simpele setup
|
||||
- ✅ Interne/corporate omgeving
|
||||
- ✅ Je wilt een voorspelbare DNS naam
|
||||
|
||||
---
|
||||
|
||||
### 2. **Resource Group Reuse** (Aanbevolen voor security) 🔒
|
||||
|
||||
**DNS Format:** `registryname-hash.azurecr.io`
|
||||
|
||||
**Voorbeeld:**
|
||||
- Registry naam: `zuyderlandcmdbacr`
|
||||
- DNS naam: `zuyderlandcmdbacr-abc123.azurecr.io` (met unieke hash)
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Extra security layer
|
||||
- ✅ Consistent binnen resource group
|
||||
- ✅ Voorkomt subdomain takeover
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Hash in de naam (minder voorspelbaar)
|
||||
- ❌ Moet alle configuraties aanpassen met volledige DNS naam
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Productie omgevingen
|
||||
- ✅ Security is belangrijk
|
||||
- ✅ Je wilt extra bescherming
|
||||
|
||||
---
|
||||
|
||||
### 3. **Subscription Reuse**
|
||||
|
||||
**DNS Format:** `registryname-hash.azurecr.io` (hash gebaseerd op subscription)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- Als je meerdere resource groups hebt binnen dezelfde subscription
|
||||
- Je wilt consistentie binnen de subscription
|
||||
|
||||
---
|
||||
|
||||
### 4. **Tenant Reuse**
|
||||
|
||||
**DNS Format:** `registryname-hash.azurecr.io` (hash gebaseerd op tenant)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- Als je meerdere subscriptions hebt binnen dezelfde tenant
|
||||
- Je wilt consistentie binnen de tenant
|
||||
|
||||
---
|
||||
|
||||
### 5. **No Reuse**
|
||||
|
||||
**DNS Format:** `registryname-uniquehash.azurecr.io` (altijd unieke hash)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- Maximale security vereist
|
||||
- Je wilt absoluut geen risico op DNS conflicts
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Aanbeveling voor Jouw Situatie
|
||||
|
||||
**Voor Zuyderland CMDB GUI (20 gebruikers, corporate omgeving):**
|
||||
|
||||
### Optie A: **"Unsecure"** (Aanbevolen) ⭐
|
||||
|
||||
**Waarom:**
|
||||
- ✅ Eenvoudigste setup
|
||||
- ✅ Voorspelbare DNS naam
|
||||
- ✅ Geen configuratie wijzigingen nodig
|
||||
- ✅ Voldoende voor interne corporate tool
|
||||
|
||||
**DNS naam wordt:** `zuyderlandcmdbacr.azurecr.io`
|
||||
|
||||
**Configuratie:**
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
acrName: 'zuyderlandcmdbacr' # Simpel, zonder hash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Optie B: **"Resource Group Reuse"** (Als je extra security wilt) 🔒
|
||||
|
||||
**Waarom:**
|
||||
- ✅ Extra security layer
|
||||
- ✅ Voorkomt subdomain takeover
|
||||
- ✅ Consistent binnen resource group
|
||||
|
||||
**DNS naam wordt:** `zuyderlandcmdbacr-abc123.azurecr.io` (met hash)
|
||||
|
||||
**⚠️ Belangrijk:** Je moet dan alle configuraties aanpassen!
|
||||
|
||||
**Configuratie wijzigingen nodig:**
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
acrName: 'zuyderlandcmdbacr-abc123' # Met hash!
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.acr.yml
|
||||
image: zuyderlandcmdbacr-abc123.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
```
|
||||
|
||||
```bash
|
||||
# scripts/build-and-push-azure.sh
|
||||
REGISTRY="zuyderlandcmdbacr-abc123.azurecr.io" # Met hash!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Belangrijke Waarschuwingen
|
||||
|
||||
### 1. **Permanente Keuze**
|
||||
De DNL Scope keuze is **permanent** en kan **niet meer worden gewijzigd** na aanmaken van de registry!
|
||||
|
||||
### 2. **Geen Streepjes in Registry Naam**
|
||||
Als je een DNL Scope met hash gebruikt, mag je **geen streepjes (`-`)** gebruiken in de registry naam, omdat de hash zelf al een streepje gebruikt als scheidingsteken.
|
||||
|
||||
**Goed:** `zuyderlandcmdbacr`
|
||||
**Fout:** `zuyderland-cmdb-acr` (streepje conflict met hash)
|
||||
|
||||
### 3. **Configuratie Aanpassingen**
|
||||
Als je een hash gebruikt, moet je **alle configuraties aanpassen** met de volledige DNS naam (inclusief hash).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist
|
||||
|
||||
### Als je "Unsecure" kiest:
|
||||
- [ ] Registry naam zonder streepjes (bijv. `zuyderlandcmdbacr`)
|
||||
- [ ] DNS naam wordt: `zuyderlandcmdbacr.azurecr.io`
|
||||
- [ ] Geen configuratie wijzigingen nodig
|
||||
- [ ] Gebruik `acrName: 'zuyderlandcmdbacr'` in pipeline
|
||||
|
||||
### Als je "Resource Group Reuse" kiest:
|
||||
- [ ] Registry naam zonder streepjes (bijv. `zuyderlandcmdbacr`)
|
||||
- [ ] Noteer de volledige DNS naam na aanmaken (met hash)
|
||||
- [ ] Pas `azure-pipelines.yml` aan met volledige DNS naam
|
||||
- [ ] Pas `docker-compose.prod.acr.yml` aan met volledige DNS naam
|
||||
- [ ] Pas `scripts/build-and-push-azure.sh` aan met volledige DNS naam
|
||||
|
||||
---
|
||||
|
||||
## 🔍 DNS Naam Vinden
|
||||
|
||||
Na het aanmaken van de ACR, vind je de DNS naam:
|
||||
|
||||
**Via Azure Portal:**
|
||||
1. Ga naar je Container Registry
|
||||
2. Klik op **"Overview"**
|
||||
3. De **"Login server"** is je DNS naam
|
||||
|
||||
**Via Azure CLI:**
|
||||
```bash
|
||||
az acr show --name zuyderlandcmdbacr --query loginServer -o tsv
|
||||
```
|
||||
|
||||
**Output voorbeelden:**
|
||||
- Unsecure: `zuyderlandcmdbacr.azurecr.io`
|
||||
- Met hash: `zuyderlandcmdbacr-abc123.azurecr.io`
|
||||
|
||||
---
|
||||
|
||||
## 💡 Mijn Aanbeveling
|
||||
|
||||
**Voor jouw situatie (corporate tool, 20 gebruikers):**
|
||||
|
||||
Kies **"Unsecure"** omdat:
|
||||
1. ✅ Eenvoudigste setup
|
||||
2. ✅ Geen configuratie wijzigingen nodig
|
||||
3. ✅ Voldoende security voor interne tool
|
||||
4. ✅ Voorspelbare DNS naam
|
||||
|
||||
Als je later meer security nodig hebt, kun je altijd een nieuwe registry aanmaken met een andere scope (maar dan moet je wel alles migreren).
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure Container Registry DNL Scope Documentation](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-get-started-portal)
|
||||
- [Subdomain Takeover Prevention](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-security)
|
||||
205
docs/AZURE-ACR-PERMISSIONS.md
Normal file
205
docs/AZURE-ACR-PERMISSIONS.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Azure Container Registry - Role Assignment Permissions Mode
|
||||
|
||||
## 🎯 Aanbeveling voor Jouw Situatie
|
||||
|
||||
**Voor Zuyderland CMDB GUI (20 gebruikers, corporate tool, productie):**
|
||||
|
||||
### ✅ **RBAC Registry Permissions** (Aanbevolen) ⭐
|
||||
|
||||
**Waarom:**
|
||||
- ✅ Eenvoudiger te beheren
|
||||
- ✅ Voldoende voor jouw use case
|
||||
- ✅ Minder complexiteit
|
||||
- ✅ Standaard keuze voor de meeste scenario's
|
||||
|
||||
---
|
||||
|
||||
## 📊 Opties Vergelijking
|
||||
|
||||
### Optie 1: **RBAC Registry Permissions** ⭐ **AANBEVOLEN**
|
||||
|
||||
**Hoe het werkt:**
|
||||
- Permissions worden ingesteld op **registry niveau**
|
||||
- Alle repositories binnen de registry delen dezelfde permissions
|
||||
- Gebruikers hebben toegang tot alle repositories of geen
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ **Eenvoudig** - Minder complexiteit
|
||||
- ✅ **Makkelijk te beheren** - Eén set permissions voor de hele registry
|
||||
- ✅ **Voldoende voor de meeste scenario's** - Perfect voor jouw situatie
|
||||
- ✅ **Standaard keuze** - Meest gebruikte optie
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Minder flexibel - Kan niet per repository permissions instellen
|
||||
- ❌ Alle repositories hebben dezelfde toegang
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ **Jouw situatie** - 20 gebruikers, corporate tool
|
||||
- ✅ Kleine tot middelgrote teams
|
||||
- ✅ Alle repositories hebben dezelfde toegangsvereisten
|
||||
- ✅ Eenvoudige permission structuur gewenst
|
||||
|
||||
**Voorbeeld:**
|
||||
- Alle developers hebben toegang tot alle repositories
|
||||
- Alle CI/CD pipelines hebben toegang tot alle repositories
|
||||
- Geen per-repository verschillen nodig
|
||||
|
||||
---
|
||||
|
||||
### Optie 2: **RBAC Registry + ABAC Repository Permissions**
|
||||
|
||||
**Hoe het werkt:**
|
||||
- Permissions op **registry niveau** (RBAC)
|
||||
- **Extra** permissions op **repository niveau** (ABAC - Attribute-Based Access Control)
|
||||
- Kan per repository verschillende permissions instellen
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ **Flexibeler** - Per repository permissions mogelijk
|
||||
- ✅ **Granular control** - Verschillende teams kunnen verschillende repositories hebben
|
||||
- ✅ **Enterprise features** - Voor complexe organisaties
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ **Complexer** - Meer configuratie nodig
|
||||
- ❌ **Moeilijker te beheren** - Meerdere permission levels
|
||||
- ❌ **Meer overhead** - Meer tijd nodig voor setup en onderhoud
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Grote organisaties met meerdere teams
|
||||
- ✅ Verschillende repositories hebben verschillende toegangsvereisten
|
||||
- ✅ Compliance requirements die granular control vereisen
|
||||
- ✅ Multi-tenant scenarios
|
||||
|
||||
**Voorbeeld:**
|
||||
- Team A heeft alleen toegang tot repository A
|
||||
- Team B heeft alleen toegang tot repository B
|
||||
- CI/CD pipeline heeft toegang tot alle repositories
|
||||
- Externe partners hebben alleen toegang tot specifieke repositories
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Jouw Situatie Analyse
|
||||
|
||||
**Jouw setup:**
|
||||
- 2 repositories: `zuyderland-cmdb-gui/backend` en `zuyderland-cmdb-gui/frontend`
|
||||
- 20 gebruikers (klein team)
|
||||
- Corporate tool (interne gebruikers)
|
||||
- Productie omgeving
|
||||
|
||||
**Permission vereisten:**
|
||||
- ✅ Alle teamleden hebben toegang tot beide repositories
|
||||
- ✅ CI/CD pipeline heeft toegang tot beide repositories
|
||||
- ✅ Geen per-repository verschillen nodig
|
||||
- ✅ Eenvoudige beheer gewenst
|
||||
|
||||
**Conclusie:** ✅ **RBAC Registry Permissions is perfect!**
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist: Welke Keuze?
|
||||
|
||||
### Kies **RBAC Registry Permissions** als:
|
||||
- [x] Je <50 gebruikers hebt ✅
|
||||
- [x] Alle repositories dezelfde toegang hebben ✅
|
||||
- [x] Je eenvoudige beheer wilt ✅
|
||||
- [x] Je geen per-repository verschillen nodig hebt ✅
|
||||
- [x] Je een klein tot middelgroot team hebt ✅
|
||||
|
||||
**→ Jouw situatie: ✅ Kies RBAC Registry Permissions!**
|
||||
|
||||
### Kies **RBAC Registry + ABAC Repository Permissions** als:
|
||||
- [ ] Je >100 gebruikers hebt
|
||||
- [ ] Verschillende repositories verschillende toegang nodig hebben
|
||||
- [ ] Je granular control nodig hebt
|
||||
- [ ] Je multi-tenant scenario hebt
|
||||
- [ ] Je compliance requirements hebt die granular control vereisen
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Kan Ik Later Wisselen?
|
||||
|
||||
**⚠️ Belangrijk:**
|
||||
- Deze keuze is **permanent** en kan **niet meer worden gewijzigd** na aanmaken van de registry!
|
||||
- Als je later ABAC nodig hebt, moet je een nieuwe registry aanmaken
|
||||
|
||||
**Aanbeveling:**
|
||||
- Start met **RBAC Registry Permissions** (eenvoudigst)
|
||||
- Als je later granular control nodig hebt, overweeg dan een nieuwe registry met ABAC
|
||||
- Voor jouw situatie is RBAC Registry Permissions voldoende
|
||||
|
||||
---
|
||||
|
||||
## 💡 Permission Rollen (RBAC Registry Permissions)
|
||||
|
||||
Met RBAC Registry Permissions kun je deze rollen toewijzen:
|
||||
|
||||
### **AcrPull** (Lezen)
|
||||
- Images pullen
|
||||
- Voor: Developers, CI/CD pipelines
|
||||
|
||||
### **AcrPush** (Schrijven)
|
||||
- Images pushen
|
||||
- Voor: CI/CD pipelines, build servers
|
||||
|
||||
### **AcrDelete** (Verwijderen)
|
||||
- Images verwijderen
|
||||
- Voor: Administrators, cleanup scripts
|
||||
|
||||
### **Owner** (Volledig beheer)
|
||||
- Alles + registry beheer
|
||||
- Voor: Administrators
|
||||
|
||||
**Voor jouw situatie:**
|
||||
- **Developers**: `AcrPull` (images pullen)
|
||||
- **CI/CD Pipeline**: `AcrPush` (images pushen)
|
||||
- **Administrators**: `Owner` (volledig beheer)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Mijn Aanbeveling
|
||||
|
||||
**Voor Zuyderland CMDB GUI:**
|
||||
|
||||
### ✅ **Kies RBAC Registry Permissions** ⭐
|
||||
|
||||
**Waarom:**
|
||||
1. ✅ **Eenvoudig** - Minder complexiteit, makkelijker te beheren
|
||||
2. ✅ **Voldoende** - Alle repositories hebben dezelfde toegang (wat je nodig hebt)
|
||||
3. ✅ **Standaard** - Meest gebruikte optie, goed gedocumenteerd
|
||||
4. ✅ **Perfect voor jouw situatie** - 20 gebruikers, 2 repositories, corporate tool
|
||||
|
||||
**Je hebt niet nodig:**
|
||||
- ❌ Per-repository permissions (alle repositories hebben dezelfde toegang)
|
||||
- ❌ Complexe permission structuur (klein team)
|
||||
- ❌ Multi-tenant scenarios (corporate tool)
|
||||
|
||||
**Setup:**
|
||||
1. Kies **RBAC Registry Permissions**
|
||||
2. Wijs rollen toe aan gebruikers/groepen:
|
||||
- Developers → `AcrPull`
|
||||
- CI/CD → `AcrPush`
|
||||
- Admins → `Owner`
|
||||
|
||||
**Klaar!** ✅
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure Container Registry RBAC](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-roles)
|
||||
- [ACR Permissions Best Practices](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-best-practices)
|
||||
- [ABAC Repository Permissions](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-repository-scoped-permissions)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Conclusie
|
||||
|
||||
**Kies: RBAC Registry Permissions** ⭐
|
||||
|
||||
Dit is de beste keuze voor:
|
||||
- ✅ 20 gebruikers
|
||||
- ✅ Corporate tool
|
||||
- ✅ 2 repositories (backend + frontend)
|
||||
- ✅ Eenvoudige beheer gewenst
|
||||
- ✅ Alle repositories hebben dezelfde toegang
|
||||
|
||||
Je kunt altijd later een nieuwe registry aanmaken met ABAC als je granular control nodig hebt, maar voor jouw situatie is dat niet nodig.
|
||||
246
docs/AZURE-ACR-PRICING.md
Normal file
246
docs/AZURE-ACR-PRICING.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Azure Container Registry - Pricing Plan Keuze
|
||||
|
||||
## 🎯 Aanbeveling voor Jouw Situatie
|
||||
|
||||
**Voor Zuyderland CMDB GUI (20 gebruikers, corporate tool, productie):**
|
||||
|
||||
### ✅ **Basic SKU** (Aanbevolen) ⭐
|
||||
|
||||
**Waarom:**
|
||||
- ✅ Voldoende storage (10GB) voor meerdere versies
|
||||
- ✅ Goedkoop (~€5/maand)
|
||||
- ✅ Alle features die je nodig hebt
|
||||
- ✅ Perfect voor kleine tot middelgrote teams
|
||||
|
||||
---
|
||||
|
||||
## 📊 SKU Vergelijking
|
||||
|
||||
### Basic SKU (~€5/maand) ⭐ **AANBEVOLEN**
|
||||
|
||||
**Inclusief:**
|
||||
- ✅ **10GB storage** - Ruim voldoende voor backend + frontend images met meerdere versies
|
||||
- ✅ **1GB/day webhook throughput** - Voldoende voor CI/CD
|
||||
- ✅ **Unlimited pulls** - Geen extra kosten voor image pulls
|
||||
- ✅ **Admin user enabled** - Voor development/productie
|
||||
- ✅ **RBAC support** - Role-based access control
|
||||
- ✅ **Content trust** - Image signing support
|
||||
|
||||
**Limitaties:**
|
||||
- ❌ Geen geo-replicatie
|
||||
- ❌ Geen security scanning (vulnerability scanning)
|
||||
- ❌ Geen content trust storage
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ **Jouw situatie** - 20 gebruikers, corporate tool
|
||||
- ✅ Development en productie omgevingen
|
||||
- ✅ Kleine tot middelgrote teams
|
||||
- ✅ Budget-conscious deployments
|
||||
|
||||
**Voorbeeld kosten:**
|
||||
- 2 images (backend + frontend)
|
||||
- ~10 versies per image
|
||||
- ~500MB per image = ~10GB totaal
|
||||
- **Kosten: ~€5/maand** (alleen storage, geen extra pull kosten)
|
||||
|
||||
---
|
||||
|
||||
### Standard SKU (~€20/maand)
|
||||
|
||||
**Inclusief (alles van Basic +):**
|
||||
- ✅ **100GB storage** - Voor grote deployments
|
||||
- ✅ **10GB/day webhook throughput** - Voor hoge CI/CD volumes
|
||||
- ✅ **Geo-replicatie** - Images repliceren naar meerdere regio's
|
||||
- ✅ **Content trust storage** - Voor image signing
|
||||
|
||||
**Extra features:**
|
||||
- ✅ **Better performance** - Snellere pulls voor geo-replicated images
|
||||
- ✅ **Disaster recovery** - Images beschikbaar in meerdere regio's
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Grote deployments (>50GB images)
|
||||
- ✅ Multi-region deployments nodig
|
||||
- ✅ Hoge CI/CD volumes (>1GB/day)
|
||||
- ✅ Disaster recovery requirements
|
||||
|
||||
**Voor jouw situatie:** ❌ **Niet nodig** - Basic is voldoende
|
||||
|
||||
---
|
||||
|
||||
### Premium SKU (~€50/maand)
|
||||
|
||||
**Inclusief (alles van Standard +):**
|
||||
- ✅ **500GB storage** - Voor zeer grote deployments
|
||||
- ✅ **50GB/day webhook throughput** - Voor enterprise CI/CD
|
||||
- ✅ **Security scanning** - Automatische vulnerability scanning
|
||||
- ✅ **Advanced security features** - Firewall rules, private endpoints
|
||||
- ✅ **Dedicated throughput** - Garantie op performance
|
||||
|
||||
**Extra features:**
|
||||
- ✅ **Image vulnerability scanning** - Automatisch scannen op security issues
|
||||
- ✅ **Private endpoints** - Volledig private connectivity
|
||||
- ✅ **Firewall rules** - Network-level security
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Enterprise deployments
|
||||
- ✅ Security compliance requirements (ISO 27001, etc.)
|
||||
- ✅ Zeer grote deployments (>100GB)
|
||||
- ✅ Multi-tenant scenarios
|
||||
|
||||
**Voor jouw situatie:** ❌ **Niet nodig** - Overkill voor 20 gebruikers
|
||||
|
||||
---
|
||||
|
||||
## 💰 Kosten Breakdown
|
||||
|
||||
### Basic SKU (Aanbevolen) ⭐
|
||||
|
||||
**Maandelijkse kosten:**
|
||||
- **Storage**: €0.167 per GB/maand
|
||||
- **10GB storage**: ~€1.67/maand
|
||||
- **Base fee**: ~€3-4/maand
|
||||
- **Totaal**: ~€5/maand
|
||||
|
||||
**Voorbeeld voor jouw situatie:**
|
||||
- Backend image: ~200MB
|
||||
- Frontend image: ~50MB
|
||||
- 10 versies per image: ~2.5GB
|
||||
- **Ruim binnen 10GB limit** ✅
|
||||
|
||||
**Jaarlijkse kosten:** ~€60/jaar
|
||||
|
||||
---
|
||||
|
||||
### Standard SKU
|
||||
|
||||
**Maandelijkse kosten:**
|
||||
- **Storage**: €0.167 per GB/maand (eerste 100GB)
|
||||
- **100GB storage**: ~€16.70/maand
|
||||
- **Base fee**: ~€3-4/maand
|
||||
- **Totaal**: ~€20/maand
|
||||
|
||||
**Jaarlijkse kosten:** ~€240/jaar
|
||||
|
||||
**Voor jouw situatie:** ❌ **Te duur** - Je gebruikt maar ~2.5GB
|
||||
|
||||
---
|
||||
|
||||
### Premium SKU
|
||||
|
||||
**Maandelijkse kosten:**
|
||||
- **Storage**: €0.167 per GB/maand (eerste 500GB)
|
||||
- **500GB storage**: ~€83.50/maand
|
||||
- **Base fee**: ~€16.50/maand
|
||||
- **Totaal**: ~€50-100/maand (afhankelijk van storage)
|
||||
|
||||
**Jaarlijkse kosten:** ~€600-1200/jaar
|
||||
|
||||
**Voor jouw situatie:** ❌ **Veel te duur** - Niet nodig
|
||||
|
||||
---
|
||||
|
||||
## 📈 Wanneer Upgrade naar Standard/Premium?
|
||||
|
||||
### Upgrade naar Standard als:
|
||||
- ✅ Je >50GB images hebt
|
||||
- ✅ Je multi-region deployment nodig hebt
|
||||
- ✅ Je >1GB/day webhook throughput nodig hebt
|
||||
- ✅ Je disaster recovery nodig hebt
|
||||
|
||||
### Upgrade naar Premium als:
|
||||
- ✅ Je security scanning nodig hebt (compliance)
|
||||
- ✅ Je >100GB images hebt
|
||||
- ✅ Je private endpoints nodig hebt
|
||||
- ✅ Je enterprise security features nodig hebt
|
||||
|
||||
**Voor jouw situatie:** Start met **Basic**, upgrade later als nodig.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Upgrade/Downgrade
|
||||
|
||||
**Goed nieuws:**
|
||||
- ✅ Je kunt altijd upgraden (Basic → Standard → Premium)
|
||||
- ✅ Je kunt downgraden (Premium → Standard → Basic)
|
||||
- ⚠️ **Let op**: Bij downgrade verlies je mogelijk data als je over de storage limit gaat
|
||||
|
||||
**Aanbeveling:**
|
||||
- Start met **Basic**
|
||||
- Monitor storage gebruik
|
||||
- Upgrade alleen als je echt de extra features nodig hebt
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist: Welke SKU?
|
||||
|
||||
### Kies Basic als:
|
||||
- [x] Je <50GB images hebt ✅
|
||||
- [x] Je <20 gebruikers hebt ✅
|
||||
- [x] Je geen geo-replicatie nodig hebt ✅
|
||||
- [x] Je geen security scanning nodig hebt ✅
|
||||
- [x] Je budget-conscious bent ✅
|
||||
|
||||
**→ Jouw situatie: ✅ Kies Basic!**
|
||||
|
||||
### Kies Standard als:
|
||||
- [ ] Je >50GB images hebt
|
||||
- [ ] Je multi-region deployment nodig hebt
|
||||
- [ ] Je disaster recovery nodig hebt
|
||||
- [ ] Je >1GB/day webhook throughput nodig hebt
|
||||
|
||||
### Kies Premium als:
|
||||
- [ ] Je security scanning nodig hebt (compliance)
|
||||
- [ ] Je >100GB images hebt
|
||||
- [ ] Je private endpoints nodig hebt
|
||||
- [ ] Je enterprise security features nodig hebt
|
||||
|
||||
---
|
||||
|
||||
## 💡 Mijn Aanbeveling
|
||||
|
||||
**Voor Zuyderland CMDB GUI:**
|
||||
|
||||
### ✅ **Kies Basic SKU** ⭐
|
||||
|
||||
**Waarom:**
|
||||
1. ✅ **Voldoende storage** - 10GB is ruim voldoende voor jouw 2 images met meerdere versies
|
||||
2. ✅ **Kosteneffectief** - ~€5/maand vs €20-50/maand
|
||||
3. ✅ **Alle features die je nodig hebt** - RBAC, content trust, unlimited pulls
|
||||
4. ✅ **Eenvoudig** - Geen complexe configuratie nodig
|
||||
5. ✅ **Upgrade mogelijk** - Je kunt altijd later upgraden als nodig
|
||||
|
||||
**Geschatte storage gebruik:**
|
||||
- Backend: ~200MB × 10 versies = ~2GB
|
||||
- Frontend: ~50MB × 10 versies = ~0.5GB
|
||||
- **Totaal: ~2.5GB** (ruim binnen 10GB limit)
|
||||
|
||||
**Kosten:**
|
||||
- **Maandelijks**: ~€5
|
||||
- **Jaarlijks**: ~€60
|
||||
- **Kosteneffectief** voor jouw use case
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Conclusie
|
||||
|
||||
**Kies: Basic SKU** ⭐
|
||||
|
||||
Dit is de beste keuze voor:
|
||||
- ✅ 20 gebruikers
|
||||
- ✅ Corporate tool
|
||||
- ✅ Productie omgeving
|
||||
- ✅ Budget-conscious
|
||||
- ✅ Eenvoudige setup
|
||||
|
||||
Je kunt altijd later upgraden naar Standard of Premium als je:
|
||||
- Meer storage nodig hebt
|
||||
- Geo-replicatie nodig hebt
|
||||
- Security scanning nodig hebt
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure Container Registry Pricing](https://azure.microsoft.com/en-us/pricing/details/container-registry/)
|
||||
- [ACR SKU Comparison](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-skus)
|
||||
- [ACR Storage Limits](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-skus#sku-features-and-limits)
|
||||
287
docs/AZURE-ACR-QUICKSTART.md
Normal file
287
docs/AZURE-ACR-QUICKSTART.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Azure Container Registry - Quick Start Guide
|
||||
|
||||
Snelstart guide om zelf Azure Container Registry aan te maken en te configureren voor productie.
|
||||
|
||||
## 🚀 Stap 1: Azure Container Registry Aanmaken
|
||||
|
||||
### Optie A: Met Script (Aanbevolen)
|
||||
|
||||
```bash
|
||||
# Run het script
|
||||
./scripts/create-acr.sh
|
||||
|
||||
# Of met custom parameters:
|
||||
./scripts/create-acr.sh rg-cmdb-gui zuyderlandcmdbacr westeurope Basic
|
||||
```
|
||||
|
||||
Het script doet automatisch:
|
||||
- ✅ Checkt of je ingelogd bent bij Azure
|
||||
- ✅ Maakt resource group aan (als nodig)
|
||||
- ✅ Controleert of ACR naam beschikbaar is
|
||||
- ✅ Maakt ACR aan met Basic SKU
|
||||
- ✅ Toont credentials
|
||||
- ✅ Test Docker login
|
||||
|
||||
### Optie B: Handmatig via Azure CLI
|
||||
|
||||
```bash
|
||||
# Login bij Azure
|
||||
az login
|
||||
|
||||
# Resource group aanmaken
|
||||
az group create --name rg-cmdb-gui --location westeurope
|
||||
|
||||
# Check of naam beschikbaar is
|
||||
az acr check-name --name zuyderlandcmdbacr
|
||||
|
||||
# ACR aanmaken (Basic SKU - ~€5/maand)
|
||||
az acr create \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--name zuyderlandcmdbacr \
|
||||
--sku Basic \
|
||||
--admin-enabled true
|
||||
|
||||
# Credentials ophalen
|
||||
az acr credential show --name zuyderlandcmdbacr
|
||||
```
|
||||
|
||||
### Optie C: Via Azure Portal
|
||||
|
||||
1. Ga naar [Azure Portal](https://portal.azure.com)
|
||||
2. Klik **"Create a resource"**
|
||||
3. Zoek **"Container Registry"**
|
||||
4. Klik **"Create"**
|
||||
5. Vul in:
|
||||
- **Resource group**: `rg-cmdb-gui` (of maak nieuwe)
|
||||
- **Registry name**: `zuyderlandcmdbacr` (moet uniek zijn, alleen kleine letters en cijfers, **geen streepjes**)
|
||||
- **Location**: `West Europe`
|
||||
- **SKU**: `Basic` ⭐ (aanbevolen - ~€5/maand)
|
||||
- **Domain name label scope**:
|
||||
- **"Unsecure"** ⭐ (aanbevolen) - DNS naam wordt: `zuyderlandcmdbacr.azurecr.io`
|
||||
- **"Resource Group Reuse"** (voor extra security) - DNS naam wordt: `zuyderlandcmdbacr-abc123.azurecr.io` (met hash)
|
||||
- ⚠️ **Let op**: Als je een hash gebruikt, moet je alle configuraties aanpassen met de volledige DNS naam!
|
||||
- **Role assignment permissions mode**:
|
||||
- **"RBAC Registry Permissions"** ⭐ (aanbevolen - eenvoudigst)
|
||||
- **"RBAC Registry + ABAC Repository Permissions"** (alleen als je per-repository permissions nodig hebt)
|
||||
6. Klik **"Review + create"** → **"Create"**
|
||||
|
||||
**💡 Aanbeveling:** Kies **"Unsecure"** voor de eenvoudigste setup. Zie `docs/AZURE-ACR-DNL-SCOPE.md` voor details.
|
||||
|
||||
**Noteer je ACR naam!** Je hebt deze nodig voor de volgende stappen.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Stap 2: Pipeline Variabelen Aanpassen
|
||||
|
||||
Pas `azure-pipelines.yml` aan met jouw ACR naam:
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
# Pas deze aan naar jouw ACR naam
|
||||
acrName: 'zuyderlandcmdbacr' # ← Jouw ACR naam hier
|
||||
|
||||
repositoryName: 'zuyderland-cmdb-gui'
|
||||
|
||||
# Service connection naam (maak je in volgende stap)
|
||||
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection'
|
||||
|
||||
imageTag: '$(Build.BuildId)'
|
||||
```
|
||||
|
||||
**Commit en push:**
|
||||
```bash
|
||||
git add azure-pipelines.yml
|
||||
git commit -m "Configure ACR in pipeline"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Stap 3: Service Connection Aanmaken in Azure DevOps
|
||||
|
||||
Deze connection geeft Azure DevOps toegang tot je ACR.
|
||||
|
||||
1. **Ga naar je Azure DevOps project**
|
||||
2. Klik op **⚙️ Project Settings** (onderaan links)
|
||||
3. Ga naar **Service connections** (onder Pipelines)
|
||||
4. Klik op **"New service connection"**
|
||||
5. Kies **"Docker Registry"**
|
||||
6. Kies **"Azure Container Registry"**
|
||||
7. Vul in:
|
||||
- **Azure subscription**: Selecteer je Azure subscription
|
||||
- **Azure container registry**: Selecteer je ACR (bijv. `zuyderlandcmdbacr`)
|
||||
- **Service connection name**: `zuyderland-cmdb-acr-connection`
|
||||
- **Description**: Optioneel (bijv. "ACR for CMDB GUI")
|
||||
8. Klik **"Save"**
|
||||
|
||||
**✅ Noteer de service connection naam!** Deze moet overeenkomen met `dockerRegistryServiceConnection` in `azure-pipelines.yml`.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Stap 4: Pipeline Aanmaken en Run
|
||||
|
||||
1. **Ga naar je Azure DevOps project**
|
||||
2. Klik op **Pipelines** (links in het menu)
|
||||
3. Klik op **"New pipeline"** of **"Create Pipeline"**
|
||||
4. Kies **"Azure Repos Git"** (of waar je code staat)
|
||||
5. Selecteer je repository: **"Zuyderland CMDB GUI"**
|
||||
6. Kies **"Existing Azure Pipelines YAML file"**
|
||||
7. Selecteer:
|
||||
- **Branch**: `main`
|
||||
- **Path**: `/azure-pipelines.yml`
|
||||
8. Klik **"Continue"**
|
||||
9. **Review** de pipeline configuratie
|
||||
10. Klik **"Run"** om de pipeline te starten
|
||||
|
||||
---
|
||||
|
||||
## ✅ Stap 5: Verifiëren
|
||||
|
||||
### In Azure Portal:
|
||||
1. Ga naar je **Container Registry** (`zuyderlandcmdbacr`)
|
||||
2. Klik op **"Repositories"**
|
||||
3. Je zou moeten zien:
|
||||
- `zuyderland-cmdb-gui/backend`
|
||||
- `zuyderland-cmdb-gui/frontend`
|
||||
4. Klik op een repository om de tags te zien (bijv. `latest`, `123`)
|
||||
|
||||
### Via Azure CLI:
|
||||
```bash
|
||||
# Lijst repositories
|
||||
az acr repository list --name zuyderlandcmdbacr
|
||||
|
||||
# Lijst tags voor backend
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
|
||||
|
||||
# Lijst tags voor frontend
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Stap 6: Images Lokaal Testen (Optioneel)
|
||||
|
||||
```bash
|
||||
# Login bij ACR
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
|
||||
# Pull images
|
||||
docker pull zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
docker pull zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
|
||||
# Test run (met docker-compose)
|
||||
docker-compose -f docker-compose.prod.acr.yml pull
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist
|
||||
|
||||
- [ ] Azure Container Registry aangemaakt
|
||||
- [ ] ACR naam genoteerd
|
||||
- [ ] `azure-pipelines.yml` variabelen aangepast
|
||||
- [ ] Service Connection aangemaakt in Azure DevOps
|
||||
- [ ] Pipeline aangemaakt en gerund
|
||||
- [ ] Images succesvol gebouwd en gepusht
|
||||
- [ ] Images geverifieerd in Azure Portal
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Automatische Triggers
|
||||
|
||||
De pipeline triggert automatisch bij:
|
||||
|
||||
1. **Push naar `main` branch** → Bouwt `latest` tag
|
||||
2. **Git tags die beginnen met `v*`** → Bouwt versie tag (bijv. `v1.0.0`)
|
||||
|
||||
**Voorbeeld:**
|
||||
```bash
|
||||
# Tag aanmaken en pushen
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
# → Pipeline triggert automatisch en bouwt versie 1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Pipeline Fails: "Service connection not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de service connection naam in `azure-pipelines.yml` overeenkomt met de naam in Azure DevOps
|
||||
- Ga naar Project Settings → Service connections en verifieer de naam
|
||||
|
||||
### Pipeline Fails: "ACR not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de `acrName` variabele correct is in `azure-pipelines.yml`
|
||||
- Verifieer dat de ACR bestaat: `az acr list`
|
||||
|
||||
### Pipeline Fails: "Permission denied"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de service connection de juiste permissions heeft
|
||||
- Verifieer dat je Azure subscription toegang heeft tot de ACR
|
||||
- Probeer de service connection opnieuw aan te maken
|
||||
|
||||
### ACR Naam Niet Beschikbaar
|
||||
|
||||
**Oplossing:**
|
||||
- ACR namen moeten uniek zijn wereldwijd
|
||||
- Probeer een andere naam:
|
||||
- `zuyderlandcmdbacr1`
|
||||
- `zuyderlandcmdbprod`
|
||||
- `cmdbzuyderlandacr`
|
||||
- `zuyderlandcmdbgui`
|
||||
|
||||
---
|
||||
|
||||
## 💰 Kosten & SKU Keuze
|
||||
|
||||
**Aanbeveling: Basic SKU** ⭐ (~€5/maand)
|
||||
|
||||
**Basic SKU** (Aanbevolen voor jouw situatie):
|
||||
- ✅ 10GB storage - Ruim voldoende voor backend + frontend met meerdere versies
|
||||
- ✅ 1GB/day webhook throughput - Voldoende voor CI/CD
|
||||
- ✅ Unlimited pulls - Geen extra kosten
|
||||
- ✅ RBAC support - Role-based access control
|
||||
- ✅ **Kosten: ~€5/maand**
|
||||
|
||||
**Standard SKU** (~€20/maand):
|
||||
- 100GB storage
|
||||
- 10GB/day webhook throughput
|
||||
- Geo-replicatie
|
||||
- **Niet nodig voor jouw situatie**
|
||||
|
||||
**Premium SKU** (~€50/maand):
|
||||
- 500GB storage
|
||||
- Security scanning
|
||||
- Private endpoints
|
||||
- **Overkill voor 20 gebruikers**
|
||||
|
||||
**Voor jouw situatie (20 gebruikers): Basic is perfect!** ✅
|
||||
|
||||
📚 Zie `docs/AZURE-ACR-PRICING.md` voor volledige vergelijking.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- **Volledige ACR Guide**: `docs/AZURE-CONTAINER-REGISTRY.md`
|
||||
- **Azure DevOps Setup**: `docs/AZURE-DEVOPS-SETUP.md`
|
||||
- **Deployment Guide**: `docs/PRODUCTION-DEPLOYMENT.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Volgende Stappen
|
||||
|
||||
Nu je images in ACR staan, kun je ze deployen naar:
|
||||
|
||||
1. **Azure Container Instances (ACI)** - Eenvoudig, snel
|
||||
2. **Azure App Service (Container)** - Managed service
|
||||
3. **Azure Kubernetes Service (AKS)** - Voor complexere setups
|
||||
4. **VM met Docker Compose** - Volledige controle
|
||||
|
||||
Zie `docs/AZURE-DEPLOYMENT-SUMMARY.md` voor deployment opties.
|
||||
279
docs/AZURE-APP-SERVICE-DEPLOYMENT.md
Normal file
279
docs/AZURE-APP-SERVICE-DEPLOYMENT.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Azure App Service Deployment - Stap-voor-Stap Guide 🚀
|
||||
|
||||
Complete deployment guide voor Zuyderland CMDB GUI naar Azure App Service.
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- Azure CLI geïnstalleerd en geconfigureerd (`az login`)
|
||||
- Docker images in ACR: `zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest` en `frontend:latest`
|
||||
- Azure DevOps pipeline werkt (images worden automatisch gebouwd)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Start (15 minuten)
|
||||
|
||||
### Stap 1: Resource Group
|
||||
|
||||
```bash
|
||||
az group create \
|
||||
--name rg-cmdb-gui-prod \
|
||||
--location westeurope
|
||||
```
|
||||
|
||||
### Stap 2: App Service Plan
|
||||
|
||||
```bash
|
||||
az appservice plan create \
|
||||
--name plan-cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--sku B1 \
|
||||
--is-linux
|
||||
```
|
||||
|
||||
### Stap 3: Web Apps
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
az webapp create \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--plan plan-cmdb-gui-prod \
|
||||
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
|
||||
# Frontend
|
||||
az webapp create \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--plan plan-cmdb-gui-prod \
|
||||
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
```
|
||||
|
||||
### Stap 4: ACR Authentication
|
||||
|
||||
```bash
|
||||
# Enable Managed Identity
|
||||
az webapp identity assign --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
az webapp identity assign --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
|
||||
|
||||
# Get Principal IDs
|
||||
BACKEND_PRINCIPAL_ID=$(az webapp identity show --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod --query principalId -o tsv)
|
||||
FRONTEND_PRINCIPAL_ID=$(az webapp identity show --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod --query principalId -o tsv)
|
||||
|
||||
# Get ACR Resource ID (vervang <acr-resource-group> met jouw ACR resource group)
|
||||
ACR_ID=$(az acr show --name zdlas --query id -o tsv)
|
||||
|
||||
# Grant AcrPull permissions
|
||||
az role assignment create --assignee $BACKEND_PRINCIPAL_ID --role AcrPull --scope $ACR_ID
|
||||
az role assignment create --assignee $FRONTEND_PRINCIPAL_ID --role AcrPull --scope $ACR_ID
|
||||
|
||||
# Configure container settings
|
||||
az webapp config container set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \
|
||||
--docker-registry-server-url https://zdlas.azurecr.io
|
||||
|
||||
az webapp config container set \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \
|
||||
--docker-registry-server-url https://zdlas.azurecr.io
|
||||
```
|
||||
|
||||
### Stap 5: Environment Variabelen
|
||||
|
||||
```bash
|
||||
# Backend (vervang met jouw waarden)
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
JIRA_BASE_URL=https://jira.zuyderland.nl \
|
||||
JIRA_SCHEMA_ID=your-schema-id \
|
||||
JIRA_PAT=your-pat-token \
|
||||
SESSION_SECRET=$(openssl rand -hex 32) \
|
||||
FRONTEND_URL=https://cmdb-frontend-prod.azurewebsites.net
|
||||
|
||||
# Frontend
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
VITE_API_URL=https://cmdb-backend-prod.azurewebsites.net/api
|
||||
```
|
||||
|
||||
### Stap 6: Start Apps
|
||||
|
||||
```bash
|
||||
az webapp start --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
az webapp start --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
### Stap 7: Test
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl https://cmdb-backend-prod.azurewebsites.net/api/health
|
||||
|
||||
# Frontend
|
||||
curl https://cmdb-frontend-prod.azurewebsites.net
|
||||
```
|
||||
|
||||
**🎉 Je applicatie is nu live!**
|
||||
|
||||
- Frontend: `https://cmdb-frontend-prod.azurewebsites.net`
|
||||
- Backend API: `https://cmdb-backend-prod.azurewebsites.net/api`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Azure Key Vault Setup (Aanbevolen)
|
||||
|
||||
Voor productie: gebruik Azure Key Vault voor secrets.
|
||||
|
||||
### Stap 1: Key Vault Aanmaken
|
||||
|
||||
```bash
|
||||
az keyvault create \
|
||||
--name kv-cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--location westeurope \
|
||||
--sku standard
|
||||
```
|
||||
|
||||
### Stap 2: Secrets Toevoegen
|
||||
|
||||
```bash
|
||||
az keyvault secret set --vault-name kv-cmdb-gui-prod --name JiraPat --value "your-token"
|
||||
az keyvault secret set --vault-name kv-cmdb-gui-prod --name SessionSecret --value "$(openssl rand -hex 32)"
|
||||
az keyvault secret set --vault-name kv-cmdb-gui-prod --name JiraSchemaId --value "your-schema-id"
|
||||
```
|
||||
|
||||
### Stap 3: Grant Access
|
||||
|
||||
```bash
|
||||
az keyvault set-policy \
|
||||
--name kv-cmdb-gui-prod \
|
||||
--object-id $BACKEND_PRINCIPAL_ID \
|
||||
--secret-permissions get list
|
||||
```
|
||||
|
||||
### Stap 4: Configure App Settings met Key Vault References
|
||||
|
||||
```bash
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
JIRA_PAT="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraPat/)" \
|
||||
SESSION_SECRET="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/SessionSecret/)" \
|
||||
JIRA_SCHEMA_ID="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraSchemaId/)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring Setup
|
||||
|
||||
### Application Insights
|
||||
|
||||
```bash
|
||||
# Create Application Insights
|
||||
az monitor app-insights component create \
|
||||
--app cmdb-gui-prod \
|
||||
--location westeurope \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--application-type web
|
||||
|
||||
# Get Instrumentation Key
|
||||
INSTRUMENTATION_KEY=$(az monitor app-insights component show \
|
||||
--app cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--query instrumentationKey -o tsv)
|
||||
|
||||
# Configure App Settings
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
APPINSIGHTS_INSTRUMENTATIONKEY=$INSTRUMENTATION_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates Deployen
|
||||
|
||||
### Optie 1: Manual (Eenvoudig)
|
||||
|
||||
```bash
|
||||
# Restart Web Apps (pull nieuwe latest image)
|
||||
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
az webapp restart --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
### Optie 2: Deployment Slots (Zero-Downtime)
|
||||
|
||||
```bash
|
||||
# Create staging slot
|
||||
az webapp deployment slot create \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--slot staging
|
||||
|
||||
# Deploy to staging
|
||||
az webapp deployment slot swap \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--slot staging \
|
||||
--target-slot production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
# Live logs
|
||||
az webapp log tail --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
|
||||
# Download logs
|
||||
az webapp log download --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod --log-file logs.zip
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
az webapp show --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod --query state
|
||||
```
|
||||
|
||||
### Restart App
|
||||
|
||||
```bash
|
||||
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- **Deployment Advies**: `docs/DEPLOYMENT-ADVICE.md`
|
||||
- **Quick Deployment Guide**: `docs/QUICK-DEPLOYMENT-GUIDE.md`
|
||||
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
- [ ] Resource Group aangemaakt
|
||||
- [ ] App Service Plan aangemaakt
|
||||
- [ ] Web Apps aangemaakt
|
||||
- [ ] ACR authentication geconfigureerd
|
||||
- [ ] Environment variabelen ingesteld
|
||||
- [ ] Key Vault geconfigureerd (optioneel)
|
||||
- [ ] Application Insights ingeschakeld
|
||||
- [ ] Health checks werken
|
||||
- [ ] Team geïnformeerd
|
||||
|
||||
**Veel succes! 🚀**
|
||||
270
docs/AZURE-CLI-QUICKSTART.md
Normal file
270
docs/AZURE-CLI-QUICKSTART.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Azure CLI - Quick Start Guide
|
||||
|
||||
## 📍 Waar voer je deze commando's uit?
|
||||
|
||||
Je voert deze commando's uit in de **Terminal** (command line) op je computer.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Terminal Openen
|
||||
|
||||
### Op macOS (jouw situatie):
|
||||
1. **Open Terminal:**
|
||||
- Druk op `Cmd + Space` (Spotlight)
|
||||
- Typ "Terminal"
|
||||
- Druk Enter
|
||||
- Of: Applications → Utilities → Terminal
|
||||
|
||||
2. **Of gebruik iTerm2** (als je die hebt geïnstalleerd)
|
||||
|
||||
### Op Windows:
|
||||
- **PowerShell** of **Command Prompt**
|
||||
- Druk `Win + R`, typ `powershell`, Enter
|
||||
|
||||
### Op Linux:
|
||||
- Open je terminal emulator (bijv. GNOME Terminal, Konsole)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Stap 1: Check of Azure CLI Geïnstalleerd is
|
||||
|
||||
**Voer dit commando uit in de terminal:**
|
||||
|
||||
```bash
|
||||
az --version
|
||||
```
|
||||
|
||||
**Als je een versie ziet** (bijv. `azure-cli 2.50.0`): ✅ Azure CLI is geïnstalleerd, ga door naar Stap 2.
|
||||
|
||||
**Als je een foutmelding krijgt** (bijv. `command not found`): ❌ Azure CLI is niet geïnstalleerd, zie installatie hieronder.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Stap 2: Azure CLI Installeren (als nodig)
|
||||
|
||||
### Op macOS:
|
||||
|
||||
**Optie A: Met Homebrew (Aanbevolen)**
|
||||
```bash
|
||||
# Installeer Homebrew (als je die nog niet hebt)
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Installeer Azure CLI
|
||||
brew install azure-cli
|
||||
```
|
||||
|
||||
**Optie B: Met Installer**
|
||||
1. Download: https://aka.ms/installazureclimac
|
||||
2. Open het `.pkg` bestand
|
||||
3. Volg de installatie wizard
|
||||
|
||||
**Optie C: Met pip (Python)**
|
||||
```bash
|
||||
pip3 install azure-cli
|
||||
```
|
||||
|
||||
### Op Windows:
|
||||
1. Download: https://aka.ms/installazurecliwindows
|
||||
2. Run de `.msi` installer
|
||||
3. Volg de installatie wizard
|
||||
|
||||
### Op Linux:
|
||||
```bash
|
||||
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
|
||||
```
|
||||
|
||||
**Na installatie, check opnieuw:**
|
||||
```bash
|
||||
az --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Stap 3: Login bij Azure
|
||||
|
||||
**Voer dit commando uit:**
|
||||
|
||||
```bash
|
||||
az login
|
||||
```
|
||||
|
||||
**Wat gebeurt er:**
|
||||
1. Je browser opent automatisch
|
||||
2. Log in met je Azure account (hetzelfde account dat je gebruikt voor Azure Portal)
|
||||
3. Na succesvol inloggen, sluit je de browser
|
||||
4. De terminal toont je subscriptions
|
||||
|
||||
**Als browser niet opent automatisch:**
|
||||
- Je krijgt een code en URL in de terminal
|
||||
- Kopieer de code
|
||||
- Open de URL in je browser
|
||||
- Voer de code in
|
||||
|
||||
**Verwachte output:**
|
||||
```
|
||||
[
|
||||
{
|
||||
"cloudName": "AzureCloud",
|
||||
"id": "12345678-1234-1234-1234-123456789012",
|
||||
"name": "Subscription Name",
|
||||
"state": "Enabled",
|
||||
...
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**✅ Je bent nu ingelogd!**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Stap 4: Haal ACR Credentials Op
|
||||
|
||||
**Voer dit commando uit:**
|
||||
|
||||
```bash
|
||||
az acr credential show --name zdlas
|
||||
```
|
||||
|
||||
**Verwachte output:**
|
||||
```json
|
||||
{
|
||||
"passwords": [
|
||||
{
|
||||
"name": "password",
|
||||
"value": "abc123xyz..."
|
||||
},
|
||||
{
|
||||
"name": "password2",
|
||||
"value": "def456uvw..."
|
||||
}
|
||||
],
|
||||
"username": "zdlas"
|
||||
}
|
||||
```
|
||||
|
||||
**Noteer:**
|
||||
- **Username**: `zdlas` (of wat er staat)
|
||||
- **Password**: Gebruik `passwords[0].value` (de eerste password)
|
||||
|
||||
**⚠️ Belangrijk:** Deze credentials zijn gevoelig! Deel ze niet en gebruik ze alleen voor de service connection.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Complete Stappen in Terminal
|
||||
|
||||
**Hier is de complete reeks commando's:**
|
||||
|
||||
```bash
|
||||
# 1. Check of Azure CLI geïnstalleerd is
|
||||
az --version
|
||||
|
||||
# 2. Login bij Azure (opent browser)
|
||||
az login
|
||||
|
||||
# 3. Haal ACR credentials op
|
||||
az acr credential show --name zdlas
|
||||
```
|
||||
|
||||
**Kopieer de output** en gebruik de `username` en `passwords[0].value` in Azure DevOps.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Alternatief: Via Azure Portal (Zonder Azure CLI)
|
||||
|
||||
**Als je Azure CLI niet wilt installeren, kun je credentials ook via Azure Portal ophalen:**
|
||||
|
||||
1. **Ga naar Azure Portal**: https://portal.azure.com
|
||||
2. **Ga naar je Container Registry**: Zoek naar `zdlas`
|
||||
3. **Klik op "Access keys"** (links in het menu)
|
||||
4. **Je ziet:**
|
||||
- **Login server**: `zdlas.azurecr.io`
|
||||
- **Username**: `zdlas` (of admin username)
|
||||
- **Password**: Klik op "Show" naast password om het te zien
|
||||
- **Password2**: Alternatieve password
|
||||
|
||||
5. **Kopieer de username en password**
|
||||
|
||||
**✅ Dit is hetzelfde als `az acr credential show`!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Voor Jouw Situatie (Service Connection)
|
||||
|
||||
**Gebruik deze credentials in Azure DevOps:**
|
||||
|
||||
1. **In de service connection wizard:**
|
||||
- Kies "Docker Registry" → "Others"
|
||||
- **Docker Registry**: `zdlas.azurecr.io`
|
||||
- **Docker ID**: `zdlas` (of de username uit de output)
|
||||
- **Docker Password**: `passwords[0].value` (uit de output)
|
||||
- **Service connection name**: `zuyderland-cmdb-acr-connection`
|
||||
|
||||
2. **Save**
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### "az: command not found"
|
||||
|
||||
**Oplossing:** Azure CLI is niet geïnstalleerd
|
||||
- Installeer Azure CLI (zie Stap 2 hierboven)
|
||||
- Of gebruik Azure Portal alternatief (zie hierboven)
|
||||
|
||||
### "az login" opent geen browser
|
||||
|
||||
**Oplossing:**
|
||||
- Kopieer de code en URL uit de terminal
|
||||
- Open de URL handmatig in je browser
|
||||
- Voer de code in
|
||||
|
||||
### "Subscription not found" of "Access denied"
|
||||
|
||||
**Oplossing:**
|
||||
- Check of je ingelogd bent met het juiste Azure account
|
||||
- Check of je toegang hebt tot de subscription waar de ACR staat
|
||||
- Probeer: `az account list` om je subscriptions te zien
|
||||
- Selecteer de juiste subscription: `az account set --subscription "Subscription Name"`
|
||||
|
||||
### "ACR not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Check of de ACR naam correct is: `zdlas`
|
||||
- Check of je toegang hebt tot de ACR
|
||||
- Probeer: `az acr list` om alle ACR's te zien
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Azure CLI blijft ingelogd** - Je hoeft niet elke keer `az login` te doen
|
||||
2. **Check je subscription** - Als je meerdere subscriptions hebt: `az account show`
|
||||
3. **Wissel subscription** - `az account set --subscription "Subscription Name"`
|
||||
4. **Logout** - `az logout` (als je klaar bent)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure CLI Installatie](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
|
||||
- [Azure CLI Login](https://learn.microsoft.com/en-us/cli/azure/authenticate-azure-cli)
|
||||
- [ACR Credentials](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Reference
|
||||
|
||||
**Terminal openen:**
|
||||
- macOS: `Cmd + Space` → "Terminal"
|
||||
|
||||
**Azure CLI commando's:**
|
||||
```bash
|
||||
az --version # Check installatie
|
||||
az login # Login bij Azure
|
||||
az acr credential show --name zdlas # Haal credentials op
|
||||
```
|
||||
|
||||
**Azure Portal alternatief:**
|
||||
- Portal → Container Registry → Access keys
|
||||
|
||||
**Klaar!** 🚀
|
||||
451
docs/AZURE-CONTAINER-REGISTRY.md
Normal file
451
docs/AZURE-CONTAINER-REGISTRY.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Azure Container Registry - Docker Images Build & Push Guide
|
||||
|
||||
Deze guide beschrijft hoe je Docker images bouwt en naar Azure Container Registry (ACR) pusht voor de Zuyderland CMDB GUI applicatie.
|
||||
|
||||
## 📋 Inhoudsopgave
|
||||
|
||||
1. [Azure Container Registry Setup](#azure-container-registry-setup)
|
||||
2. [Lokale Build & Push](#lokale-build--push)
|
||||
3. [Azure DevOps Pipeline](#azure-devops-pipeline)
|
||||
4. [Docker Compose Configuration](#docker-compose-configuration)
|
||||
5. [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Azure Container Registry Setup
|
||||
|
||||
### 1. Azure Container Registry Aanmaken
|
||||
|
||||
Als je nog geen ACR hebt, maak er een aan via Azure Portal of Azure CLI:
|
||||
|
||||
```bash
|
||||
# Resource group (als nog niet bestaat)
|
||||
az group create --name rg-cmdb-gui --location westeurope
|
||||
|
||||
# Azure Container Registry aanmaken
|
||||
az acr create \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--name zuyderlandcmdbacr \
|
||||
--sku Basic \
|
||||
--admin-enabled true
|
||||
```
|
||||
|
||||
**ACR SKU Opties:**
|
||||
- **Basic**: Geschikt voor development/test (~€5/maand)
|
||||
- **Standard**: Voor productie met geo-replicatie (~€20/maand)
|
||||
- **Premium**: Voor enterprise met security features (~€50/maand)
|
||||
|
||||
### 2. Registry URL
|
||||
|
||||
Na aanmaken is je registry beschikbaar op:
|
||||
```
|
||||
<acr-name>.azurecr.io
|
||||
```
|
||||
|
||||
Bijvoorbeeld: `zuyderlandcmdbacr.azurecr.io`
|
||||
|
||||
### 3. Authentication
|
||||
|
||||
ACR ondersteunt meerdere authenticatiemethoden:
|
||||
|
||||
**A) Admin Credentials (Eenvoudig, voor development)**
|
||||
```bash
|
||||
# Admin credentials ophalen
|
||||
az acr credential show --name zuyderlandcmdbacr
|
||||
|
||||
# Login met Docker
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
# OF
|
||||
docker login zuyderlandcmdbacr.azurecr.io -u <admin-username> -p <admin-password>
|
||||
```
|
||||
|
||||
**B) Azure Service Principal (Aanbevolen voor CI/CD)**
|
||||
```bash
|
||||
# Service Principal aanmaken
|
||||
az ad sp create-for-rbac --name "zuyderland-cmdb-acr-sp" --role acrpull --scopes /subscriptions/<subscription-id>/resourceGroups/rg-cmdb-gui/providers/Microsoft.ContainerRegistry/registries/zuyderlandcmdbacr
|
||||
|
||||
# Gebruik de output credentials in CI/CD
|
||||
```
|
||||
|
||||
**C) Managed Identity (Best voor Azure services)**
|
||||
- Gebruik Managed Identity voor Azure DevOps, App Service, etc.
|
||||
- Configureer via Azure Portal → ACR → Access Control (IAM)
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Lokale Build & Push
|
||||
|
||||
### Optie 1: Met Script (Aanbevolen)
|
||||
|
||||
Gebruik het `build-and-push-azure.sh` script:
|
||||
|
||||
```bash
|
||||
# Maak script uitvoerbaar
|
||||
chmod +x scripts/build-and-push-azure.sh
|
||||
|
||||
# Build en push (gebruikt 'latest' als versie)
|
||||
./scripts/build-and-push-azure.sh
|
||||
|
||||
# Build en push met specifieke versie
|
||||
./scripts/build-and-push-azure.sh 1.0.0
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
export ACR_NAME="zuyderlandcmdbacr"
|
||||
export REPO_NAME="zuyderland-cmdb-gui"
|
||||
./scripts/build-and-push-azure.sh 1.0.0
|
||||
```
|
||||
|
||||
### Optie 2: Handmatig met Docker Commands
|
||||
|
||||
```bash
|
||||
# Login
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
|
||||
# Set variabelen
|
||||
ACR_NAME="zuyderlandcmdbacr"
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
REPO_NAME="zuyderland-cmdb-gui"
|
||||
VERSION="1.0.0"
|
||||
|
||||
# Build backend
|
||||
docker build -t ${REGISTRY}/${REPO_NAME}/backend:${VERSION} \
|
||||
-t ${REGISTRY}/${REPO_NAME}/backend:latest \
|
||||
-f backend/Dockerfile.prod ./backend
|
||||
|
||||
# Build frontend
|
||||
docker build -t ${REGISTRY}/${REPO_NAME}/frontend:${VERSION} \
|
||||
-t ${REGISTRY}/${REPO_NAME}/frontend:latest \
|
||||
-f frontend/Dockerfile.prod ./frontend
|
||||
|
||||
# Push images
|
||||
docker push ${REGISTRY}/${REPO_NAME}/backend:${VERSION}
|
||||
docker push ${REGISTRY}/${REPO_NAME}/backend:latest
|
||||
docker push ${REGISTRY}/${REPO_NAME}/frontend:${VERSION}
|
||||
docker push ${REGISTRY}/${REPO_NAME}/frontend:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Azure DevOps Pipeline
|
||||
|
||||
### 1. Service Connection Aanmaken
|
||||
|
||||
In Azure DevOps:
|
||||
|
||||
1. **Project Settings** → **Service connections** → **New service connection**
|
||||
2. Kies **Docker Registry**
|
||||
3. Kies **Azure Container Registry**
|
||||
4. Selecteer je Azure subscription en ACR
|
||||
5. Geef een naam: `zuyderland-cmdb-acr-connection`
|
||||
|
||||
### 2. Pipeline Configuratie
|
||||
|
||||
Het project bevat al een `azure-pipelines.yml` bestand. Configureer deze in Azure DevOps:
|
||||
|
||||
1. **Pipelines** → **New pipeline**
|
||||
2. Kies je repository (Azure Repos)
|
||||
3. Kies **Existing Azure Pipelines YAML file**
|
||||
4. Selecteer `azure-pipelines.yml`
|
||||
5. Review en run
|
||||
|
||||
### 3. Pipeline Variabelen Aanpassen
|
||||
|
||||
Pas de variabelen in `azure-pipelines.yml` aan naar jouw instellingen:
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
acrName: 'zuyderlandcmdbacr' # Jouw ACR naam
|
||||
repositoryName: 'zuyderland-cmdb-gui'
|
||||
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection'
|
||||
```
|
||||
|
||||
### 4. Automatische Triggers
|
||||
|
||||
De pipeline triggert automatisch bij:
|
||||
- Push naar `main` branch
|
||||
- Tags die beginnen met `v*` (bijv. `v1.0.0`)
|
||||
|
||||
**Handmatig Triggeren:**
|
||||
```bash
|
||||
# Tag aanmaken en pushen
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Docker Compose Configuration
|
||||
|
||||
### Productie Docker Compose met ACR
|
||||
|
||||
Maak `docker-compose.prod.acr.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- nginx_cache:/var/cache/nginx
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
nginx_cache:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### Gebruik Specifieke Versies
|
||||
|
||||
Voor productie deployments, gebruik specifieke versies:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:v1.0.0
|
||||
|
||||
frontend:
|
||||
image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:v1.0.0
|
||||
```
|
||||
|
||||
### Pull en Deploy
|
||||
|
||||
```bash
|
||||
# Login (als nodig)
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
|
||||
# Pull images
|
||||
docker-compose -f docker-compose.prod.acr.yml pull
|
||||
|
||||
# Deploy
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
|
||||
# Status checken
|
||||
docker-compose -f docker-compose.prod.acr.yml ps
|
||||
|
||||
# Logs bekijken
|
||||
docker-compose -f docker-compose.prod.acr.yml logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### 1. Versioning
|
||||
|
||||
- **Gebruik semantic versioning**: `v1.0.0`, `v1.0.1`, etc.
|
||||
- **Tag altijd als `latest`**: Voor development/CI/CD
|
||||
- **Productie**: Gebruik specifieke versies, nooit `latest`
|
||||
|
||||
```bash
|
||||
# Tag met versie
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
|
||||
# Build met versie
|
||||
./scripts/build-and-push-azure.sh 1.0.0
|
||||
```
|
||||
|
||||
### 2. Security
|
||||
|
||||
- **Admin credentials uitschakelen** in productie (gebruik Service Principal)
|
||||
- **Enable Content Trust** voor image signing (optioneel)
|
||||
- **Scan images** voor vulnerabilities (Azure Security Center)
|
||||
|
||||
```bash
|
||||
# Admin uitschakelen
|
||||
az acr update --name zuyderlandcmdbacr --admin-enabled false
|
||||
```
|
||||
|
||||
### 3. Image Cleanup
|
||||
|
||||
ACR heeft een retention policy voor oude images:
|
||||
|
||||
```bash
|
||||
# Retention policy instellen (bijv. laatste 10 tags behouden)
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend --orderby time_desc --top 10
|
||||
|
||||
# Oude tags verwijderen (handmatig of via policy)
|
||||
az acr repository delete --name zuyderlandcmdbacr --image zuyderland-cmdb-gui/backend:old-tag
|
||||
```
|
||||
|
||||
### 4. Multi-Stage Builds
|
||||
|
||||
De `Dockerfile.prod` bestanden gebruiken al multi-stage builds voor kleinere images.
|
||||
|
||||
### 5. Build Cache
|
||||
|
||||
Voor snellere builds, gebruik build cache:
|
||||
|
||||
```bash
|
||||
# Build met cache
|
||||
docker build --cache-from zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \
|
||||
-t zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:new-tag \
|
||||
-f backend/Dockerfile.prod ./backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
```bash
|
||||
# Check Azure login
|
||||
az account show
|
||||
|
||||
# Re-login
|
||||
az login
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
|
||||
# Check Docker login
|
||||
cat ~/.docker/config.json
|
||||
```
|
||||
|
||||
### Build Errors
|
||||
|
||||
```bash
|
||||
# Build met verbose output
|
||||
docker build --progress=plain -t test-image -f backend/Dockerfile.prod ./backend
|
||||
|
||||
# Check lokale images
|
||||
docker images | grep zuyderland-cmdb-gui
|
||||
```
|
||||
|
||||
### Push Errors
|
||||
|
||||
```bash
|
||||
# Check ACR connectivity
|
||||
az acr check-health --name zuyderlandcmdbacr
|
||||
|
||||
# Check repository exists
|
||||
az acr repository list --name zuyderlandcmdbacr
|
||||
|
||||
# View repository tags
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
|
||||
```
|
||||
|
||||
### Azure DevOps Pipeline Errors
|
||||
|
||||
- Check **Service Connection** permissions
|
||||
- Verify **ACR naam** in pipeline variables
|
||||
- Check **Dockerfile paths** zijn correct
|
||||
- Review pipeline logs in Azure DevOps
|
||||
|
||||
---
|
||||
|
||||
## 📝 Usage Examples
|
||||
|
||||
### Eenvoudige Workflow
|
||||
|
||||
```bash
|
||||
# 1. Code aanpassen en committen
|
||||
git add .
|
||||
git commit -m "Update feature"
|
||||
git push origin main
|
||||
|
||||
# 2. Build en push naar ACR
|
||||
./scripts/build-and-push-azure.sh
|
||||
|
||||
# 3. Deploy (op productie server)
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
docker-compose -f docker-compose.prod.acr.yml pull
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
```
|
||||
|
||||
### Versioned Release
|
||||
|
||||
```bash
|
||||
# 1. Tag release
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
|
||||
# 2. Build en push met versie
|
||||
./scripts/build-and-push-azure.sh 1.0.0
|
||||
|
||||
# 3. Update docker-compose met versie
|
||||
# Edit docker-compose.prod.acr.yml: image: ...backend:v1.0.0
|
||||
|
||||
# 4. Deploy
|
||||
docker-compose -f docker-compose.prod.acr.yml pull
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
```
|
||||
|
||||
### Azure DevOps Automated
|
||||
|
||||
1. Push code naar `main` → Pipeline triggert automatisch
|
||||
2. Pipeline bouwt images en pusht naar ACR
|
||||
3. Deploy handmatig of via release pipeline
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Azure Container Registry Documentation](https://docs.microsoft.com/en-us/azure/container-registry/)
|
||||
- [Azure DevOps Docker Task](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/build/docker)
|
||||
- [ACR Best Practices](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-best-practices)
|
||||
- [Docker Compose Production Guide](./PRODUCTION-DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Vergelijking: Gitea vs Azure Container Registry
|
||||
|
||||
| Feature | Gitea Registry | Azure Container Registry |
|
||||
|---------|---------------|-------------------------|
|
||||
| **Kosten** | Gratis (met Gitea) | €5-50/maand (afhankelijk van SKU) |
|
||||
| **Security** | Basic | Enterprise-grade (RBAC, scanning) |
|
||||
| **CI/CD** | Gitea Actions | Azure DevOps, GitHub Actions |
|
||||
| **Geo-replicatie** | Nee | Ja (Standard/Premium) |
|
||||
| **Image Scanning** | Nee | Ja (Azure Security Center) |
|
||||
| **Integratie** | Gitea ecosystem | Azure ecosystem (App Service, AKS, etc.) |
|
||||
|
||||
**Aanbeveling:**
|
||||
- **Development/Test**: Gitea Registry (gratis, eenvoudig)
|
||||
- **Productie**: Azure Container Registry (security, enterprise features)
|
||||
310
docs/AZURE-DEVOPS-SETUP.md
Normal file
310
docs/AZURE-DEVOPS-SETUP.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Azure DevOps Setup - Stap voor Stap Guide
|
||||
|
||||
Nu je code in Azure DevOps staat, volg deze stappen om Docker images automatisch te bouwen en naar Azure Container Registry te pushen.
|
||||
|
||||
## 📋 Stappenplan
|
||||
|
||||
### Stap 1: Azure Container Registry Aanmaken
|
||||
|
||||
Als je nog geen Azure Container Registry hebt:
|
||||
|
||||
**Optie A: Met Script (Aanbevolen) 🚀**
|
||||
```bash
|
||||
# Run het script (interactief)
|
||||
./scripts/create-acr.sh
|
||||
|
||||
# Of met custom parameters:
|
||||
./scripts/create-acr.sh rg-cmdb-gui zuyderlandcmdbacr westeurope Basic
|
||||
```
|
||||
|
||||
Het script doet automatisch:
|
||||
- ✅ Checkt of je ingelogd bent bij Azure
|
||||
- ✅ Maakt resource group aan (als nodig)
|
||||
- ✅ Controleert of ACR naam beschikbaar is
|
||||
- ✅ Maakt ACR aan met Basic SKU
|
||||
- ✅ Toont credentials
|
||||
- ✅ Test Docker login
|
||||
|
||||
**Optie B: Via Azure CLI (Handmatig)**
|
||||
```bash
|
||||
# Login bij Azure
|
||||
az login
|
||||
|
||||
# Resource group aanmaken (als nog niet bestaat)
|
||||
az group create --name rg-cmdb-gui --location westeurope
|
||||
|
||||
# Container Registry aanmaken
|
||||
az acr create \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--name zuyderlandcmdbacr \
|
||||
--sku Basic \
|
||||
--admin-enabled true
|
||||
```
|
||||
|
||||
**Optie C: Via Azure Portal**
|
||||
1. Ga naar [Azure Portal](https://portal.azure.com)
|
||||
2. Klik op **"Create a resource"**
|
||||
3. Zoek naar **"Container Registry"**
|
||||
4. Klik **"Create"**
|
||||
5. Vul in:
|
||||
- **Resource group**: Kies bestaande of maak nieuwe (bijv. `rg-cmdb-gui`)
|
||||
- **Registry name**: Bijv. `zuyderlandcmdbacr` (moet uniek zijn, alleen kleine letters en cijfers)
|
||||
- **Location**: `West Europe` (of gewenste regio)
|
||||
- **SKU**: `Basic` (voor development/test) of `Standard` (voor productie)
|
||||
6. Klik **"Review + create"** → **"Create"**
|
||||
|
||||
**Noteer je ACR naam!** Je hebt deze nodig in de volgende stappen.
|
||||
|
||||
**📚 Zie `docs/AZURE-ACR-QUICKSTART.md` voor een complete quick-start guide.**
|
||||
|
||||
---
|
||||
|
||||
### Stap 2: Service Connection Aanmaken in Azure DevOps
|
||||
|
||||
Deze connection geeft Azure DevOps toegang tot je Azure Container Registry.
|
||||
|
||||
1. **Ga naar je Azure DevOps project**
|
||||
2. Klik op **⚙️ Project Settings** (onderaan links)
|
||||
3. Ga naar **Service connections** (onder Pipelines)
|
||||
4. Klik op **"New service connection"**
|
||||
5. Kies **"Docker Registry"**
|
||||
6. Kies **"Azure Container Registry"**
|
||||
7. Vul in:
|
||||
- **Azure subscription**: Selecteer je Azure subscription
|
||||
- **Azure container registry**: Selecteer je ACR (bijv. `zuyderlandcmdbacr`)
|
||||
- **Service connection name**: Bijv. `zuyderland-cmdb-acr-connection`
|
||||
- **Description**: Optioneel
|
||||
8. Klik **"Save"**
|
||||
|
||||
**✅ Noteer de service connection naam!** Je hebt deze nodig voor de pipeline.
|
||||
|
||||
---
|
||||
|
||||
### Stap 3: Pipeline Variabelen Aanpassen
|
||||
|
||||
Pas de `azure-pipelines.yml` aan naar jouw instellingen:
|
||||
|
||||
1. **Open** `azure-pipelines.yml` in je repository
|
||||
2. **Pas de variabelen aan** (regel 15-20):
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
# Pas deze aan naar jouw ACR naam
|
||||
acrName: 'zuyderlandcmdbacr' # ← Jouw ACR naam hier
|
||||
|
||||
repositoryName: 'zuyderland-cmdb-gui'
|
||||
|
||||
# Pas deze aan naar de service connection naam die je net hebt gemaakt
|
||||
dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' # ← Jouw service connection naam
|
||||
|
||||
imageTag: '$(Build.BuildId)'
|
||||
```
|
||||
|
||||
3. **Commit en push** de wijzigingen:
|
||||
```bash
|
||||
git add azure-pipelines.yml
|
||||
git commit -m "Configure Azure DevOps pipeline"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stap 4: Pipeline Aanmaken in Azure DevOps
|
||||
|
||||
1. **Ga naar je Azure DevOps project**
|
||||
2. Klik op **Pipelines** (links in het menu)
|
||||
3. Klik op **"New pipeline"** of **"Create Pipeline"**
|
||||
4. Kies **"Azure Repos Git"** (of waar je code staat)
|
||||
5. Selecteer je repository: **"Zuyderland CMDB GUI"** (of jouw repo naam)
|
||||
6. Kies **"Existing Azure Pipelines YAML file"**
|
||||
7. Selecteer:
|
||||
- **Branch**: `main`
|
||||
- **Path**: `/azure-pipelines.yml`
|
||||
8. Klik **"Continue"**
|
||||
9. **Review** de pipeline configuratie
|
||||
10. Klik **"Run"** om de pipeline te starten
|
||||
|
||||
---
|
||||
|
||||
### Stap 5: Pipeline Uitvoeren
|
||||
|
||||
De pipeline start automatisch en zal:
|
||||
|
||||
1. ✅ Code uitchecken
|
||||
2. ✅ Backend Docker image bouwen
|
||||
3. ✅ Frontend Docker image bouwen
|
||||
4. ✅ Images naar Azure Container Registry pushen
|
||||
|
||||
**Je kunt de voortgang volgen:**
|
||||
- Klik op de running pipeline
|
||||
- Bekijk de logs per stap
|
||||
- Bij success zie je de image URLs
|
||||
|
||||
**Verwachte output:**
|
||||
```
|
||||
Backend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:123
|
||||
Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stap 6: Images Verifiëren
|
||||
|
||||
**In Azure Portal:**
|
||||
1. Ga naar je **Container Registry** (`zuyderlandcmdbacr`)
|
||||
2. Klik op **"Repositories"**
|
||||
3. Je zou moeten zien:
|
||||
- `zuyderland-cmdb-gui/backend`
|
||||
- `zuyderland-cmdb-gui/frontend`
|
||||
4. Klik op een repository om de tags te zien (bijv. `latest`, `123`)
|
||||
|
||||
**Via Azure CLI:**
|
||||
```bash
|
||||
# Lijst repositories
|
||||
az acr repository list --name zuyderlandcmdbacr
|
||||
|
||||
# Lijst tags voor backend
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
|
||||
|
||||
# Lijst tags voor frontend
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Automatische Triggers
|
||||
|
||||
De pipeline triggert automatisch bij:
|
||||
|
||||
1. **Push naar `main` branch** → Bouwt `latest` tag
|
||||
2. **Git tags die beginnen met `v*`** → Bouwt versie tag (bijv. `v1.0.0`)
|
||||
|
||||
**Voorbeeld:**
|
||||
```bash
|
||||
# Tag aanmaken en pushen
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
# → Pipeline triggert automatisch en bouwt versie 1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Pipeline Fails: "Service connection not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de service connection naam in `azure-pipelines.yml` overeenkomt met de naam in Azure DevOps
|
||||
- Ga naar Project Settings → Service connections en verifieer de naam
|
||||
|
||||
### Pipeline Fails: "ACR not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de `acrName` variabele correct is in `azure-pipelines.yml`
|
||||
- Verifieer dat de ACR bestaat: `az acr list`
|
||||
|
||||
### Pipeline Fails: "Permission denied"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de service connection de juiste permissions heeft
|
||||
- Verifieer dat je Azure subscription toegang heeft tot de ACR
|
||||
- Probeer de service connection opnieuw aan te maken
|
||||
|
||||
### Images worden niet gepusht
|
||||
|
||||
**Oplossing:**
|
||||
- Check de pipeline logs voor specifieke errors
|
||||
- Verifieer dat de Docker build succesvol is
|
||||
- Controleer of de ACR admin-enabled is (voor development)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Volgende Stappen: Deployment
|
||||
|
||||
Nu je images in Azure Container Registry staan, kun je ze deployen:
|
||||
|
||||
### Optie 1: Azure App Service
|
||||
|
||||
```bash
|
||||
# Web App aanmaken en configureren
|
||||
az webapp create --name cmdb-backend --resource-group rg-cmdb-gui --plan plan-cmdb-gui
|
||||
az webapp config container set --name cmdb-backend --resource-group rg-cmdb-gui \
|
||||
--docker-custom-image-name zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \
|
||||
--docker-registry-server-url https://zuyderlandcmdbacr.azurecr.io
|
||||
```
|
||||
|
||||
### Optie 2: Docker Compose op VM
|
||||
|
||||
```bash
|
||||
# Login bij ACR
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
|
||||
# Pull images
|
||||
docker-compose -f docker-compose.prod.acr.yml pull
|
||||
|
||||
# Deploy
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
```
|
||||
|
||||
### Optie 3: Azure Container Instances (ACI)
|
||||
|
||||
```bash
|
||||
az container create \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--name cmdb-backend \
|
||||
--image zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \
|
||||
--registry-login-server zuyderlandcmdbacr.azurecr.io \
|
||||
--registry-username <acr-username> \
|
||||
--registry-password <acr-password>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Checklist
|
||||
|
||||
- [ ] Azure Container Registry aangemaakt
|
||||
- [ ] Service Connection geconfigureerd in Azure DevOps
|
||||
- [ ] `azure-pipelines.yml` variabelen aangepast
|
||||
- [ ] Pipeline aangemaakt en gerund
|
||||
- [ ] Images succesvol gebouwd en gepusht
|
||||
- [ ] Images geverifieerd in Azure Portal
|
||||
- [ ] Automatische triggers getest (push naar main)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Reference
|
||||
|
||||
**ACR Login:**
|
||||
```bash
|
||||
az acr login --name zuyderlandcmdbacr
|
||||
```
|
||||
|
||||
**Images Lijsten:**
|
||||
```bash
|
||||
az acr repository list --name zuyderlandcmdbacr
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend
|
||||
```
|
||||
|
||||
**Pipeline Handmatig Triggeren:**
|
||||
- Ga naar Pipelines → Selecteer pipeline → "Run pipeline"
|
||||
|
||||
**Pipeline Logs Bekijken:**
|
||||
- Ga naar Pipelines → Selecteer pipeline → Klik op de run → Bekijk logs per stap
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure Container Registry Docs](https://docs.microsoft.com/en-us/azure/container-registry/)
|
||||
- [Azure DevOps Pipelines Docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/)
|
||||
- [Docker Task Reference](https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/build/docker)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Gebruik versie tags voor productie**: Tag releases met `v1.0.0`, `v1.0.1`, etc.
|
||||
2. **Monitor pipeline costs**: Azure DevOps geeft gratis build minuten per maand
|
||||
3. **Enable retention policies**: Configureer ACR om oude images automatisch te verwijderen
|
||||
4. **Use build caching**: Voor snellere builds bij volgende runs
|
||||
5. **Set up notifications**: Configureer email/Slack notificaties voor pipeline status
|
||||
250
docs/AZURE-PIPELINE-REPO-TROUBLESHOOTING.md
Normal file
250
docs/AZURE-PIPELINE-REPO-TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Azure DevOps Pipeline - Repository Not Found
|
||||
|
||||
## 🔴 Probleem: "No matching repositories were found"
|
||||
|
||||
Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline, probeer deze oplossingen:
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 1: Check Repository Naam
|
||||
|
||||
**Probleem:** De repository naam komt mogelijk niet overeen.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Ga naar Repos** (links in het menu)
|
||||
2. **Check de exacte repository naam**
|
||||
- Kijk naar de repository die je hebt gepusht
|
||||
- Noteer de exacte naam (inclusief hoofdletters/spaties)
|
||||
|
||||
3. **In de pipeline wizard:**
|
||||
- Zoek naar de repository met de exacte naam
|
||||
- Of probeer verschillende variaties:
|
||||
- `Zuyderland CMDB GUI`
|
||||
- `zuyderland-cmdb-gui`
|
||||
- `ZuyderlandCMDBGUI`
|
||||
|
||||
**Jouw repository naam zou moeten zijn:** `Zuyderland CMDB GUI` (met spaties)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 2: Check Repository Type
|
||||
|
||||
**In de pipeline wizard, probeer verschillende repository types:**
|
||||
|
||||
1. **Azure Repos Git** (als je code in Azure DevOps staat)
|
||||
- Dit is waarschijnlijk wat je nodig hebt
|
||||
- Check of je repository hier staat
|
||||
|
||||
2. **GitHub** (als je code in GitHub staat)
|
||||
- Niet van toepassing voor jou
|
||||
|
||||
3. **Other Git** (als je code ergens anders staat)
|
||||
- Niet van toepassing voor jou
|
||||
|
||||
**Voor jouw situatie:** Kies **"Azure Repos Git"**
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 3: Check Repository Toegang
|
||||
|
||||
**Probleem:** Je hebt mogelijk geen toegang tot de repository.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Ga naar Repos** (links in het menu)
|
||||
2. **Check of je de repository ziet**
|
||||
- Als je de repository niet ziet, heb je mogelijk geen toegang
|
||||
3. **Check permissions:**
|
||||
- Project Settings → Repositories → Security
|
||||
- Check of je account toegang heeft
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 4: Check Project
|
||||
|
||||
**Probleem:** Je bent mogelijk in het verkeerde project.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Check het project naam** (bovenaan links)
|
||||
- Moet zijn: **"JiraAssetsCMDB"**
|
||||
2. **Als je in een ander project bent:**
|
||||
- Klik op het project dropdown (bovenaan links)
|
||||
- Selecteer **"JiraAssetsCMDB"**
|
||||
3. **Probeer opnieuw** de pipeline aan te maken
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 5: Refresh/Herlaad
|
||||
|
||||
**Soms helpt een simpele refresh:**
|
||||
|
||||
1. **Refresh de browser pagina** (F5 of Cmd+R)
|
||||
2. **Sluit en open opnieuw** de pipeline wizard
|
||||
3. **Probeer opnieuw**
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 6: Check of Repository Bestaat
|
||||
|
||||
**Probleem:** De repository bestaat mogelijk niet in Azure DevOps.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Ga naar Repos** (links in het menu)
|
||||
2. **Check of je repository zichtbaar is**
|
||||
- Je zou moeten zien: `Zuyderland CMDB GUI` (of jouw repo naam)
|
||||
3. **Als de repository niet bestaat:**
|
||||
- Je moet eerst de code pushen naar Azure DevOps
|
||||
- Of de repository aanmaken in Azure DevOps
|
||||
|
||||
**Check of je code al in Azure DevOps staat:**
|
||||
- Ga naar Repos → Files
|
||||
- Je zou je code moeten zien (bijv. `azure-pipelines.yml`, `backend/`, `frontend/`, etc.)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 7: Maak Repository Aan (Als Die Niet Bestaat)
|
||||
|
||||
**Als de repository nog niet bestaat in Azure DevOps:**
|
||||
|
||||
### Optie A: Push Code naar Bestaande Repository
|
||||
|
||||
**Als de repository al bestaat maar leeg is:**
|
||||
|
||||
1. **Check de repository URL:**
|
||||
```
|
||||
git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI
|
||||
```
|
||||
|
||||
2. **Push je code:**
|
||||
```bash
|
||||
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
|
||||
git push azure main
|
||||
```
|
||||
|
||||
3. **Check in Azure DevOps:**
|
||||
- Ga naar Repos → Files
|
||||
- Je zou je code moeten zien
|
||||
|
||||
### Optie B: Maak Nieuwe Repository Aan
|
||||
|
||||
**Als de repository helemaal niet bestaat:**
|
||||
|
||||
1. **Ga naar Repos** (links in het menu)
|
||||
2. **Klik op "New repository"** of het "+" icoon
|
||||
3. **Vul in:**
|
||||
- **Repository name**: `Zuyderland CMDB GUI`
|
||||
- **Type**: Git
|
||||
4. **Create**
|
||||
5. **Push je code:**
|
||||
```bash
|
||||
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
|
||||
git remote add azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI
|
||||
git push azure main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 8: Gebruik "Other Git" Als Workaround
|
||||
|
||||
**Als niets werkt, gebruik "Other Git" als tijdelijke oplossing:**
|
||||
|
||||
1. **In de pipeline wizard:**
|
||||
- Kies **"Other Git"** (in plaats van "Azure Repos Git")
|
||||
2. **Vul in:**
|
||||
- **Repository URL**: `git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI`
|
||||
- Of HTTPS: `https://ZuyderlandMedischCentrum@dev.azure.com/ZuyderlandMedischCentrum/JiraAssetsCMDB/_git/Zuyderland%20CMDB%20GUI`
|
||||
3. **Branch**: `main`
|
||||
4. **Continue**
|
||||
|
||||
**⚠️ Let op:** Dit werkt, maar "Azure Repos Git" is de voorkeursoptie.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Diagnose Stappen
|
||||
|
||||
**Om te diagnosticeren wat het probleem is:**
|
||||
|
||||
### 1. Check of Repository Bestaat
|
||||
|
||||
1. Ga naar **Repos** (links in het menu)
|
||||
2. Check of je `Zuyderland CMDB GUI` ziet
|
||||
3. Klik erop en check of je code ziet
|
||||
|
||||
### 2. Check Repository URL
|
||||
|
||||
**In Terminal:**
|
||||
```bash
|
||||
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
|
||||
git remote -v
|
||||
```
|
||||
|
||||
**Je zou moeten zien:**
|
||||
```
|
||||
azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI (fetch)
|
||||
azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI (push)
|
||||
```
|
||||
|
||||
### 3. Check of Code Gepusht is
|
||||
|
||||
**In Terminal:**
|
||||
```bash
|
||||
git log azure/main --oneline -5
|
||||
```
|
||||
|
||||
**Als je commits ziet:** ✅ Code is gepusht
|
||||
**Als je een fout krijgt:** ❌ Code is niet gepusht
|
||||
|
||||
### 4. Push Code (Als Niet Gepusht)
|
||||
|
||||
```bash
|
||||
git push azure main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Aanbevolen Aanpak
|
||||
|
||||
**Probeer in deze volgorde:**
|
||||
|
||||
1. ✅ **Check Repos** - Ga naar Repos en check of je repository bestaat
|
||||
2. ✅ **Check project naam** - Zorg dat je in "JiraAssetsCMDB" project bent
|
||||
3. ✅ **Refresh pagina** - Soms helpt een simpele refresh
|
||||
4. ✅ **Push code** - Als repository leeg is, push je code
|
||||
5. ✅ **Gebruik "Other Git"** - Als workaround
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Fix (Meest Waarschijnlijk)
|
||||
|
||||
**Het probleem is waarschijnlijk dat de repository leeg is of niet bestaat:**
|
||||
|
||||
1. **Check in Azure DevOps:**
|
||||
- Ga naar **Repos** → **Files**
|
||||
- Check of je code ziet (bijv. `azure-pipelines.yml`)
|
||||
|
||||
2. **Als repository leeg is:**
|
||||
```bash
|
||||
cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui
|
||||
git push azure main
|
||||
```
|
||||
|
||||
3. **Probeer opnieuw** de pipeline aan te maken
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure DevOps Repositories](https://learn.microsoft.com/en-us/azure/devops/repos/)
|
||||
- [Create Pipeline from Repository](https://learn.microsoft.com/en-us/azure/devops/pipelines/create-first-pipeline)
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Nog Steeds Problemen?
|
||||
|
||||
Als niets werkt:
|
||||
|
||||
1. **Check of je in het juiste project bent** (JiraAssetsCMDB)
|
||||
2. **Check of de repository bestaat** (Repos → Files)
|
||||
3. **Push je code** naar Azure DevOps
|
||||
4. **Gebruik "Other Git"** als workaround
|
||||
|
||||
**De "Other Git" optie werkt altijd**, ook als de repository niet wordt gevonden in de dropdown.
|
||||
283
docs/AZURE-REGISTRY-BESLISSING.md
Normal file
283
docs/AZURE-REGISTRY-BESLISSING.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Azure Container Registry - Moet ik dit aanvragen?
|
||||
|
||||
## 🤔 Korte Antwoord
|
||||
|
||||
**Het hangt af van je deployment strategie:**
|
||||
|
||||
1. **Azure App Service (zonder containers)** → ❌ **Geen ACR nodig**
|
||||
- Direct deployment van code
|
||||
- Eenvoudiger en goedkoper
|
||||
- **Aanbevolen voor jouw situatie** (20 gebruikers)
|
||||
|
||||
2. **Container-based deployment** → ✅ **ACR nodig** (of alternatief)
|
||||
- Azure Container Instances (ACI)
|
||||
- Azure Kubernetes Service (AKS)
|
||||
- VM met Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 📊 Deployment Opties Vergelijking
|
||||
|
||||
### Optie 1: Azure App Service (Zonder Containers) ⭐ **AANBEVOLEN**
|
||||
|
||||
**Wat je nodig hebt:**
|
||||
- ✅ Azure App Service Plan (B1) - €15-25/maand
|
||||
- ✅ Azure Key Vault - €1-2/maand
|
||||
- ✅ Database (PostgreSQL of SQLite) - €1-30/maand
|
||||
- ❌ **Geen Container Registry nodig!**
|
||||
|
||||
**Hoe het werkt:**
|
||||
- Azure DevOps bouwt je code direct
|
||||
- Deployt naar App Service via ZIP deploy of Git
|
||||
- Geen Docker images nodig
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Eenvoudiger setup
|
||||
- ✅ Goedkoper (geen ACR kosten)
|
||||
- ✅ Snellere deployments
|
||||
- ✅ Managed service (minder onderhoud)
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Minder flexibel dan containers
|
||||
- ❌ Platform-specifiek (Azure only)
|
||||
|
||||
**Voor jouw situatie:** ✅ **Dit is de beste optie!**
|
||||
|
||||
---
|
||||
|
||||
### Optie 2: Container Registry (Als je containers wilt gebruiken)
|
||||
|
||||
**Je hebt 3 keuzes voor een registry:**
|
||||
|
||||
#### A) Azure Container Registry (ACR) 💰
|
||||
|
||||
**Kosten:**
|
||||
- Basic: ~€5/maand
|
||||
- Standard: ~€20/maand
|
||||
- Premium: ~€50/maand
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Integratie met Azure services
|
||||
- ✅ Security scanning (Premium)
|
||||
- ✅ Geo-replicatie (Standard/Premium)
|
||||
- ✅ RBAC integratie
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Extra kosten
|
||||
- ❌ Moet aangevraagd worden bij IT
|
||||
|
||||
**Wanneer aanvragen:**
|
||||
- Als je containers gebruikt in productie
|
||||
- Als je Azure-native deployment wilt
|
||||
- Als je security scanning nodig hebt
|
||||
|
||||
---
|
||||
|
||||
#### B) Gitea Container Registry (Gratis) 🆓
|
||||
|
||||
**Kosten:** Gratis (als je al Gitea hebt)
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Geen extra kosten
|
||||
- ✅ Al beschikbaar (als Gitea dit ondersteunt)
|
||||
- ✅ Eenvoudig te gebruiken
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Minder features dan ACR
|
||||
- ❌ Geen security scanning
|
||||
- ❌ Geen geo-replicatie
|
||||
|
||||
**Je hebt al:**
|
||||
- ✅ Script: `scripts/build-and-push.sh`
|
||||
- ✅ Config: `docker-compose.prod.registry.yml`
|
||||
- ✅ Documentatie: `docs/GITEA-DOCKER-REGISTRY.md`
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Development/test omgevingen
|
||||
- ✅ Als je al Gitea hebt met registry enabled
|
||||
- ✅ Kleine projecten zonder enterprise requirements
|
||||
|
||||
---
|
||||
|
||||
#### C) Docker Hub (Gratis/Paid)
|
||||
|
||||
**Kosten:**
|
||||
- Free: 1 private repo, unlimited public
|
||||
- Pro: $5/maand voor unlimited private repos
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Eenvoudig te gebruiken
|
||||
- ✅ Gratis voor public images
|
||||
- ✅ Wereldwijd beschikbaar
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Rate limits op free tier
|
||||
- ❌ Minder integratie met Azure
|
||||
- ❌ Security concerns (voor private data)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- Development/test
|
||||
- Public images
|
||||
- Als je geen Azure-native oplossing nodig hebt
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Aanbeveling voor Jouw Situatie
|
||||
|
||||
### Scenario 1: Eenvoudige Productie Deployment (Aanbevolen) ⭐
|
||||
|
||||
**Gebruik: Azure App Service zonder containers**
|
||||
|
||||
**Waarom:**
|
||||
- ✅ Geen ACR nodig
|
||||
- ✅ Eenvoudiger en goedkoper
|
||||
- ✅ Voldoende voor 20 gebruikers
|
||||
- ✅ Minder complexiteit
|
||||
|
||||
**Stappen:**
|
||||
1. **Niet nodig:** ACR aanvragen
|
||||
2. **Wel nodig:** Azure App Service Plan aanvragen
|
||||
3. **Pipeline aanpassen:** Gebruik Azure App Service deployment task in plaats van Docker
|
||||
|
||||
**Pipeline aanpassing:**
|
||||
```yaml
|
||||
# In plaats van Docker build/push:
|
||||
- task: AzureWebApp@1
|
||||
inputs:
|
||||
azureSubscription: 'your-subscription'
|
||||
appName: 'cmdb-backend'
|
||||
package: '$(System.DefaultWorkingDirectory)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Container-based Deployment
|
||||
|
||||
**Als je toch containers wilt gebruiken:**
|
||||
|
||||
**Optie A: Gebruik Gitea Registry (als beschikbaar)**
|
||||
- ✅ Geen aanvraag nodig
|
||||
- ✅ Gratis
|
||||
- ✅ Al geconfigureerd in je project
|
||||
|
||||
**Optie B: Vraag ACR aan bij IT**
|
||||
- 📧 Stuur een request naar IT/Infrastructure team
|
||||
- 📋 Vermeld: "Azure Container Registry - Basic tier voor CMDB GUI project"
|
||||
- 💰 Budget: ~€5-20/maand (afhankelijk van tier)
|
||||
|
||||
**Request Template:**
|
||||
```
|
||||
Onderwerp: Azure Container Registry aanvraag - CMDB GUI Project
|
||||
|
||||
Beste IT Team,
|
||||
|
||||
Voor het Zuyderland CMDB GUI project hebben we een Azure Container Registry nodig
|
||||
voor het hosten van Docker images.
|
||||
|
||||
Details:
|
||||
- Project: Zuyderland CMDB GUI
|
||||
- Registry naam: zuyderlandcmdbacr (of zoals jullie naming convention)
|
||||
- SKU: Basic (voor development/productie)
|
||||
- Resource Group: rg-cmdb-gui
|
||||
- Location: West Europe
|
||||
- Doel: Hosten van backend en frontend Docker images voor productie deployment
|
||||
|
||||
Geschatte kosten: €5-20/maand (Basic tier)
|
||||
|
||||
Alvast bedankt!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Beslissingsmatrix
|
||||
|
||||
| Situatie | Registry Nodig? | Welke? | Kosten |
|
||||
|----------|----------------|--------|--------|
|
||||
| **Azure App Service (code deploy)** | ❌ Nee | - | €0 |
|
||||
| **Gitea Registry beschikbaar** | ✅ Ja | Gitea | €0 |
|
||||
| **Containers + Azure native** | ✅ Ja | ACR Basic | €5/maand |
|
||||
| **Containers + Security scanning** | ✅ Ja | ACR Standard | €20/maand |
|
||||
| **Development/test only** | ✅ Ja | Docker Hub (free) | €0 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start: Wat Moet Je Nu Doen?
|
||||
|
||||
### Als je Azure App Service gebruikt (Aanbevolen):
|
||||
|
||||
1. ✅ **Geen ACR nodig** - Skip deze stap
|
||||
2. ✅ Vraag **Azure App Service Plan** aan bij IT
|
||||
3. ✅ Configureer pipeline voor App Service deployment
|
||||
4. ✅ Gebruik bestaande `azure-pipelines.yml` maar pas aan voor App Service
|
||||
|
||||
### Als je containers gebruikt:
|
||||
|
||||
1. ✅ **Check eerst:** Is Gitea Container Registry beschikbaar?
|
||||
- Zo ja → Gebruik Gitea (gratis, al geconfigureerd)
|
||||
- Zo nee → Vraag ACR Basic aan bij IT
|
||||
|
||||
2. ✅ Als je ACR aanvraagt:
|
||||
- Stuur request naar IT team
|
||||
- Gebruik request template hierboven
|
||||
- Wacht op goedkeuring
|
||||
|
||||
3. ✅ Configureer pipeline:
|
||||
- Pas `azure-pipelines.yml` aan met ACR naam
|
||||
- Maak service connection in Azure DevOps
|
||||
- Test pipeline
|
||||
|
||||
---
|
||||
|
||||
## 💡 Mijn Aanbeveling
|
||||
|
||||
**Voor jouw situatie (20 gebruikers, interne tool):**
|
||||
|
||||
1. **Start met Azure App Service** (zonder containers)
|
||||
- Eenvoudiger
|
||||
- Goedkoper
|
||||
- Voldoende functionaliteit
|
||||
|
||||
2. **Als je later containers nodig hebt:**
|
||||
- Gebruik eerst Gitea Registry (als beschikbaar)
|
||||
- Vraag ACR aan als Gitea niet voldoet
|
||||
|
||||
3. **Vraag ACR alleen aan als:**
|
||||
- Je security scanning nodig hebt
|
||||
- Je geo-replicatie nodig hebt
|
||||
- Je Azure-native container deployment wilt
|
||||
|
||||
---
|
||||
|
||||
## ❓ Vragen voor IT Team
|
||||
|
||||
Als je ACR wilt aanvragen, vraag dan:
|
||||
|
||||
1. **Hebben we al een ACR?** (misschien kunnen we die delen)
|
||||
2. **Wat is de naming convention?** (voor registry naam)
|
||||
3. **Welke SKU is aanbevolen?** (Basic/Standard/Premium)
|
||||
4. **Welke resource group gebruiken we?** (best practices)
|
||||
5. **Zijn er compliance requirements?** (security scanning, etc.)
|
||||
6. **Heeft Gitea Container Registry?** (gratis alternatief)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- **Azure App Service Deployment**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
|
||||
- **Gitea Registry**: `docs/GITEA-DOCKER-REGISTRY.md`
|
||||
- **Azure Container Registry**: `docs/AZURE-CONTAINER-REGISTRY.md`
|
||||
- **Azure DevOps Setup**: `docs/AZURE-DEVOPS-SETUP.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Conclusie
|
||||
|
||||
**Kort antwoord:**
|
||||
- **Azure App Service?** → ❌ Geen ACR nodig
|
||||
- **Containers?** → ✅ ACR nodig (of Gitea/Docker Hub)
|
||||
- **Aanbeveling:** Start met App Service, vraag ACR later aan als nodig
|
||||
|
||||
**Actie:**
|
||||
1. Beslis: App Service of Containers?
|
||||
2. Als Containers: Check Gitea Registry eerst
|
||||
3. Als ACR nodig: Vraag aan bij IT met request template
|
||||
231
docs/AZURE-SERVICE-CONNECTION-AUTH.md
Normal file
231
docs/AZURE-SERVICE-CONNECTION-AUTH.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Azure DevOps Service Connection - Authentication Type
|
||||
|
||||
## 🎯 Aanbeveling voor Jouw Situatie
|
||||
|
||||
**Voor Zuyderland CMDB GUI met Azure Container Registry:**
|
||||
|
||||
### ✅ **Service Principal** (Aanbevolen) ⭐
|
||||
|
||||
**Waarom:**
|
||||
- ✅ Werkt altijd en is betrouwbaar
|
||||
- ✅ Meest ondersteunde optie
|
||||
- ✅ Eenvoudig te configureren
|
||||
- ✅ Werkt perfect met Azure Container Registry
|
||||
- ✅ Geen speciale vereisten
|
||||
|
||||
---
|
||||
|
||||
## 📊 Opties Vergelijking
|
||||
|
||||
### Optie 1: **Service Principal** ⭐ **AANBEVOLEN**
|
||||
|
||||
**Hoe het werkt:**
|
||||
- Azure DevOps maakt automatisch een Service Principal aan in Azure AD
|
||||
- De Service Principal krijgt toegang tot je Azure Container Registry
|
||||
- Azure DevOps gebruikt deze credentials om in te loggen bij ACR
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ **Eenvoudig** - Azure DevOps doet alles automatisch
|
||||
- ✅ **Betrouwbaar** - Werkt altijd, geen speciale configuratie nodig
|
||||
- ✅ **Veilig** - Credentials worden veilig opgeslagen in Azure DevOps
|
||||
- ✅ **Meest ondersteund** - Standaard optie voor de meeste scenario's
|
||||
- ✅ **Werkt met alle Azure services** - Niet alleen ACR
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ Maakt een Service Principal aan in Azure AD (maar dit is normaal en veilig)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ **Jouw situatie** - Azure DevOps Services (cloud) met Azure Container Registry
|
||||
- ✅ De meeste scenario's
|
||||
- ✅ Als je eenvoudige, betrouwbare authenticatie wilt
|
||||
- ✅ Standaard keuze voor nieuwe service connections
|
||||
|
||||
**Configuratie:**
|
||||
- Azure DevOps doet alles automatisch
|
||||
- Je hoeft alleen je Azure subscription en ACR te selecteren
|
||||
- Azure DevOps maakt de Service Principal aan en geeft deze de juiste permissions
|
||||
|
||||
---
|
||||
|
||||
### Optie 2: **Managed Service Identity (MSI)**
|
||||
|
||||
**Hoe het werkt:**
|
||||
- Gebruikt een Managed Identity van Azure DevOps zelf
|
||||
- Geen credentials nodig - Azure beheert alles
|
||||
- Werkt alleen als Azure DevOps een Managed Identity heeft
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Geen credentials te beheren
|
||||
- ✅ Automatisch geroteerd door Azure
|
||||
- ✅ Modernere aanpak
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ **Werkt alleen met Azure DevOps Server (on-premises)** met Managed Identity
|
||||
- ❌ **Werkt NIET met Azure DevOps Services (cloud)** - Dit is belangrijk!
|
||||
- ❌ Vereist speciale configuratie
|
||||
- ❌ Minder flexibel
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Azure DevOps Server (on-premises) met Managed Identity
|
||||
- ✅ Als je geen credentials wilt beheren
|
||||
- ❌ **NIET voor Azure DevOps Services (cloud)** - Dit werkt niet!
|
||||
|
||||
**Voor jouw situatie:** ❌ **Niet geschikt** - Je gebruikt Azure DevOps Services (cloud), niet on-premises
|
||||
|
||||
---
|
||||
|
||||
### Optie 3: **Workload Identity Federation**
|
||||
|
||||
**Hoe het werkt:**
|
||||
- Modernere manier zonder secrets
|
||||
- Gebruikt federated identity (OIDC)
|
||||
- Azure DevOps krijgt een token van Azure AD zonder credentials op te slaan
|
||||
|
||||
**Voordelen:**
|
||||
- ✅ Geen secrets opgeslagen
|
||||
- ✅ Modernere, veiligere aanpak
|
||||
- ✅ Automatisch token management
|
||||
|
||||
**Nadelen:**
|
||||
- ❌ **Nog niet volledig ondersteund** voor alle scenario's
|
||||
- ❌ Kan complexer zijn om te configureren
|
||||
- ❌ Vereist specifieke Azure AD configuratie
|
||||
- ❌ Mogelijk niet beschikbaar in alle Azure DevOps organisaties
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- ✅ Als je de modernste security features wilt
|
||||
- ✅ Als je organisatie Workload Identity Federation ondersteunt
|
||||
- ✅ Voor nieuwe projecten waar je geen legacy support nodig hebt
|
||||
- ❌ **Niet aanbevolen als je eenvoudige setup wilt**
|
||||
|
||||
**Voor jouw situatie:** ⚠️ **Mogelijk beschikbaar, maar Service Principal is eenvoudiger**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Jouw Situatie Analyse
|
||||
|
||||
**Jouw setup:**
|
||||
- ✅ Azure DevOps Services (cloud) - `dev.azure.com`
|
||||
- ✅ Azure Container Registry - `zdlas.azurecr.io`
|
||||
- ✅ Eenvoudige setup gewenst
|
||||
- ✅ Betrouwbare authenticatie nodig
|
||||
|
||||
**Conclusie:** ✅ **Service Principal is perfect!**
|
||||
|
||||
**Waarom niet de andere opties:**
|
||||
- ❌ **Managed Service Identity**: Werkt niet met Azure DevOps Services (cloud)
|
||||
- ⚠️ **Workload Identity Federation**: Mogelijk beschikbaar, maar complexer dan nodig
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist: Welke Keuze?
|
||||
|
||||
### Kies **Service Principal** als:
|
||||
- [x] Je Azure DevOps Services (cloud) gebruikt ✅
|
||||
- [x] Je eenvoudige setup wilt ✅
|
||||
- [x] Je betrouwbare authenticatie nodig hebt ✅
|
||||
- [x] Je standaard, goed ondersteunde optie wilt ✅
|
||||
- [x] Je Azure Container Registry gebruikt ✅
|
||||
|
||||
**→ Jouw situatie: ✅ Kies Service Principal!**
|
||||
|
||||
### Kies **Managed Service Identity** als:
|
||||
- [ ] Je Azure DevOps Server (on-premises) gebruikt
|
||||
- [ ] Je Managed Identity hebt geconfigureerd
|
||||
- [ ] Je geen credentials wilt beheren
|
||||
|
||||
**→ Jouw situatie: ❌ Niet geschikt**
|
||||
|
||||
### Kies **Workload Identity Federation** als:
|
||||
- [ ] Je de modernste security features wilt
|
||||
- [ ] Je organisatie dit ondersteunt
|
||||
- [ ] Je geen legacy support nodig hebt
|
||||
- [ ] Je bereid bent om extra configuratie te doen
|
||||
|
||||
**→ Jouw situatie: ⚠️ Mogelijk, maar niet nodig**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuratie Stappen (Service Principal)
|
||||
|
||||
Wanneer je **Service Principal** kiest:
|
||||
|
||||
1. **Selecteer Azure Subscription**
|
||||
- Kies je Azure subscription uit de dropdown
|
||||
|
||||
2. **Selecteer Azure Container Registry**
|
||||
- Kies je ACR (`zdlas`) uit de dropdown
|
||||
|
||||
3. **Service Connection Name**
|
||||
- Vul in: `zuyderland-cmdb-acr-connection`
|
||||
- ⚠️ **Belangrijk**: Deze naam moet overeenkomen met `dockerRegistryServiceConnection` in `azure-pipelines.yml`!
|
||||
|
||||
4. **Security**
|
||||
- Azure DevOps maakt automatisch een Service Principal aan
|
||||
- De Service Principal krijgt automatisch de juiste permissions (AcrPush role)
|
||||
- Credentials worden veilig opgeslagen in Azure DevOps
|
||||
|
||||
5. **Save**
|
||||
- Klik "Save" of "Verify and save"
|
||||
- Azure DevOps test automatisch de connection
|
||||
|
||||
**✅ Klaar!** Geen extra configuratie nodig.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Kan Ik Later Wisselen?
|
||||
|
||||
**Ja, maar:**
|
||||
- Je kunt altijd een nieuwe service connection aanmaken met een ander authentication type
|
||||
- Je moet dan wel de pipeline variabelen aanpassen
|
||||
- Service Principal is meestal de beste keuze, dus wisselen is meestal niet nodig
|
||||
|
||||
---
|
||||
|
||||
## 💡 Mijn Aanbeveling
|
||||
|
||||
**Voor Zuyderland CMDB GUI:**
|
||||
|
||||
### ✅ **Kies Service Principal** ⭐
|
||||
|
||||
**Waarom:**
|
||||
1. ✅ **Werkt perfect** - Standaard optie voor Azure DevOps Services
|
||||
2. ✅ **Eenvoudig** - Azure DevOps doet alles automatisch
|
||||
3. ✅ **Betrouwbaar** - Meest geteste en ondersteunde optie
|
||||
4. ✅ **Veilig** - Credentials worden veilig beheerd door Azure DevOps
|
||||
5. ✅ **Perfect voor jouw situatie** - Cloud Azure DevOps + Azure Container Registry
|
||||
|
||||
**Je hebt niet nodig:**
|
||||
- ❌ Managed Service Identity (werkt niet met cloud Azure DevOps)
|
||||
- ❌ Workload Identity Federation (complexer dan nodig)
|
||||
|
||||
**Setup:**
|
||||
1. Kies **Service Principal**
|
||||
2. Selecteer je subscription en ACR
|
||||
3. Geef een naam: `zuyderland-cmdb-acr-connection`
|
||||
4. Save
|
||||
|
||||
**Klaar!** ✅
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure DevOps Service Connections](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints)
|
||||
- [Service Principal Authentication](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure)
|
||||
- [Managed Service Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/)
|
||||
- [Workload Identity Federation](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#workload-identity-federation)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Conclusie
|
||||
|
||||
**Kies: Service Principal** ⭐
|
||||
|
||||
Dit is de beste keuze voor:
|
||||
- ✅ Azure DevOps Services (cloud)
|
||||
- ✅ Azure Container Registry
|
||||
- ✅ Eenvoudige, betrouwbare setup
|
||||
- ✅ Standaard, goed ondersteunde optie
|
||||
|
||||
Je kunt altijd later een andere optie proberen als je dat wilt, maar Service Principal is meestal de beste keuze.
|
||||
237
docs/AZURE-SERVICE-CONNECTION-TROUBLESHOOTING.md
Normal file
237
docs/AZURE-SERVICE-CONNECTION-TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Azure DevOps Service Connection - Troubleshooting
|
||||
|
||||
## 🔴 Probleem: "Loading Registries..." blijft hangen
|
||||
|
||||
Als de Azure Container Registry dropdown blijft laden zonder resultaten, probeer deze oplossingen:
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 1: Check Subscription Toegang
|
||||
|
||||
**Probleem:** Je hebt mogelijk geen toegang tot de subscription waar de ACR staat.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Check in Azure Portal:**
|
||||
- Ga naar je Container Registry (`zdlas`)
|
||||
- Klik op **"Access control (IAM)"**
|
||||
- Check of je de juiste rol hebt (bijv. Owner, Contributor, of AcrPush)
|
||||
|
||||
2. **Check Subscription:**
|
||||
- Ga naar je Azure Subscription
|
||||
- Klik op **"Access control (IAM)"**
|
||||
- Check of je toegang hebt tot de subscription
|
||||
|
||||
3. **Probeer opnieuw:**
|
||||
- Ga terug naar Azure DevOps
|
||||
- Selecteer de juiste subscription
|
||||
- Wacht even (kan 10-30 seconden duren)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 2: Refresh/Herlaad de Pagina
|
||||
|
||||
**Soms helpt een simpele refresh:**
|
||||
|
||||
1. **Refresh de browser pagina** (F5 of Cmd+R)
|
||||
2. **Of sluit en open opnieuw** de service connection wizard
|
||||
3. **Probeer opnieuw** de registry te selecteren
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 3: Check Resource Group Locatie
|
||||
|
||||
**Probleem:** Soms laadt Azure DevOps alleen registries in bepaalde regio's.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Check waar je ACR staat:**
|
||||
```bash
|
||||
az acr show --name zdlas --query location -o tsv
|
||||
```
|
||||
|
||||
2. **Check of de subscription toegang heeft tot die regio**
|
||||
|
||||
3. **Probeer handmatig de registry naam in te vullen** (zie Oplossing 4)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 4: Handmatig Registry Naam Invoeren
|
||||
|
||||
**Als de dropdown niet werkt, kun je handmatig de registry naam invoeren:**
|
||||
|
||||
1. **In de service connection wizard:**
|
||||
- Laat de dropdown leeg (of selecteer "Enter value manually")
|
||||
- Typ handmatig: `zdlas`
|
||||
- Of de volledige naam: `zdlas.azurecr.io`
|
||||
|
||||
2. **Save en test**
|
||||
|
||||
**Let op:** Soms accepteert Azure DevOps alleen de registry naam zonder `.azurecr.io`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 5: Check Service Principal Permissions
|
||||
|
||||
**Probleem:** De Service Principal die Azure DevOps probeert aan te maken heeft mogelijk niet de juiste permissions.
|
||||
|
||||
**Oplossing:**
|
||||
1. **Maak handmatig een Service Principal aan:**
|
||||
```bash
|
||||
# Login bij Azure
|
||||
az login
|
||||
|
||||
# Maak Service Principal aan
|
||||
az ad sp create-for-rbac --name "zuyderland-cmdb-acr-sp" \
|
||||
--role acrpush \
|
||||
--scopes /subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.ContainerRegistry/registries/zdlas
|
||||
```
|
||||
|
||||
2. **Gebruik deze credentials in Azure DevOps:**
|
||||
- Kies "Docker Registry" → "Others"
|
||||
- Vul in:
|
||||
- Registry URL: `zdlas.azurecr.io`
|
||||
- Username: (van de Service Principal output)
|
||||
- Password: (van de Service Principal output)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 6: Gebruik "Others" in plaats van "Azure Container Registry"
|
||||
|
||||
**Als de Azure Container Registry optie niet werkt, gebruik dan "Others":**
|
||||
|
||||
1. **In de service connection wizard:**
|
||||
- Kies **"Docker Registry"**
|
||||
- Kies **"Others"** (in plaats van "Azure Container Registry")
|
||||
|
||||
2. **Vul handmatig in:**
|
||||
- **Docker Registry**: `zdlas.azurecr.io`
|
||||
- **Docker ID**: (leeg laten of je ACR admin username)
|
||||
- **Docker Password**: (je ACR admin password)
|
||||
|
||||
3. **Haal ACR credentials op:**
|
||||
```bash
|
||||
# Login bij Azure
|
||||
az login
|
||||
|
||||
# Haal admin credentials op
|
||||
az acr credential show --name zdlas
|
||||
```
|
||||
|
||||
Gebruik de `username` en `passwords[0].value` uit de output.
|
||||
|
||||
4. **Save en test**
|
||||
|
||||
**⚠️ Let op:** Met "Others" moet je handmatig credentials beheren. Service Principal is veiliger, maar dit werkt als tijdelijke oplossing.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 7: Check Browser/Network
|
||||
|
||||
**Probleem:** Browser of network issues kunnen de dropdown blokkeren.
|
||||
|
||||
**Oplossingen:**
|
||||
1. **Probeer een andere browser** (Chrome, Firefox, Edge)
|
||||
2. **Disable browser extensions** (ad blockers, etc.)
|
||||
3. **Check network connectivity** naar Azure
|
||||
4. **Probeer incognito/private mode**
|
||||
|
||||
---
|
||||
|
||||
## ✅ Oplossing 8: Wacht Even en Probeer Later
|
||||
|
||||
**Soms is het een tijdelijk Azure issue:**
|
||||
|
||||
1. **Wacht 5-10 minuten**
|
||||
2. **Probeer opnieuw**
|
||||
3. **Check Azure Status**: https://status.azure.com/
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Diagnose Stappen
|
||||
|
||||
**Om te diagnosticeren wat het probleem is:**
|
||||
|
||||
### 1. Check ACR Bestaat en is Toegankelijk
|
||||
|
||||
```bash
|
||||
# Login bij Azure
|
||||
az login
|
||||
|
||||
# Check of ACR bestaat
|
||||
az acr show --name zdlas
|
||||
|
||||
# Check ACR credentials
|
||||
az acr credential show --name zdlas
|
||||
|
||||
# Check ACR permissions
|
||||
az acr show --name zdlas --query "networkRuleSet" -o table
|
||||
```
|
||||
|
||||
### 2. Check Azure DevOps Subscription Toegang
|
||||
|
||||
1. Ga naar Azure Portal
|
||||
2. Ga naar je Subscription
|
||||
3. Check "Access control (IAM)"
|
||||
4. Check of je account toegang heeft
|
||||
|
||||
### 3. Check Service Principal Permissions
|
||||
|
||||
```bash
|
||||
# List Service Principals
|
||||
az ad sp list --display-name "zuyderland-cmdb-acr-sp" -o table
|
||||
|
||||
# Check permissions op ACR
|
||||
az role assignment list --scope /subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.ContainerRegistry/registries/zdlas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Aanbevolen Aanpak
|
||||
|
||||
**Probeer in deze volgorde:**
|
||||
|
||||
1. ✅ **Refresh de pagina** (Oplossing 2)
|
||||
2. ✅ **Check subscription toegang** (Oplossing 1)
|
||||
3. ✅ **Handmatig registry naam invoeren** (Oplossing 4)
|
||||
4. ✅ **Gebruik "Others" optie** (Oplossing 6) - als tijdelijke oplossing
|
||||
5. ✅ **Maak handmatig Service Principal** (Oplossing 5) - voor permanente oplossing
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Fix (Aanbevolen)
|
||||
|
||||
**Als de dropdown niet werkt, gebruik deze workaround:**
|
||||
|
||||
1. **Kies "Docker Registry" → "Others"**
|
||||
2. **Vul in:**
|
||||
- Registry URL: `zdlas.azurecr.io`
|
||||
- Username: (haal op met `az acr credential show --name zdlas`)
|
||||
- Password: (haal op met `az acr credential show --name zdlas`)
|
||||
3. **Service connection name**: `zuyderland-cmdb-acr-connection`
|
||||
4. **Save**
|
||||
|
||||
**Dit werkt altijd, ook als de Azure Container Registry optie niet werkt.**
|
||||
|
||||
**Later kun je:**
|
||||
- De service connection verwijderen
|
||||
- Opnieuw aanmaken met "Azure Container Registry" optie (als die dan wel werkt)
|
||||
- Of de "Others" optie behouden (werkt ook prima)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- [Azure DevOps Service Connections Troubleshooting](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints)
|
||||
- [ACR Access Control](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication)
|
||||
- [Service Principal Permissions](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-roles)
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Nog Steeds Problemen?
|
||||
|
||||
Als niets werkt:
|
||||
|
||||
1. **Check Azure DevOps logs** (als je toegang hebt)
|
||||
2. **Contact Azure Support** (als je een support plan hebt)
|
||||
3. **Gebruik "Others" optie** als workaround (werkt altijd)
|
||||
|
||||
**De "Others" optie is een volledig werkende oplossing**, alleen iets minder geautomatiseerd dan de Azure Container Registry optie.
|
||||
142
docs/DATABASE-ACCESS.md
Normal file
142
docs/DATABASE-ACCESS.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Database Access Guide
|
||||
|
||||
This guide shows you how to easily access and view records in the PostgreSQL database.
|
||||
|
||||
## Quick Access
|
||||
|
||||
### Option 1: Using the Script (Easiest)
|
||||
|
||||
```bash
|
||||
# Connect using psql
|
||||
./scripts/open-database.sh psql
|
||||
|
||||
# Or via Docker
|
||||
./scripts/open-database.sh docker
|
||||
|
||||
# Or get connection string for GUI tools
|
||||
./scripts/open-database.sh url
|
||||
```
|
||||
|
||||
### Option 2: Direct psql Command
|
||||
|
||||
```bash
|
||||
# If PostgreSQL is running locally
|
||||
PGPASSWORD=cmdb-dev psql -h localhost -p 5432 -U cmdb -d cmdb
|
||||
```
|
||||
|
||||
### Option 3: Via Docker
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL container
|
||||
docker exec -it $(docker ps | grep postgres | awk '{print $1}') psql -U cmdb -d cmdb
|
||||
```
|
||||
|
||||
## Connection Details
|
||||
|
||||
From `docker-compose.yml`:
|
||||
- **Host**: localhost (or `postgres` if connecting from Docker network)
|
||||
- **Port**: 5432
|
||||
- **Database**: cmdb
|
||||
- **User**: cmdb
|
||||
- **Password**: cmdb-dev
|
||||
|
||||
**Connection String:**
|
||||
```
|
||||
postgresql://cmdb:cmdb-dev@localhost:5432/cmdb
|
||||
```
|
||||
|
||||
## GUI Tools
|
||||
|
||||
### pgAdmin (Free, Web-based)
|
||||
1. Download from: https://www.pgadmin.org/download/
|
||||
2. Add new server with connection details above
|
||||
3. Browse tables and run queries
|
||||
|
||||
### DBeaver (Free, Cross-platform)
|
||||
1. Download from: https://dbeaver.io/download/
|
||||
2. Create new PostgreSQL connection
|
||||
3. Use connection string or individual fields
|
||||
|
||||
### TablePlus (macOS, Paid but has free tier)
|
||||
1. Download from: https://tableplus.com/
|
||||
2. Create new PostgreSQL connection
|
||||
3. Enter connection details
|
||||
|
||||
### DataGrip (JetBrains, Paid)
|
||||
1. Part of JetBrains IDEs or standalone
|
||||
2. Create new PostgreSQL data source
|
||||
3. Use connection string
|
||||
|
||||
## Useful SQL Commands
|
||||
|
||||
Once connected, try these commands:
|
||||
|
||||
```sql
|
||||
-- List all tables
|
||||
\dt
|
||||
|
||||
-- Describe a table structure
|
||||
\d users
|
||||
\d classifications
|
||||
\d cache_objects
|
||||
|
||||
-- View all users
|
||||
SELECT * FROM users;
|
||||
|
||||
-- View classifications
|
||||
SELECT * FROM classifications ORDER BY created_at DESC LIMIT 10;
|
||||
|
||||
-- View cached objects
|
||||
SELECT object_key, object_type, updated_at FROM cache_objects ORDER BY updated_at DESC LIMIT 20;
|
||||
|
||||
-- Count records per table
|
||||
SELECT
|
||||
'users' as table_name, COUNT(*) as count FROM users
|
||||
UNION ALL
|
||||
SELECT
|
||||
'classifications', COUNT(*) FROM classifications
|
||||
UNION ALL
|
||||
SELECT
|
||||
'cache_objects', COUNT(*) FROM cache_objects;
|
||||
|
||||
-- View user settings
|
||||
SELECT u.username, u.email, us.ai_provider, us.ai_enabled
|
||||
FROM users u
|
||||
LEFT JOIN user_settings us ON u.id = us.user_id;
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
If you're using environment variables instead of Docker:
|
||||
|
||||
```bash
|
||||
# Check your .env file for:
|
||||
DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb
|
||||
# or
|
||||
DATABASE_TYPE=postgres
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=cmdb
|
||||
DATABASE_USER=cmdb
|
||||
DATABASE_PASSWORD=cmdb-dev
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database not running
|
||||
```bash
|
||||
# Start PostgreSQL container
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Check if it's running
|
||||
docker ps | grep postgres
|
||||
```
|
||||
|
||||
### Connection refused
|
||||
- Make sure PostgreSQL container is running
|
||||
- Check if port 5432 is already in use
|
||||
- Verify connection details match docker-compose.yml
|
||||
|
||||
### Permission denied
|
||||
- Verify username and password match docker-compose.yml
|
||||
- Check if user has access to the database
|
||||
635
docs/DEPLOYMENT-ADVICE.md
Normal file
635
docs/DEPLOYMENT-ADVICE.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# Deployment Advies - Zuyderland CMDB GUI 🎯
|
||||
|
||||
**Datum:** {{ vandaag }}
|
||||
**Aanbeveling:** Azure App Service (Basic Tier)
|
||||
**Geschatte kosten:** ~€20-25/maand
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analyse van Jouw Situatie
|
||||
|
||||
### Jouw Requirements:
|
||||
- ✅ **Managed service** (geen serverbeheer) - **Jouw voorkeur**
|
||||
- ✅ **Interne productie** (niet bedrijfskritisch)
|
||||
- ✅ **20 gebruikers** (kleine team)
|
||||
- ✅ **Downtime acceptabel** (kan zelfs 's avonds/weekend uit)
|
||||
- ✅ **Budget geen probleem** (~€20-25/maand is prima)
|
||||
- ✅ **Monitoring via Elastic stack** (kan geïntegreerd worden)
|
||||
- ✅ **NEN 7510 compliance** (vereist)
|
||||
- ✅ **Updates:** Initieel dagelijks, daarna wekelijks/maandelijks
|
||||
|
||||
### Waarom Azure App Service Perfect Past:
|
||||
|
||||
1. **Managed Service** ⭐
|
||||
- Geen serverbeheer, SSH, Linux configuratie
|
||||
- Azure beheert alles (updates, security patches, scaling)
|
||||
- Perfect voor jouw voorkeur: "liever niet als het niet hoeft"
|
||||
|
||||
2. **Eenvoudig & Snel**
|
||||
- Setup in ~15 minuten
|
||||
- Automatische SSL/TLS certificaten
|
||||
- Integratie met Azure DevOps (je pipeline werkt al!)
|
||||
|
||||
3. **Kosten-Effectief**
|
||||
- Basic B1 plan: ~€15-25/maand
|
||||
- Voldoende voor 20 gebruikers
|
||||
- Geen verborgen kosten
|
||||
|
||||
4. **Flexibel**
|
||||
- Deployment slots voor testen (staging → productie)
|
||||
- Eenvoudige rollback
|
||||
- Integratie met Azure Key Vault voor secrets
|
||||
|
||||
5. **Monitoring & Compliance**
|
||||
- Integratie met Azure Monitor → kan naar Elastic stack
|
||||
- Logging en audit trails (NEN 7510 compliance)
|
||||
- Health checks ingebouwd
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Aanbevolen Architectuur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Azure App Service Plan (B1) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Frontend │ │ Backend │ │
|
||||
│ │ Web App │ │ Web App │ │
|
||||
│ │ (Container) │ │ (Container) │ │
|
||||
│ └──────────────┘ └──────┬───────┘ │
|
||||
└──────────────────────────┼─────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌───────▼──────┐ ┌─────▼──────┐ ┌────▼─────┐
|
||||
│ Azure Key │ │ Azure │ │ Elastic │
|
||||
│ Vault │ │ Monitor │ │ Stack │
|
||||
│ (Secrets) │ │ (Logs) │ │ (Export) │
|
||||
└──────────────┘ └────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
**Componenten:**
|
||||
- **App Service Plan B1**: 1 vCPU, 1.75GB RAM (voldoende voor 20 gebruikers)
|
||||
- **2 Web Apps**: Frontend + Backend (delen dezelfde plan = kostenbesparend)
|
||||
- **Azure Key Vault**: Voor secrets (Jira credentials, session secrets)
|
||||
- **Azure Monitor**: Logging → kan geëxporteerd worden naar Elastic stack
|
||||
- **Azure Storage**: Voor SQLite database (als je SQLite blijft gebruiken)
|
||||
|
||||
**Kosten Breakdown:**
|
||||
- App Service Plan B1: ~€15-20/maand
|
||||
- Azure Key Vault: ~€1-2/maand
|
||||
- Azure Storage (SQLite): ~€1-2/maand
|
||||
- **Totaal: ~€17-24/maand**
|
||||
|
||||
---
|
||||
|
||||
## 📋 Stap-voor-Stap Deployment Plan
|
||||
|
||||
### Fase 1: Basis Setup (15 minuten)
|
||||
|
||||
#### Stap 1.1: Resource Group Aanmaken
|
||||
|
||||
```bash
|
||||
az group create \
|
||||
--name rg-cmdb-gui-prod \
|
||||
--location westeurope
|
||||
```
|
||||
|
||||
#### Stap 1.2: App Service Plan Aanmaken
|
||||
|
||||
```bash
|
||||
az appservice plan create \
|
||||
--name plan-cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--sku B1 \
|
||||
--is-linux
|
||||
```
|
||||
|
||||
**Waarom B1?**
|
||||
- 1 vCPU, 1.75GB RAM
|
||||
- Voldoende voor 20 gebruikers
|
||||
- Goede prijs/prestatie verhouding
|
||||
|
||||
#### Stap 1.3: Web Apps Aanmaken
|
||||
|
||||
```bash
|
||||
# Backend Web App
|
||||
az webapp create \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--plan plan-cmdb-gui-prod \
|
||||
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
|
||||
# Frontend Web App
|
||||
az webapp create \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--plan plan-cmdb-gui-prod \
|
||||
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fase 2: ACR Authentication (5 minuten)
|
||||
|
||||
#### Stap 2.1: Enable Managed Identity
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
az webapp identity assign \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod
|
||||
|
||||
# Frontend
|
||||
az webapp identity assign \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
#### Stap 2.2: Grant ACR Pull Permissions
|
||||
|
||||
```bash
|
||||
# Get managed identity principal ID
|
||||
BACKEND_PRINCIPAL_ID=$(az webapp identity show \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--query principalId -o tsv)
|
||||
|
||||
FRONTEND_PRINCIPAL_ID=$(az webapp identity show \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--query principalId -o tsv)
|
||||
|
||||
# Get ACR resource ID
|
||||
ACR_ID=$(az acr show \
|
||||
--name zdlas \
|
||||
--resource-group <acr-resource-group> \
|
||||
--query id -o tsv)
|
||||
|
||||
# Grant AcrPull role
|
||||
az role assignment create \
|
||||
--assignee $BACKEND_PRINCIPAL_ID \
|
||||
--role AcrPull \
|
||||
--scope $ACR_ID
|
||||
|
||||
az role assignment create \
|
||||
--assignee $FRONTEND_PRINCIPAL_ID \
|
||||
--role AcrPull \
|
||||
--scope $ACR_ID
|
||||
```
|
||||
|
||||
#### Stap 2.3: Configure Container Settings
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
az webapp config container set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \
|
||||
--docker-registry-server-url https://zdlas.azurecr.io
|
||||
|
||||
# Frontend
|
||||
az webapp config container set \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \
|
||||
--docker-registry-server-url https://zdlas.azurecr.io
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fase 3: Environment Variabelen (10 minuten)
|
||||
|
||||
#### Stap 3.1: Azure Key Vault Aanmaken
|
||||
|
||||
```bash
|
||||
az keyvault create \
|
||||
--name kv-cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--location westeurope \
|
||||
--sku standard
|
||||
```
|
||||
|
||||
#### Stap 3.2: Secrets Toevoegen aan Key Vault
|
||||
|
||||
```bash
|
||||
# Jira Personal Access Token (of OAuth credentials)
|
||||
az keyvault secret set \
|
||||
--vault-name kv-cmdb-gui-prod \
|
||||
--name JiraPat \
|
||||
--value "your-jira-pat-token"
|
||||
|
||||
# Session Secret
|
||||
az keyvault secret set \
|
||||
--vault-name kv-cmdb-gui-prod \
|
||||
--name SessionSecret \
|
||||
--value "$(openssl rand -hex 32)"
|
||||
|
||||
# Jira Schema ID
|
||||
az keyvault secret set \
|
||||
--vault-name kv-cmdb-gui-prod \
|
||||
--name JiraSchemaId \
|
||||
--value "your-schema-id"
|
||||
|
||||
# Anthropic API Key (optioneel)
|
||||
az keyvault secret set \
|
||||
--vault-name kv-cmdb-gui-prod \
|
||||
--name AnthropicApiKey \
|
||||
--value "your-anthropic-key"
|
||||
```
|
||||
|
||||
#### Stap 3.3: Grant Web Apps Access tot Key Vault
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
az keyvault set-policy \
|
||||
--name kv-cmdb-gui-prod \
|
||||
--object-id $BACKEND_PRINCIPAL_ID \
|
||||
--secret-permissions get list
|
||||
|
||||
# Frontend (als nodig)
|
||||
az keyvault set-policy \
|
||||
--name kv-cmdb-gui-prod \
|
||||
--object-id $FRONTEND_PRINCIPAL_ID \
|
||||
--secret-permissions get list
|
||||
```
|
||||
|
||||
#### Stap 3.4: Configure App Settings met Key Vault References
|
||||
|
||||
```bash
|
||||
# Backend App Settings
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
JIRA_BASE_URL=https://jira.zuyderland.nl \
|
||||
JIRA_SCHEMA_ID="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraSchemaId/)" \
|
||||
JIRA_PAT="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/JiraPat/)" \
|
||||
SESSION_SECRET="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/SessionSecret/)" \
|
||||
ANTHROPIC_API_KEY="@Microsoft.KeyVault(SecretUri=https://kv-cmdb-gui-prod.vault.azure.net/secrets/AnthropicApiKey/)" \
|
||||
FRONTEND_URL=https://cmdb-frontend-prod.azurewebsites.net
|
||||
|
||||
# Frontend App Settings
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
VITE_API_URL=https://cmdb-backend-prod.azurewebsites.net/api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fase 4: SSL/TLS & Domain (10 minuten)
|
||||
|
||||
#### Stap 4.1: Gratis SSL via App Service
|
||||
|
||||
App Service heeft automatisch SSL voor `*.azurewebsites.net`:
|
||||
- Frontend: `https://cmdb-frontend-prod.azurewebsites.net`
|
||||
- Backend: `https://cmdb-backend-prod.azurewebsites.net`
|
||||
|
||||
**Geen configuratie nodig!** SSL werkt automatisch.
|
||||
|
||||
#### Stap 4.2: Custom Domain (Optioneel - Later)
|
||||
|
||||
Als je later een custom domain wilt (bijv. `cmdb.zuyderland.nl`):
|
||||
|
||||
```bash
|
||||
# Add custom domain
|
||||
az webapp config hostname add \
|
||||
--webapp-name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--hostname cmdb.zuyderland.nl
|
||||
|
||||
# Bind SSL certificate (App Service Certificate of Let's Encrypt)
|
||||
az webapp config ssl bind \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--certificate-thumbprint <thumbprint> \
|
||||
--ssl-type SNI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fase 5: Monitoring & Logging (15 minuten)
|
||||
|
||||
#### Stap 5.1: Enable Application Insights
|
||||
|
||||
```bash
|
||||
# Create Application Insights
|
||||
az monitor app-insights component create \
|
||||
--app cmdb-gui-prod \
|
||||
--location westeurope \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--application-type web
|
||||
|
||||
# Get Instrumentation Key
|
||||
INSTRUMENTATION_KEY=$(az monitor app-insights component show \
|
||||
--app cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--query instrumentationKey -o tsv)
|
||||
|
||||
# Configure App Settings
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--settings \
|
||||
APPINSIGHTS_INSTRUMENTATIONKEY=$INSTRUMENTATION_KEY \
|
||||
APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=$INSTRUMENTATION_KEY"
|
||||
```
|
||||
|
||||
#### Stap 5.2: Export naar Elastic Stack (Later)
|
||||
|
||||
Azure Monitor kan geëxporteerd worden naar Elastic stack via:
|
||||
- **Azure Monitor → Log Analytics Workspace → Export naar Elastic**
|
||||
- Of gebruik **Azure Function** om logs te streamen naar Elastic
|
||||
|
||||
**Zie:** `docs/ELASTIC-STACK-INTEGRATION.md` (te maken als nodig)
|
||||
|
||||
---
|
||||
|
||||
### Fase 6: Test & Start (5 minuten)
|
||||
|
||||
#### Stap 6.1: Start Web Apps
|
||||
|
||||
```bash
|
||||
az webapp start --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
az webapp start --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
#### Stap 6.2: Test Health Endpoints
|
||||
|
||||
```bash
|
||||
# Backend health check
|
||||
curl https://cmdb-backend-prod.azurewebsites.net/api/health
|
||||
|
||||
# Frontend
|
||||
curl https://cmdb-frontend-prod.azurewebsites.net
|
||||
```
|
||||
|
||||
#### Stap 6.3: Check Logs
|
||||
|
||||
```bash
|
||||
# Backend logs
|
||||
az webapp log tail --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
|
||||
# Frontend logs
|
||||
az webapp log tail --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 NEN 7510 Compliance
|
||||
|
||||
### Wat App Service Biedt:
|
||||
|
||||
1. **Encryption**
|
||||
- ✅ Data at rest: Azure Storage encryption
|
||||
- ✅ Data in transit: TLS 1.2+ (automatisch)
|
||||
- ✅ Secrets: Azure Key Vault (encrypted)
|
||||
|
||||
2. **Access Control**
|
||||
- ✅ Azure AD integratie (RBAC)
|
||||
- ✅ Managed Identity (geen credentials in code)
|
||||
- ✅ Network isolation (optioneel via VNet integration)
|
||||
|
||||
3. **Logging & Audit**
|
||||
- ✅ Application Insights (alle API calls)
|
||||
- ✅ Azure Monitor (resource logs)
|
||||
- ✅ Activity Logs (wie deed wat)
|
||||
- ✅ Export naar Elastic stack mogelijk
|
||||
|
||||
4. **Backup & Recovery**
|
||||
- ✅ App Service backups (optioneel)
|
||||
- ✅ Key Vault soft delete (recovery)
|
||||
- ⚠️ **Opmerking**: Jouw data wordt gesynchroniseerd vanuit Jira, dus backup is minder kritisch
|
||||
|
||||
### Compliance Checklist:
|
||||
|
||||
- [ ] Secrets in Azure Key Vault (niet in code)
|
||||
- [ ] HTTPS only (automatisch via App Service)
|
||||
- [ ] Logging ingeschakeld (Application Insights)
|
||||
- [ ] Access control (Azure AD RBAC)
|
||||
- [ ] Audit trail (Activity Logs)
|
||||
- [ ] Encryption at rest (Azure Storage)
|
||||
- [ ] Encryption in transit (TLS 1.2+)
|
||||
|
||||
**Zie:** `docs/NEN-7510-COMPLIANCE.md` (te maken als nodig)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 VPN/Private Network Opties (Voor Later)
|
||||
|
||||
### Optie 1: App Service VNet Integration
|
||||
|
||||
**Wat het doet:**
|
||||
- Web App verbindt met Azure Virtual Network
|
||||
- Toegang tot resources in VNet (bijv. database, andere services)
|
||||
- **Niet**: Maakt de app niet privé (app blijft publiek bereikbaar)
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- Als je een database in VNet hebt
|
||||
- Als je andere Azure services in VNet moet bereiken
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Create VNet (als nog niet bestaat)
|
||||
az network vnet create \
|
||||
--name vnet-cmdb-gui \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--address-prefix 10.0.0.0/16
|
||||
|
||||
# Create subnet
|
||||
az network vnet subnet create \
|
||||
--name subnet-app-service \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--vnet-name vnet-cmdb-gui \
|
||||
--address-prefix 10.0.1.0/24
|
||||
|
||||
# Integrate Web App with VNet
|
||||
az webapp vnet-integration add \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--vnet vnet-cmdb-gui \
|
||||
--subnet subnet-app-service
|
||||
```
|
||||
|
||||
### Optie 2: Private Endpoint (Maakt App Privé)
|
||||
|
||||
**Wat het doet:**
|
||||
- Maakt de Web App alleen bereikbaar via private IP
|
||||
- Vereist VPN/ExpressRoute om toegang te krijgen
|
||||
- **Kosten:** ~€7-10/maand per Private Endpoint
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- Als je de app alleen via VPN wilt bereiken
|
||||
- Als je geen publieke toegang wilt
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Create Private Endpoint
|
||||
az network private-endpoint create \
|
||||
--name pe-cmdb-backend \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--vnet-name vnet-cmdb-gui \
|
||||
--subnet subnet-private-endpoint \
|
||||
--private-connection-resource-id /subscriptions/<sub-id>/resourceGroups/rg-cmdb-gui-prod/providers/Microsoft.Web/sites/cmdb-backend-prod \
|
||||
--group-id sites \
|
||||
--connection-name pe-connection-backend
|
||||
```
|
||||
|
||||
### Optie 3: App Service Environment (ASE) - Enterprise
|
||||
|
||||
**Wat het doet:**
|
||||
- Volledig geïsoleerde App Service omgeving
|
||||
- Alleen bereikbaar via VNet
|
||||
- **Kosten:** ~€1000+/maand (te duur voor jouw use case)
|
||||
|
||||
**Niet aanbevolen** voor jouw situatie (te duur, overkill).
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates Deployen
|
||||
|
||||
### Automatische Deployment (Via Pipeline)
|
||||
|
||||
Je Azure DevOps pipeline bouwt al automatisch images. Voor automatische deployment:
|
||||
|
||||
#### Optie A: Continuous Deployment (Aanbevolen)
|
||||
|
||||
```bash
|
||||
# Enable continuous deployment
|
||||
az webapp deployment container config \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--enable-cd true
|
||||
|
||||
# Configure deployment slot (staging)
|
||||
az webapp deployment slot create \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--slot staging
|
||||
|
||||
# Swap staging → production (zero-downtime)
|
||||
az webapp deployment slot swap \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--slot staging \
|
||||
--target-slot production
|
||||
```
|
||||
|
||||
#### Optie B: Manual Deployment (Eenvoudig)
|
||||
|
||||
```bash
|
||||
# Pull nieuwe image en restart
|
||||
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod
|
||||
az webapp restart --name cmdb-frontend-prod --resource-group rg-cmdb-gui-prod
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
1. Push code naar `main` branch
|
||||
2. Pipeline bouwt nieuwe images → `zdlas.azurecr.io/.../backend:88767`
|
||||
3. Images worden getagged als `latest`
|
||||
4. Restart Web Apps → pull nieuwe `latest` image
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring Setup
|
||||
|
||||
### Azure Monitor → Elastic Stack Export
|
||||
|
||||
**Optie 1: Log Analytics Workspace Export**
|
||||
|
||||
```bash
|
||||
# Create Log Analytics Workspace
|
||||
az monitor log-analytics workspace create \
|
||||
--workspace-name law-cmdb-gui-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--location westeurope
|
||||
|
||||
# Configure App Service to send logs
|
||||
az webapp log config \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui-prod \
|
||||
--application-logging filesystem \
|
||||
--detailed-error-messages true \
|
||||
--failed-request-tracing true \
|
||||
--web-server-logging filesystem
|
||||
|
||||
# Export to Elastic (via Azure Function of Event Hub)
|
||||
# Zie: docs/ELASTIC-STACK-INTEGRATION.md
|
||||
```
|
||||
|
||||
**Optie 2: Application Insights → Elastic**
|
||||
|
||||
Application Insights kan geëxporteerd worden via:
|
||||
- **Continuous Export** (deprecated, maar werkt nog)
|
||||
- **Azure Function** die Application Insights API gebruikt
|
||||
- **Log Analytics Workspace** → Export naar Elastic
|
||||
|
||||
---
|
||||
|
||||
## ✅ Deployment Checklist
|
||||
|
||||
### Pre-Deployment:
|
||||
- [ ] Resource Group aangemaakt
|
||||
- [ ] App Service Plan aangemaakt (B1)
|
||||
- [ ] Web Apps aangemaakt (backend + frontend)
|
||||
- [ ] ACR authentication geconfigureerd
|
||||
- [ ] Key Vault aangemaakt
|
||||
- [ ] Secrets toegevoegd aan Key Vault
|
||||
- [ ] App Settings geconfigureerd (met Key Vault references)
|
||||
- [ ] Application Insights ingeschakeld
|
||||
|
||||
### Post-Deployment:
|
||||
- [ ] Health checks werken
|
||||
- [ ] SSL/TLS werkt (automatisch)
|
||||
- [ ] Logging werkt
|
||||
- [ ] Monitoring ingesteld
|
||||
- [ ] Team geïnformeerd over URLs
|
||||
- [ ] Documentatie bijgewerkt
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Volgende Stappen
|
||||
|
||||
1. **Start met Fase 1-3** (Basis setup + ACR + Environment variabelen)
|
||||
2. **Test de applicatie** (Fase 6)
|
||||
3. **Configureer monitoring** (Fase 5)
|
||||
4. **Documenteer voor team** (URLs, credentials, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Gerelateerde Documentatie
|
||||
|
||||
- **Quick Deployment Guide**: `docs/QUICK-DEPLOYMENT-GUIDE.md`
|
||||
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
|
||||
- **Azure Deployment Summary**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## ❓ Vragen?
|
||||
|
||||
**Veelgestelde vragen:**
|
||||
|
||||
**Q: Moet ik PostgreSQL gebruiken of kan ik SQLite houden?**
|
||||
A: SQLite is prima voor 20 gebruikers. Als je later groeit, kun je migreren naar PostgreSQL.
|
||||
|
||||
**Q: Hoe update ik de applicatie?**
|
||||
A: Push naar `main` → Pipeline bouwt images → Restart Web Apps (of gebruik deployment slots voor zero-downtime).
|
||||
|
||||
**Q: Kan ik de app 's avonds/weekend uitzetten?**
|
||||
A: Ja! `az webapp stop --name cmdb-backend-prod --resource-group rg-cmdb-gui-prod` (bespaart kosten).
|
||||
|
||||
**Q: Hoe integreer ik met Elastic stack?**
|
||||
A: Exporteer Azure Monitor logs via Log Analytics Workspace → Elastic (zie Fase 5).
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
Je hebt nu een compleet deployment plan voor Azure App Service!
|
||||
|
||||
**Start met Fase 1** en laat me weten als je hulp nodig hebt bij een specifieke stap.
|
||||
324
docs/DEPLOYMENT-NEXT-STEPS.md
Normal file
324
docs/DEPLOYMENT-NEXT-STEPS.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Deployment Next Steps - Images Gereed! 🚀
|
||||
|
||||
Je Docker images zijn succesvol gebouwd en gepusht naar Azure Container Registry! Hier zijn de volgende stappen voor deployment.
|
||||
|
||||
## ✅ Wat is al klaar:
|
||||
|
||||
- ✅ Azure Container Registry (ACR): `zdlas.azurecr.io`
|
||||
- ✅ Docker images gebouwd en gepusht:
|
||||
- `zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest`
|
||||
- `zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest`
|
||||
- ✅ Azure DevOps Pipeline: Automatische builds bij push naar `main`
|
||||
- ✅ Docker Compose configuratie: `docker-compose.prod.acr.yml`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Deployment Opties
|
||||
|
||||
Je hebt verschillende opties om de applicatie te deployen. Kies de optie die het beste past bij jouw situatie:
|
||||
|
||||
### Optie 1: Azure App Service (Aanbevolen voor productie) ⭐
|
||||
|
||||
**Voordelen:**
|
||||
- Managed service (geen server management)
|
||||
- Automatische scaling
|
||||
- Ingebouwde SSL/TLS
|
||||
- Deployment slots (zero-downtime updates)
|
||||
- Integratie met Azure Key Vault
|
||||
- Monitoring via Application Insights
|
||||
|
||||
**Geschikt voor:** Productie deployment, kleine tot middelgrote teams
|
||||
|
||||
**Kosten:** ~€15-25/maand (Basic B1 plan)
|
||||
|
||||
**Stappen:**
|
||||
1. Maak App Service Plan aan
|
||||
2. Maak 2 Web Apps aan (backend + frontend)
|
||||
3. Configureer container deployment vanuit ACR
|
||||
4. Stel environment variabelen in
|
||||
5. Configureer SSL certificaat
|
||||
|
||||
**Zie:** `docs/AZURE-APP-SERVICE-DEPLOYMENT.md` (te maken)
|
||||
|
||||
---
|
||||
|
||||
### Optie 2: Azure Container Instances (ACI) - Eenvoudig
|
||||
|
||||
**Voordelen:**
|
||||
- Snel op te zetten
|
||||
- Pay-per-use pricing
|
||||
- Geen server management
|
||||
|
||||
**Geschikt voor:** Test/development, kleine deployments
|
||||
|
||||
**Kosten:** ~€30-50/maand (2 containers)
|
||||
|
||||
**Stappen:**
|
||||
1. Maak 2 Container Instances aan
|
||||
2. Pull images vanuit ACR
|
||||
3. Configureer environment variabelen
|
||||
4. Stel networking in
|
||||
|
||||
**Zie:** `docs/AZURE-CONTAINER-INSTANCES-DEPLOYMENT.md` (te maken)
|
||||
|
||||
---
|
||||
|
||||
### Optie 3: VM met Docker Compose (Flexibel)
|
||||
|
||||
**Voordelen:**
|
||||
- Volledige controle
|
||||
- Eenvoudige deployment met Docker Compose
|
||||
- Kan lokaal getest worden
|
||||
|
||||
**Geschikt voor:** Als je al een VM hebt, of volledige controle wilt
|
||||
|
||||
**Kosten:** ~€20-40/maand (Basic VM)
|
||||
|
||||
**Stappen:**
|
||||
1. Maak Azure VM aan (Ubuntu)
|
||||
2. Installeer Docker en Docker Compose
|
||||
3. Login naar ACR
|
||||
4. Gebruik `docker-compose.prod.acr.yml`
|
||||
5. Configureer Nginx reverse proxy
|
||||
|
||||
**Zie:** `docs/VM-DOCKER-COMPOSE-DEPLOYMENT.md` (te maken)
|
||||
|
||||
---
|
||||
|
||||
### Optie 4: Azure Kubernetes Service (AKS) - Enterprise
|
||||
|
||||
**Voordelen:**
|
||||
- Enterprise-grade scaling
|
||||
- High availability
|
||||
- Advanced networking
|
||||
|
||||
**Geschikt voor:** Grote deployments, enterprise requirements
|
||||
|
||||
**Kosten:** ~€50-100+/maand (minimaal 2 nodes)
|
||||
|
||||
**Niet aanbevolen voor:** Kleine teams (20 gebruikers) - overkill
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Stap 1: Verifieer Images in ACR
|
||||
|
||||
**Controleer of de images succesvol zijn gepusht:**
|
||||
|
||||
```bash
|
||||
# Login naar ACR
|
||||
az acr login --name zdlas
|
||||
|
||||
# List repositories
|
||||
az acr repository list --name zdlas --output table
|
||||
|
||||
# List tags voor backend
|
||||
az acr repository show-tags --name zdlas --repository zuyderland-cmdb-gui/backend --output table
|
||||
|
||||
# List tags voor frontend
|
||||
az acr repository show-tags --name zdlas --repository zuyderland-cmdb-gui/frontend --output table
|
||||
```
|
||||
|
||||
**Verwachte output:**
|
||||
```
|
||||
REPOSITORY TAG CREATED
|
||||
zuyderland-cmdb-gui/backend latest ...
|
||||
zuyderland-cmdb-gui/backend 88764 ...
|
||||
zuyderland-cmdb-gui/frontend latest ...
|
||||
zuyderland-cmdb-gui/frontend 88764 ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Stap 2: Update Docker Compose voor ACR
|
||||
|
||||
**Update `docker-compose.prod.acr.yml` met de juiste ACR naam:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
frontend:
|
||||
image: zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
```
|
||||
|
||||
**Let op:** De huidige configuratie gebruikt `zuyderlandcmdbacr.azurecr.io` - pas dit aan naar `zdlas.azurecr.io` als dat je ACR naam is.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Stap 3: Bereid Environment Variabelen Voor
|
||||
|
||||
**Maak een `.env.production` bestand** (niet committen naar Git!):
|
||||
|
||||
```bash
|
||||
# Backend Environment Variables
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
|
||||
# Jira Configuration
|
||||
JIRA_BASE_URL=https://jira.zuyderland.nl
|
||||
JIRA_SCHEMA_ID=your-schema-id
|
||||
JIRA_PAT=your-personal-access-token
|
||||
# OF
|
||||
JIRA_OAUTH_CLIENT_ID=your-client-id
|
||||
JIRA_OAUTH_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# Session
|
||||
SESSION_SECRET=your-secure-random-secret
|
||||
|
||||
# AI (Optioneel)
|
||||
ANTHROPIC_API_KEY=your-anthropic-key
|
||||
OPENAI_API_KEY=your-openai-key
|
||||
|
||||
# Database (als je PostgreSQL gebruikt)
|
||||
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
||||
|
||||
# Frontend API URL
|
||||
VITE_API_URL=https://your-backend-url.com/api
|
||||
```
|
||||
|
||||
**Gebruik Azure Key Vault voor secrets in productie!**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Stap 4: Kies Deployment Methode
|
||||
|
||||
### Quick Start: VM met Docker Compose
|
||||
|
||||
**Als je snel wilt starten:**
|
||||
|
||||
1. **Maak Azure VM aan:**
|
||||
```bash
|
||||
az vm create \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--name vm-cmdb-gui \
|
||||
--image Ubuntu2204 \
|
||||
--size Standard_B2s \
|
||||
--admin-username azureuser \
|
||||
--generate-ssh-keys
|
||||
```
|
||||
|
||||
2. **SSH naar de VM:**
|
||||
```bash
|
||||
ssh azureuser@<vm-public-ip>
|
||||
```
|
||||
|
||||
3. **Installeer Docker en Docker Compose:**
|
||||
```bash
|
||||
# Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Docker Compose
|
||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
4. **Login naar ACR:**
|
||||
```bash
|
||||
az acr login --name zdlas
|
||||
# OF
|
||||
docker login zdlas.azurecr.io -u <acr-username> -p <acr-password>
|
||||
```
|
||||
|
||||
5. **Clone repository en deploy:**
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd zuyderland-cmdb-gui
|
||||
|
||||
# Update docker-compose.prod.acr.yml met juiste ACR naam
|
||||
# Maak .env.production aan
|
||||
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Stap 5: Configureer Nginx Reverse Proxy
|
||||
|
||||
**Update `nginx/nginx.conf`** voor productie:
|
||||
|
||||
- SSL/TLS certificaat configuratie
|
||||
- Domain name
|
||||
- Backend API proxy
|
||||
- Frontend static files
|
||||
|
||||
**Zie:** `nginx/nginx.conf` voor configuratie template.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Stap 6: Security Checklist
|
||||
|
||||
- [ ] SSL/TLS certificaat geconfigureerd
|
||||
- [ ] Environment variabelen in Key Vault (niet in code)
|
||||
- [ ] Firewall rules geconfigureerd
|
||||
- [ ] CORS correct geconfigureerd
|
||||
- [ ] Rate limiting ingeschakeld
|
||||
- [ ] Health checks geconfigureerd
|
||||
- [ ] Monitoring/alerting ingesteld
|
||||
|
||||
---
|
||||
|
||||
## 📊 Stap 7: Monitoring Setup
|
||||
|
||||
**Azure Application Insights:**
|
||||
- Application performance monitoring
|
||||
- Error tracking
|
||||
- Usage analytics
|
||||
|
||||
**Azure Monitor:**
|
||||
- Container health
|
||||
- Resource usage
|
||||
- Alerts
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Aanbevolen Volgorde
|
||||
|
||||
1. **Verifieer images in ACR** (Stap 1)
|
||||
2. **Kies deployment optie** (Optie 1, 2, of 3)
|
||||
3. **Bereid environment variabelen voor** (Stap 3)
|
||||
4. **Deploy naar test environment**
|
||||
5. **Test functionaliteit**
|
||||
6. **Configureer SSL/TLS**
|
||||
7. **Setup monitoring**
|
||||
8. **Deploy naar productie**
|
||||
|
||||
---
|
||||
|
||||
## 📚 Gerelateerde Documentatie
|
||||
|
||||
- **Azure App Service Deployment**: `docs/AZURE-APP-SERVICE-DEPLOYMENT.md` (te maken)
|
||||
- **Azure Container Instances**: `docs/AZURE-CONTAINER-INSTANCES-DEPLOYMENT.md` (te maken)
|
||||
- **VM Docker Compose**: `docs/VM-DOCKER-COMPOSE-DEPLOYMENT.md` (te maken)
|
||||
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
|
||||
- **Azure Deployment Summary**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## ❓ Vragen?
|
||||
|
||||
**Veelgestelde vragen:**
|
||||
|
||||
**Q: Welke deployment optie moet ik kiezen?**
|
||||
A: Voor 20 gebruikers: **Azure App Service** (Optie 1) is het meest geschikt - managed service, eenvoudig, voldoende resources.
|
||||
|
||||
**Q: Moet ik PostgreSQL gebruiken of kan ik SQLite houden?**
|
||||
A: SQLite is prima voor 20 gebruikers. PostgreSQL is beter voor groei of als je connection pooling nodig hebt.
|
||||
|
||||
**Q: Hoe configureer ik SSL?**
|
||||
A: Azure App Service heeft ingebouwde SSL. Voor VM: gebruik Let's Encrypt met certbot.
|
||||
|
||||
**Q: Hoe update ik de applicatie?**
|
||||
A: Push naar `main` branch → Pipeline bouwt nieuwe images → Pull nieuwe images in deployment → Restart containers.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
Je hebt nu:
|
||||
- ✅ Docker images in ACR
|
||||
- ✅ Automatische CI/CD pipeline
|
||||
- ✅ Deployment configuratie klaar
|
||||
|
||||
**Volgende stap:** Kies je deployment optie en volg de stappen!
|
||||
320
docs/NEXT-STEPS-ACR-CREATED.md
Normal file
320
docs/NEXT-STEPS-ACR-CREATED.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Next Steps - ACR Created! 🎉
|
||||
|
||||
Je Azure Container Registry is aangemaakt! Volg deze stappen om Docker images automatisch te bouwen en te pushen.
|
||||
|
||||
## 📋 Checklist
|
||||
|
||||
- [ ] Stap 1: Verifieer ACR naam
|
||||
- [ ] Stap 2: Update pipeline variabelen (als nodig)
|
||||
- [ ] Stap 3: Service Connection aanmaken in Azure DevOps
|
||||
- [ ] Stap 4: Pipeline aanmaken en runnen
|
||||
- [ ] Stap 5: Verifieer images in ACR
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Stap 1: Verifieer ACR Naam
|
||||
|
||||
**Vind je ACR naam:**
|
||||
|
||||
**Via Azure Portal:**
|
||||
1. Ga naar je Container Registry
|
||||
2. Klik op **"Overview"**
|
||||
3. Noteer de **"Login server"** (bijv. `zuyderlandcmdbacr.azurecr.io`)
|
||||
4. De naam vóór `.azurecr.io` is je ACR naam (bijv. `zuyderlandcmdbacr`)
|
||||
|
||||
**Via Azure CLI:**
|
||||
```bash
|
||||
az acr list --query "[].{Name:name, LoginServer:loginServer}" -o table
|
||||
```
|
||||
|
||||
**Noteer je ACR naam!** (bijv. `zuyderlandcmdbacr`)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Stap 2: Update Pipeline Variabelen
|
||||
|
||||
**Check of `azure-pipelines.yml` de juiste ACR naam heeft:**
|
||||
|
||||
1. **Open** `azure-pipelines.yml`
|
||||
2. **Controleer regel 17:**
|
||||
```yaml
|
||||
acrName: 'zuyderlandcmdbacr' # ← Pas aan naar jouw ACR naam
|
||||
```
|
||||
|
||||
3. **Als je ACR naam anders is**, pas het aan:
|
||||
```yaml
|
||||
acrName: 'jouw-acr-naam-hier'
|
||||
```
|
||||
|
||||
4. **Commit en push** (als je het hebt aangepast):
|
||||
```bash
|
||||
git add azure-pipelines.yml
|
||||
git commit -m "Update ACR name in pipeline"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**✅ Als de naam al klopt, ga door naar Stap 3!**
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Stap 3: Service Connection Aanmaken in Azure DevOps
|
||||
|
||||
Deze connection geeft Azure DevOps toegang tot je ACR.
|
||||
|
||||
### Stappen:
|
||||
|
||||
1. **Ga naar je Azure DevOps project**
|
||||
- Open je project in Azure DevOps
|
||||
|
||||
2. **Ga naar Project Settings**
|
||||
- Klik op **⚙️ Project Settings** (onderaan links in het menu)
|
||||
|
||||
3. **Open Service Connections**
|
||||
- Scroll naar **"Pipelines"** sectie
|
||||
- Klik op **"Service connections"**
|
||||
|
||||
4. **Maak nieuwe connection**
|
||||
- Klik op **"New service connection"** (of **"Create service connection"**)
|
||||
- Kies **"Docker Registry"**
|
||||
- Klik **"Next"**
|
||||
|
||||
5. **Selecteer Azure Container Registry**
|
||||
- Kies **"Azure Container Registry"**
|
||||
- Klik **"Next"**
|
||||
|
||||
6. **Configureer connection**
|
||||
- **Authentication type**: Kies **"Service Principal"** ⭐ (aanbevolen)
|
||||
- Dit is de standaard en meest betrouwbare optie
|
||||
- Azure DevOps maakt automatisch een Service Principal aan
|
||||
- **Azure subscription**: Selecteer je Azure subscription
|
||||
- **Azure container registry**: Selecteer je ACR uit de dropdown (bijv. `zdlas`)
|
||||
- **Service connection name**: `zuyderland-cmdb-acr-connection`
|
||||
- ⚠️ **Belangrijk**: Deze naam moet overeenkomen met `dockerRegistryServiceConnection` in `azure-pipelines.yml`!
|
||||
- **Description**: Optioneel (bijv. "ACR for CMDB GUI production")
|
||||
|
||||
7. **Save**
|
||||
- Klik **"Save"** (of **"Verify and save"**)
|
||||
- Azure DevOps test automatisch de connection
|
||||
- Azure DevOps maakt automatisch een Service Principal aan met de juiste permissions
|
||||
|
||||
**💡 Waarom Service Principal?**
|
||||
- ✅ Werkt perfect met Azure DevOps Services (cloud)
|
||||
- ✅ Eenvoudig - Azure DevOps doet alles automatisch
|
||||
- ✅ Betrouwbaar - Meest ondersteunde optie
|
||||
- ✅ Veilig - Credentials worden veilig beheerd
|
||||
|
||||
📚 Zie `docs/AZURE-SERVICE-CONNECTION-AUTH.md` voor details over alle authentication types.
|
||||
|
||||
**✅ Service connection is aangemaakt!**
|
||||
|
||||
**Troubleshooting:**
|
||||
- **"Loading Registries..." blijft hangen?**
|
||||
- ✅ Refresh de pagina (F5)
|
||||
- ✅ Check of je de juiste subscription hebt geselecteerd
|
||||
- ✅ Wacht 10-30 seconden (kan even duren)
|
||||
- ✅ **Workaround**: Gebruik "Others" optie (zie hieronder)
|
||||
- Als verificatie faalt, check of je toegang hebt tot de ACR in Azure Portal
|
||||
|
||||
**🔧 Workaround: Als dropdown niet werkt, gebruik "Others" optie:**
|
||||
|
||||
1. Kies **"Docker Registry"** → **"Others"** (in plaats van "Azure Container Registry")
|
||||
2. Vul handmatig in:
|
||||
- **Docker Registry**: `zdlas.azurecr.io`
|
||||
- **Docker ID**: (haal op met `az acr credential show --name zdlas`)
|
||||
- **Docker Password**: (haal op met `az acr credential show --name zdlas`)
|
||||
3. **Service connection name**: `zuyderland-cmdb-acr-connection`
|
||||
4. Save
|
||||
|
||||
**Haal credentials op:**
|
||||
```bash
|
||||
az login
|
||||
az acr credential show --name zdlas
|
||||
# Gebruik "username" en "passwords[0].value"
|
||||
```
|
||||
|
||||
📚 Zie `docs/AZURE-SERVICE-CONNECTION-TROUBLESHOOTING.md` voor uitgebreide troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Stap 4: Pipeline Aanmaken en Run
|
||||
|
||||
### Stappen:
|
||||
|
||||
1. **Ga naar Pipelines**
|
||||
- Klik op **"Pipelines"** (links in het menu)
|
||||
|
||||
2. **Create New Pipeline**
|
||||
- Klik op **"New pipeline"** of **"Create Pipeline"**
|
||||
|
||||
3. **Selecteer Repository**
|
||||
- Kies **"Azure Repos Git"** (of waar je code staat)
|
||||
- Selecteer je repository: **"Zuyderland CMDB GUI"** (of jouw repo naam)
|
||||
|
||||
4. **Kies YAML File**
|
||||
- Kies **"Existing Azure Pipelines YAML file"**
|
||||
- Selecteer:
|
||||
- **Branch**: `main` (of jouw default branch)
|
||||
- **Path**: `/azure-pipelines.yml`
|
||||
|
||||
5. **Review Configuration**
|
||||
- Azure DevOps toont de pipeline configuratie
|
||||
- Controleer of alles klopt
|
||||
|
||||
6. **Run Pipeline**
|
||||
- Klik **"Run"** om de pipeline te starten
|
||||
- De pipeline start automatisch met het bouwen van de images
|
||||
|
||||
**✅ Pipeline is gestart!**
|
||||
|
||||
### Wat gebeurt er nu?
|
||||
|
||||
De pipeline zal:
|
||||
1. ✅ Code uitchecken
|
||||
2. ✅ Backend Docker image bouwen
|
||||
3. ✅ Frontend Docker image bouwen
|
||||
4. ✅ Images naar Azure Container Registry pushen
|
||||
|
||||
**Verwachte tijd:** ~5-10 minuten (afhankelijk van build tijd)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Stap 5: Verifieer Images in ACR
|
||||
|
||||
### In Azure Portal:
|
||||
|
||||
1. **Ga naar je Container Registry**
|
||||
- Open Azure Portal
|
||||
- Ga naar je Container Registry (`zuyderlandcmdbacr`)
|
||||
|
||||
2. **Bekijk Repositories**
|
||||
- Klik op **"Repositories"** (links in het menu)
|
||||
- Je zou moeten zien:
|
||||
- `zuyderland-cmdb-gui/backend`
|
||||
- `zuyderland-cmdb-gui/frontend`
|
||||
|
||||
3. **Bekijk Tags**
|
||||
- Klik op een repository (bijv. `zuyderland-cmdb-gui/backend`)
|
||||
- Je zou tags moeten zien:
|
||||
- `latest`
|
||||
- `123` (of build ID nummer)
|
||||
|
||||
**✅ Images zijn succesvol gebouwd en gepusht!**
|
||||
|
||||
### Via Azure CLI:
|
||||
|
||||
```bash
|
||||
# Lijst repositories
|
||||
az acr repository list --name zuyderlandcmdbacr
|
||||
|
||||
# Lijst tags voor backend
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend --orderby time_desc
|
||||
|
||||
# Lijst tags voor frontend
|
||||
az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend --orderby time_desc
|
||||
```
|
||||
|
||||
### In Azure DevOps:
|
||||
|
||||
1. **Ga naar je Pipeline**
|
||||
- Klik op **"Pipelines"**
|
||||
- Klik op je pipeline run
|
||||
|
||||
2. **Bekijk Logs**
|
||||
- Klik op een job (bijv. "Build Docker Images")
|
||||
- Bekijk de logs per stap
|
||||
- Bij success zie je:
|
||||
```
|
||||
Backend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:123
|
||||
Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Pipeline Fails: "Service connection not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de service connection naam in `azure-pipelines.yml` (regel 19) overeenkomt met de naam in Azure DevOps
|
||||
- Ga naar Project Settings → Service connections en verifieer de naam
|
||||
- Pas `dockerRegistryServiceConnection` aan in `azure-pipelines.yml` als nodig
|
||||
|
||||
### Pipeline Fails: "ACR not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de `acrName` variabele correct is in `azure-pipelines.yml` (regel 17)
|
||||
- Verifieer dat de ACR bestaat: `az acr list`
|
||||
- Check of je de juiste subscription hebt geselecteerd in de service connection
|
||||
|
||||
### Pipeline Fails: "Permission denied"
|
||||
|
||||
**Oplossing:**
|
||||
- Controleer of de service connection de juiste permissions heeft
|
||||
- Verifieer dat je Azure subscription toegang heeft tot de ACR
|
||||
- Check of de service connection is geverifieerd (groen vinkje in Azure DevOps)
|
||||
- Probeer de service connection opnieuw aan te maken
|
||||
|
||||
### Images worden niet gepusht
|
||||
|
||||
**Oplossing:**
|
||||
- Check de pipeline logs voor specifieke errors
|
||||
- Verifieer dat de Docker build succesvol is
|
||||
- Controleer of de ACR admin-enabled is (voor development)
|
||||
- Check of de service connection correct is geconfigureerd
|
||||
|
||||
### Build Fails: "Dockerfile not found"
|
||||
|
||||
**Oplossing:**
|
||||
- Verifieer dat `backend/Dockerfile.prod` en `frontend/Dockerfile.prod` bestaan
|
||||
- Check of de paths correct zijn in `azure-pipelines.yml`
|
||||
- Controleer of de files zijn gecommit en gepusht naar de repository
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
Als alles goed is gegaan, heb je nu:
|
||||
|
||||
- ✅ Azure Container Registry aangemaakt
|
||||
- ✅ Service Connection geconfigureerd
|
||||
- ✅ Pipeline aangemaakt en gerund
|
||||
- ✅ Docker images gebouwd en gepusht naar ACR
|
||||
|
||||
**Je images zijn nu beschikbaar op:**
|
||||
- Backend: `zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest`
|
||||
- Frontend: `zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Volgende Stappen
|
||||
|
||||
Nu je images in ACR staan, kun je ze deployen naar:
|
||||
|
||||
1. **Azure Container Instances (ACI)** - Eenvoudig, snel
|
||||
2. **Azure App Service (Container)** - Managed service
|
||||
3. **Azure Kubernetes Service (AKS)** - Voor complexere setups
|
||||
4. **VM met Docker Compose** - Volledige controle
|
||||
|
||||
Zie `docs/AZURE-DEPLOYMENT-SUMMARY.md` voor deployment opties.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- **Quick Start Guide**: `docs/AZURE-ACR-QUICKSTART.md`
|
||||
- **Azure DevOps Setup**: `docs/AZURE-DEVOPS-SETUP.md`
|
||||
- **Container Registry Guide**: `docs/AZURE-CONTAINER-REGISTRY.md`
|
||||
- **Deployment Options**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Automatische Triggers**: De pipeline triggert automatisch bij elke push naar `main` branch
|
||||
2. **Version Tags**: Gebruik git tags (bijv. `v1.0.0`) voor versie-specifieke builds
|
||||
3. **Monitor Costs**: Check Azure Portal regelmatig voor storage gebruik
|
||||
4. **Cleanup**: Overweeg oude images te verwijderen om kosten te besparen
|
||||
|
||||
---
|
||||
|
||||
**Veel succes! 🚀**
|
||||
1061
docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md
Normal file
1061
docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
323
docs/QUICK-DEPLOYMENT-GUIDE.md
Normal file
323
docs/QUICK-DEPLOYMENT-GUIDE.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Quick Deployment Guide - Van Images naar Productie 🚀
|
||||
|
||||
Je Docker images zijn klaar! Hier is een snelle guide om ze te deployen.
|
||||
|
||||
## 🎯 Snelle Keuze: Welke Deployment Optie?
|
||||
|
||||
**Voor 20 gebruikers, productie:**
|
||||
→ **Azure App Service** (Managed, eenvoudig, ~€20/maand)
|
||||
|
||||
**Voor test/development:**
|
||||
→ **VM met Docker Compose** (Flexibel, snel op te zetten)
|
||||
|
||||
**Voor enterprise/scale:**
|
||||
→ **Azure Kubernetes Service** (Complex, maar krachtig)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Optie 1: Azure App Service (Aanbevolen) - 15 minuten
|
||||
|
||||
### Stap 1: Maak App Service Plan aan
|
||||
|
||||
```bash
|
||||
# Resource Group (als nog niet bestaat)
|
||||
az group create --name rg-cmdb-gui --location westeurope
|
||||
|
||||
# App Service Plan (Basic B1 - voldoende voor 20 gebruikers)
|
||||
az appservice plan create \
|
||||
--name plan-cmdb-gui \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--sku B1 \
|
||||
--is-linux
|
||||
```
|
||||
|
||||
### Stap 2: Maak Web Apps aan
|
||||
|
||||
```bash
|
||||
# Backend Web App
|
||||
az webapp create \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--plan plan-cmdb-gui \
|
||||
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest
|
||||
|
||||
# Frontend Web App
|
||||
az webapp create \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--plan plan-cmdb-gui \
|
||||
--deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest
|
||||
```
|
||||
|
||||
### Stap 3: Configureer ACR Authentication
|
||||
|
||||
```bash
|
||||
# Enable managed identity
|
||||
az webapp identity assign \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui
|
||||
|
||||
az webapp identity assign \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui
|
||||
|
||||
# Grant ACR pull permissions
|
||||
az acr update \
|
||||
--name zdlas \
|
||||
--admin-enabled true
|
||||
|
||||
# Configure ACR credentials
|
||||
az webapp config container set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \
|
||||
--docker-registry-server-url https://zdlas.azurecr.io \
|
||||
--docker-registry-server-user $(az acr credential show --name zdlas --query username -o tsv) \
|
||||
--docker-registry-server-password $(az acr credential show --name zdlas --query passwords[0].value -o tsv)
|
||||
|
||||
az webapp config container set \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \
|
||||
--docker-registry-server-url https://zdlas.azurecr.io \
|
||||
--docker-registry-server-user $(az acr credential show --name zdlas --query username -o tsv) \
|
||||
--docker-registry-server-password $(az acr credential show --name zdlas --query passwords[0].value -o tsv)
|
||||
```
|
||||
|
||||
### Stap 4: Configureer Environment Variabelen
|
||||
|
||||
```bash
|
||||
# Backend environment variables
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-backend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--settings \
|
||||
NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
JIRA_BASE_URL=https://jira.zuyderland.nl \
|
||||
JIRA_SCHEMA_ID=your-schema-id \
|
||||
SESSION_SECRET=your-secure-secret \
|
||||
FRONTEND_URL=https://cmdb-frontend-prod.azurewebsites.net
|
||||
|
||||
# Frontend environment variables
|
||||
az webapp config appsettings set \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--settings \
|
||||
VITE_API_URL=https://cmdb-backend-prod.azurewebsites.net/api
|
||||
```
|
||||
|
||||
### Stap 5: Configureer SSL (Gratis via App Service)
|
||||
|
||||
```bash
|
||||
# App Service heeft automatisch SSL via *.azurewebsites.net
|
||||
# Voor custom domain:
|
||||
az webapp config hostname add \
|
||||
--webapp-name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--hostname cmdb.zuyderland.nl
|
||||
|
||||
# Bind SSL certificate
|
||||
az webapp config ssl bind \
|
||||
--name cmdb-frontend-prod \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--certificate-thumbprint <thumbprint> \
|
||||
--ssl-type SNI
|
||||
```
|
||||
|
||||
### Stap 6: Start de Apps
|
||||
|
||||
```bash
|
||||
az webapp start --name cmdb-backend-prod --resource-group rg-cmdb-gui
|
||||
az webapp start --name cmdb-frontend-prod --resource-group rg-cmdb-gui
|
||||
```
|
||||
|
||||
**Je applicatie is nu live op:**
|
||||
- Frontend: `https://cmdb-frontend-prod.azurewebsites.net`
|
||||
- Backend API: `https://cmdb-backend-prod.azurewebsites.net/api`
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Optie 2: VM met Docker Compose - 20 minuten
|
||||
|
||||
### Stap 1: Maak VM aan
|
||||
|
||||
```bash
|
||||
az vm create \
|
||||
--resource-group rg-cmdb-gui \
|
||||
--name vm-cmdb-gui \
|
||||
--image Ubuntu2204 \
|
||||
--size Standard_B2s \
|
||||
--admin-username azureuser \
|
||||
--generate-ssh-keys \
|
||||
--public-ip-sku Standard
|
||||
```
|
||||
|
||||
### Stap 2: SSH naar VM en installeer Docker
|
||||
|
||||
```bash
|
||||
# SSH naar VM
|
||||
ssh azureuser@<vm-public-ip>
|
||||
|
||||
# Installeer Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Installeer Docker Compose
|
||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
### Stap 3: Login naar ACR
|
||||
|
||||
```bash
|
||||
# Installeer Azure CLI (als nog niet geïnstalleerd)
|
||||
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
|
||||
az login
|
||||
|
||||
# Login naar ACR
|
||||
az acr login --name zdlas
|
||||
|
||||
# OF gebruik credentials
|
||||
az acr credential show --name zdlas
|
||||
docker login zdlas.azurecr.io -u <username> -p <password>
|
||||
```
|
||||
|
||||
### Stap 4: Clone Repository en Deploy
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <your-repo-url>
|
||||
cd zuyderland-cmdb-gui
|
||||
|
||||
# Maak .env.production aan
|
||||
nano .env.production
|
||||
# (Plak je environment variabelen)
|
||||
|
||||
# Update docker-compose.prod.acr.yml (ACR naam is al correct: zdlas)
|
||||
|
||||
# Start containers
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
|
||||
# Check status
|
||||
docker-compose -f docker-compose.prod.acr.yml ps
|
||||
docker-compose -f docker-compose.prod.acr.yml logs -f
|
||||
```
|
||||
|
||||
### Stap 5: Configureer Nginx en SSL
|
||||
|
||||
```bash
|
||||
# Installeer certbot voor Let's Encrypt
|
||||
sudo apt update
|
||||
sudo apt install certbot python3-certbot-nginx -y
|
||||
|
||||
# Configureer SSL
|
||||
sudo certbot --nginx -d cmdb.zuyderland.nl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Verificatie
|
||||
|
||||
**Test of alles werkt:**
|
||||
|
||||
```bash
|
||||
# Check backend health
|
||||
curl https://your-backend-url/api/health
|
||||
|
||||
# Check frontend
|
||||
curl https://your-frontend-url
|
||||
|
||||
# Check container logs
|
||||
docker-compose -f docker-compose.prod.acr.yml logs backend
|
||||
docker-compose -f docker-compose.prod.acr.yml logs frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates Deployen
|
||||
|
||||
**Wanneer je code pusht naar `main`:**
|
||||
1. Pipeline bouwt automatisch nieuwe images
|
||||
2. Images worden gepusht naar ACR met nieuwe tag
|
||||
3. Pull nieuwe images:
|
||||
```bash
|
||||
# VM met Docker Compose
|
||||
docker-compose -f docker-compose.prod.acr.yml pull
|
||||
docker-compose -f docker-compose.prod.acr.yml up -d
|
||||
|
||||
# App Service (automatisch via Continuous Deployment)
|
||||
az webapp restart --name cmdb-backend-prod --resource-group rg-cmdb-gui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Environment Variabelen Template
|
||||
|
||||
**Maak `.env.production` aan:**
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
|
||||
# Jira
|
||||
JIRA_BASE_URL=https://jira.zuyderland.nl
|
||||
JIRA_SCHEMA_ID=your-schema-id
|
||||
JIRA_PAT=your-pat-token
|
||||
# OF OAuth
|
||||
JIRA_OAUTH_CLIENT_ID=your-client-id
|
||||
JIRA_OAUTH_CLIENT_SECRET=your-client-secret
|
||||
JIRA_OAUTH_CALLBACK_URL=https://your-domain/api/auth/callback
|
||||
|
||||
# Session
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
|
||||
# AI (Optioneel)
|
||||
ANTHROPIC_API_KEY=your-key
|
||||
OPENAI_API_KEY=your-key
|
||||
|
||||
# Database (als PostgreSQL)
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
|
||||
# Frontend
|
||||
VITE_API_URL=https://your-backend-url/api
|
||||
```
|
||||
|
||||
**⚠️ BELANGRIJK:** Gebruik Azure Key Vault voor secrets in productie!
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Volgende Stappen
|
||||
|
||||
1. **Kies deployment optie** (App Service of VM)
|
||||
2. **Configureer environment variabelen**
|
||||
3. **Deploy en test**
|
||||
4. **Configureer SSL/TLS**
|
||||
5. **Setup monitoring**
|
||||
6. **Documenteer voor team**
|
||||
|
||||
---
|
||||
|
||||
## 📚 Meer Informatie
|
||||
|
||||
- **Volledige Deployment Guide**: `docs/DEPLOYMENT-NEXT-STEPS.md`
|
||||
- **Production Deployment**: `docs/PRODUCTION-DEPLOYMENT.md`
|
||||
- **Azure Deployment Summary**: `docs/AZURE-DEPLOYMENT-SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Checklist
|
||||
|
||||
- [ ] Images geverifieerd in ACR
|
||||
- [ ] Deployment optie gekozen
|
||||
- [ ] Environment variabelen geconfigureerd
|
||||
- [ ] Applicatie gedeployed
|
||||
- [ ] SSL/TLS geconfigureerd
|
||||
- [ ] Health checks werken
|
||||
- [ ] Monitoring ingesteld
|
||||
- [ ] Team geïnformeerd
|
||||
|
||||
**Veel succes met de deployment! 🚀**
|
||||
192
docs/TYPESCRIPT-LOCAL-VS-CI.md
Normal file
192
docs/TYPESCRIPT-LOCAL-VS-CI.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Waarom TypeScript Errors Lokaal Niet Optreden Maar Wel in CI/CD
|
||||
|
||||
## Het Probleem
|
||||
|
||||
TypeScript compilation errors die lokaal niet optreden, maar wel in Azure DevOps pipelines of Docker builds. Dit is een veelvoorkomend probleem met verschillende oorzaken.
|
||||
|
||||
## Belangrijkste Oorzaken
|
||||
|
||||
### 1. **tsx vs tsc - Development vs Production Build**
|
||||
|
||||
**Lokaal (Development):**
|
||||
```bash
|
||||
npm run dev # Gebruikt: tsx watch src/index.ts
|
||||
```
|
||||
|
||||
**In Docker/CI (Production Build):**
|
||||
```bash
|
||||
npm run build # Gebruikt: tsc (TypeScript Compiler)
|
||||
```
|
||||
|
||||
**Verschil:**
|
||||
- **`tsx`** (TypeScript Execute): Een runtime TypeScript executor die code direct uitvoert zonder volledige type checking. Het is **minder strict** en laat veel type errors door.
|
||||
- **`tsc`** (TypeScript Compiler): De officiële TypeScript compiler die **volledige type checking** doet en alle errors rapporteert.
|
||||
|
||||
**Oplossing:**
|
||||
- Test altijd lokaal met `npm run build` voordat je pusht
|
||||
- Of gebruik `tsc --noEmit` om type checking te doen zonder te builden
|
||||
|
||||
### 2. **TypeScript Versie Verschillen**
|
||||
|
||||
**Lokaal:**
|
||||
```bash
|
||||
npx tsc --version # Kan een andere versie zijn
|
||||
```
|
||||
|
||||
**In Docker:**
|
||||
- Gebruikt de versie uit `package.json` (`"typescript": "^5.6.3"`)
|
||||
- Maar zonder `package-lock.json` kan een andere patch versie geïnstalleerd worden
|
||||
|
||||
**Oplossing:**
|
||||
- Genereer `package-lock.json` met `npm install`
|
||||
- Commit `package-lock.json` naar Git
|
||||
- Dit zorgt voor consistente dependency versies
|
||||
|
||||
### 3. **tsconfig.json Strictness**
|
||||
|
||||
Je `tsconfig.json` heeft `"strict": true`, wat betekent:
|
||||
- Alle strict type checking opties zijn aan
|
||||
- Dit is goed voor productie, maar kan lokaal vervelend zijn
|
||||
|
||||
**Mogelijke verschillen:**
|
||||
- Lokaal kan je IDE/editor andere TypeScript settings hebben
|
||||
- Lokaal kan je `tsconfig.json` overrides hebben
|
||||
- CI/CD gebruikt altijd de exacte `tsconfig.json` uit de repo
|
||||
|
||||
### 4. **Node.js Versie Verschillen**
|
||||
|
||||
**Lokaal:**
|
||||
- Kan een andere Node.js versie hebben
|
||||
- TypeScript gedrag kan verschillen tussen Node versies
|
||||
|
||||
**In Docker:**
|
||||
```dockerfile
|
||||
FROM node:20-alpine # Specifieke Node versie
|
||||
```
|
||||
|
||||
**Oplossing:**
|
||||
- Gebruik `.nvmrc` of `package.json` engines field om Node versie te specificeren
|
||||
- Zorg dat lokaal dezelfde Node versie gebruikt wordt
|
||||
|
||||
### 5. **Cached Builds**
|
||||
|
||||
**Lokaal:**
|
||||
- Oude compiled files in `dist/` kunnen nog werken
|
||||
- IDE kan gecachte type informatie gebruiken
|
||||
- `tsx` gebruikt geen build output, dus errors worden niet altijd gezien
|
||||
|
||||
**In CI/CD:**
|
||||
- Schone build elke keer
|
||||
- Geen cache, dus alle errors worden gezien
|
||||
|
||||
### 6. **Incremental Compilation**
|
||||
|
||||
**Lokaal:**
|
||||
- TypeScript kan incremental compilation gebruiken
|
||||
- Alleen gewijzigde files worden gecheckt
|
||||
|
||||
**In CI/CD:**
|
||||
- Volledige rebuild elke keer
|
||||
- Alle files worden gecheckt
|
||||
|
||||
## Best Practices om Dit Te Voorkomen
|
||||
|
||||
### 1. Test Lokaal Met Production Build
|
||||
|
||||
```bash
|
||||
# Voordat je pusht, test altijd:
|
||||
cd backend
|
||||
npm run build # Dit gebruikt tsc, net als in Docker
|
||||
```
|
||||
|
||||
### 2. Type Checking Zonder Build
|
||||
|
||||
```bash
|
||||
# Alleen type checking, geen build:
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
### 3. Pre-commit Hooks
|
||||
|
||||
Voeg een pre-commit hook toe die `tsc --noEmit` draait:
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"precommit": "npm run type-check"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Genereer package-lock.json
|
||||
|
||||
```bash
|
||||
# Genereer lock file voor consistente dependencies:
|
||||
npm install
|
||||
|
||||
# Commit package-lock.json naar Git
|
||||
git add package-lock.json
|
||||
git commit -m "Add package-lock.json for consistent builds"
|
||||
```
|
||||
|
||||
### 5. Gebruik CI/CD Lokaal
|
||||
|
||||
Test je pipeline lokaal met:
|
||||
- **act** (voor GitHub Actions)
|
||||
- **Azure DevOps Pipeline Agent** lokaal
|
||||
- **Docker build** lokaal: `docker build -f backend/Dockerfile.prod -t test-build ./backend`
|
||||
|
||||
### 6. IDE TypeScript Settings
|
||||
|
||||
Zorg dat je IDE dezelfde TypeScript versie gebruikt:
|
||||
- VS Code: Check "TypeScript: Select TypeScript Version"
|
||||
- Gebruik "Use Workspace Version"
|
||||
|
||||
## Voor Dit Project Specifiek
|
||||
|
||||
### Huidige Situatie
|
||||
|
||||
1. **Development:** `tsx watch` - minder strict
|
||||
2. **Production Build:** `tsc` - volledig strict
|
||||
3. **Geen package-lock.json** - verschillende dependency versies mogelijk
|
||||
4. **TypeScript 5.6.3** in package.json, maar lokaal mogelijk 5.9.3
|
||||
|
||||
### Aanbevolen Acties
|
||||
|
||||
1. **Genereer package-lock.json:**
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
git add package-lock.json
|
||||
git commit -m "Add package-lock.json"
|
||||
```
|
||||
|
||||
2. **Test altijd met build:**
|
||||
```bash
|
||||
npm run build # Voordat je pusht
|
||||
```
|
||||
|
||||
3. **Voeg type-check script toe:**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Test Docker build lokaal:**
|
||||
```bash
|
||||
docker build -f backend/Dockerfile.prod -t test-backend ./backend
|
||||
```
|
||||
|
||||
## Conclusie
|
||||
|
||||
Het verschil komt vooral door:
|
||||
- **tsx (dev) vs tsc (build)** - tsx is minder strict
|
||||
- **Geen package-lock.json** - verschillende dependency versies
|
||||
- **Cached builds lokaal** - oude code werkt nog
|
||||
|
||||
**Oplossing:** Test altijd met `npm run build` lokaal voordat je pusht, en genereer `package-lock.json` voor consistente builds.
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
|
||||
import { Routes, Route, Link, useLocation, Navigate, useParams, useNavigate } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import SearchDashboard from './components/SearchDashboard';
|
||||
import Dashboard from './components/Dashboard';
|
||||
@@ -22,8 +22,18 @@ import DataCompletenessConfig from './components/DataCompletenessConfig';
|
||||
import BIASyncDashboard from './components/BIASyncDashboard';
|
||||
import BusinessImportanceComparison from './components/BusinessImportanceComparison';
|
||||
import Login from './components/Login';
|
||||
import ForgotPassword from './components/ForgotPassword';
|
||||
import ResetPassword from './components/ResetPassword';
|
||||
import AcceptInvitation from './components/AcceptInvitation';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import UserManagement from './components/UserManagement';
|
||||
import RoleManagement from './components/RoleManagement';
|
||||
import ProfileSettings from './components/ProfileSettings';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
|
||||
// Module-level singleton to prevent duplicate initialization across StrictMode remounts
|
||||
let initializationPromise: Promise<void> | null = null;
|
||||
|
||||
// Redirect component for old app-components/overview/:id paths
|
||||
function RedirectToApplicationEdit() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -35,6 +45,7 @@ interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
exact?: boolean;
|
||||
requiredPermission?: string; // Permission required to see this menu item
|
||||
}
|
||||
|
||||
interface NavDropdown {
|
||||
@@ -45,11 +56,24 @@ interface NavDropdown {
|
||||
}
|
||||
|
||||
// Dropdown component for navigation
|
||||
function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) {
|
||||
function NavDropdown({ dropdown, isActive, hasPermission }: { dropdown: NavDropdown; isActive: boolean; hasPermission: (permission: string) => boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// Filter items based on permissions
|
||||
const visibleItems = dropdown.items.filter(item => {
|
||||
if (!item.requiredPermission) {
|
||||
return true; // No permission required, show item
|
||||
}
|
||||
return hasPermission(item.requiredPermission);
|
||||
});
|
||||
|
||||
// Don't render dropdown if no items are visible
|
||||
if (visibleItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -90,7 +114,7 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive:
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
{dropdown.items.map((item) => {
|
||||
{visibleItems.map((item) => {
|
||||
const itemActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path);
|
||||
@@ -119,16 +143,26 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive:
|
||||
function UserMenu() {
|
||||
const { user, authMethod, logout } = useAuthStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const initials = user.displayName
|
||||
const displayName = user.displayName || user.username || user.email || 'User';
|
||||
const email = user.email || user.emailAddress || '';
|
||||
|
||||
const initials = displayName
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsOpen(false);
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -138,7 +172,7 @@ function UserMenu() {
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
alt={displayName}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
@@ -146,7 +180,7 @@ function UserMenu() {
|
||||
<span className="text-white text-sm font-medium">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-gray-700 hidden sm:block">{user.displayName}</span>
|
||||
<span className="text-sm text-gray-700 hidden sm:block">{displayName}</span>
|
||||
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
@@ -160,26 +194,36 @@ function UserMenu() {
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-20">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900">{user.displayName}</p>
|
||||
{user.emailAddress && (
|
||||
<p className="text-xs text-gray-500 truncate">{user.emailAddress}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{displayName}</p>
|
||||
{email && (
|
||||
<p className="text-xs text-gray-500 truncate">{email}</p>
|
||||
)}
|
||||
{user.username && email !== user.username && (
|
||||
<p className="text-xs text-gray-500 truncate">@{user.username}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{authMethod === 'oauth' ? 'Jira OAuth' : 'Service Account'}
|
||||
{authMethod === 'oauth' ? 'Jira OAuth' : authMethod === 'local' ? 'Lokaal Account' : 'Service Account'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{authMethod === 'oauth' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Uitloggen
|
||||
</button>
|
||||
{(authMethod === 'local' || authMethod === 'oauth') && (
|
||||
<>
|
||||
<Link
|
||||
to="/settings/profile"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Profiel & Instellingen
|
||||
</Link>
|
||||
<div className="border-t border-gray-100 my-1"></div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Uitloggen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -190,15 +234,17 @@ function UserMenu() {
|
||||
|
||||
function AppContent() {
|
||||
const location = useLocation();
|
||||
const hasPermission = useAuthStore((state) => state.hasPermission);
|
||||
const config = useAuthStore((state) => state.config);
|
||||
|
||||
// Navigation structure
|
||||
const appComponentsDropdown: NavDropdown = {
|
||||
label: 'Application Component',
|
||||
basePath: '/application',
|
||||
items: [
|
||||
{ path: '/app-components', label: 'Dashboard', exact: true },
|
||||
{ path: '/application/overview', label: 'Overzicht', exact: false },
|
||||
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true },
|
||||
{ path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' },
|
||||
{ path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' },
|
||||
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -206,16 +252,16 @@ function AppContent() {
|
||||
label: 'Rapporten',
|
||||
basePath: '/reports',
|
||||
items: [
|
||||
{ path: '/reports', label: 'Overzicht', exact: true },
|
||||
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true },
|
||||
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true },
|
||||
{ path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true },
|
||||
{ path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true },
|
||||
{ path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true },
|
||||
{ path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true },
|
||||
{ path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true },
|
||||
{ path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true },
|
||||
{ path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true },
|
||||
{ path: '/reports', label: 'Overzicht', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true, requiredPermission: 'view_reports' },
|
||||
{ path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true, requiredPermission: 'view_reports' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -223,7 +269,7 @@ function AppContent() {
|
||||
label: 'Apps',
|
||||
basePath: '/apps',
|
||||
items: [
|
||||
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true },
|
||||
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true, requiredPermission: 'search' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -231,9 +277,18 @@ function AppContent() {
|
||||
label: 'Instellingen',
|
||||
basePath: '/settings',
|
||||
items: [
|
||||
{ path: '/settings/fte-config', label: 'FTE Config', exact: true },
|
||||
{ path: '/settings/data-model', label: 'Datamodel', exact: true },
|
||||
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true },
|
||||
{ path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' },
|
||||
{ path: '/settings/data-model', label: 'Datamodel', exact: true, requiredPermission: 'manage_settings' },
|
||||
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true, requiredPermission: 'manage_settings' },
|
||||
],
|
||||
};
|
||||
|
||||
const adminDropdown: NavDropdown = {
|
||||
label: 'Beheer',
|
||||
basePath: '/admin',
|
||||
items: [
|
||||
{ path: '/admin/users', label: 'Gebruikers', exact: true, requiredPermission: 'manage_users' },
|
||||
{ path: '/admin/roles', label: 'Rollen', exact: true, requiredPermission: 'manage_roles' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -241,7 +296,7 @@ function AppContent() {
|
||||
const isReportsActive = location.pathname.startsWith('/reports');
|
||||
const isSettingsActive = location.pathname.startsWith('/settings');
|
||||
const isAppsActive = location.pathname.startsWith('/apps');
|
||||
const isDashboardActive = location.pathname === '/';
|
||||
const isAdminActive = location.pathname.startsWith('/admin');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
@@ -254,37 +309,27 @@ function AppContent() {
|
||||
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Analyse Tool
|
||||
{config?.appName || 'CMDB Insight'}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
||||
<p className="text-xs text-gray-500">{config?.appTagline || 'Management console for Jira Assets'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-1">
|
||||
{/* Dashboard (Search) */}
|
||||
<Link
|
||||
to="/"
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isDashboardActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
{/* Application Component Dropdown */}
|
||||
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
|
||||
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} hasPermission={hasPermission} />
|
||||
|
||||
{/* Apps Dropdown */}
|
||||
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} />
|
||||
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
|
||||
|
||||
{/* Reports Dropdown */}
|
||||
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
|
||||
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} hasPermission={hasPermission} />
|
||||
|
||||
{/* Settings Dropdown */}
|
||||
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} />
|
||||
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} hasPermission={hasPermission} />
|
||||
|
||||
{/* Admin Dropdown */}
|
||||
<NavDropdown dropdown={adminDropdown} isActive={isAdminActive} hasPermission={hasPermission} />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -297,36 +342,43 @@ function AppContent() {
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
{/* Main Dashboard (Search) */}
|
||||
<Route path="/" element={<SearchDashboard />} />
|
||||
<Route path="/" element={<ProtectedRoute><SearchDashboard /></ProtectedRoute>} />
|
||||
|
||||
{/* Application routes (new structure) */}
|
||||
<Route path="/application/overview" element={<ApplicationList />} />
|
||||
<Route path="/application/fte-calculator" element={<FTECalculator />} />
|
||||
<Route path="/application/:id" element={<ApplicationInfo />} />
|
||||
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
|
||||
{/* Application routes (new structure) - specific routes first, then dynamic */}
|
||||
<Route path="/application/overview" element={<ProtectedRoute requirePermission="search"><ApplicationList /></ProtectedRoute>} />
|
||||
<Route path="/application/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></ProtectedRoute>} />
|
||||
<Route path="/application/:id/edit" element={<ProtectedRoute requirePermission="edit_applications"><GovernanceModelHelper /></ProtectedRoute>} />
|
||||
<Route path="/application/:id" element={<ProtectedRoute requirePermission="search"><ApplicationInfo /></ProtectedRoute>} />
|
||||
|
||||
{/* Application Component routes */}
|
||||
<Route path="/app-components" element={<Dashboard />} />
|
||||
<Route path="/app-components" element={<ProtectedRoute requirePermission="search"><Dashboard /></ProtectedRoute>} />
|
||||
|
||||
{/* Reports routes */}
|
||||
<Route path="/reports" element={<ReportsDashboard />} />
|
||||
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
|
||||
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
|
||||
<Route path="/reports/technical-debt-heatmap" element={<TechnicalDebtHeatmap />} />
|
||||
<Route path="/reports/lifecycle-pipeline" element={<LifecyclePipeline />} />
|
||||
<Route path="/reports/data-completeness" element={<DataCompletenessScore />} />
|
||||
<Route path="/reports/zira-domain-coverage" element={<ZiRADomainCoverage />} />
|
||||
<Route path="/reports/fte-per-zira-domain" element={<FTEPerZiRADomain />} />
|
||||
<Route path="/reports/complexity-dynamics-bubble" element={<ComplexityDynamicsBubbleChart />} />
|
||||
<Route path="/reports/business-importance-comparison" element={<BusinessImportanceComparison />} />
|
||||
<Route path="/reports" element={<ProtectedRoute requirePermission="view_reports"><ReportsDashboard /></ProtectedRoute>} />
|
||||
<Route path="/reports/team-dashboard" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
|
||||
<Route path="/reports/governance-analysis" element={<ProtectedRoute requirePermission="view_reports"><GovernanceAnalysis /></ProtectedRoute>} />
|
||||
<Route path="/reports/technical-debt-heatmap" element={<ProtectedRoute requirePermission="view_reports"><TechnicalDebtHeatmap /></ProtectedRoute>} />
|
||||
<Route path="/reports/lifecycle-pipeline" element={<ProtectedRoute requirePermission="view_reports"><LifecyclePipeline /></ProtectedRoute>} />
|
||||
<Route path="/reports/data-completeness" element={<ProtectedRoute requirePermission="view_reports"><DataCompletenessScore /></ProtectedRoute>} />
|
||||
<Route path="/reports/zira-domain-coverage" element={<ProtectedRoute requirePermission="view_reports"><ZiRADomainCoverage /></ProtectedRoute>} />
|
||||
<Route path="/reports/fte-per-zira-domain" element={<ProtectedRoute requirePermission="view_reports"><FTEPerZiRADomain /></ProtectedRoute>} />
|
||||
<Route path="/reports/complexity-dynamics-bubble" element={<ProtectedRoute requirePermission="view_reports"><ComplexityDynamicsBubbleChart /></ProtectedRoute>} />
|
||||
<Route path="/reports/business-importance-comparison" element={<ProtectedRoute requirePermission="view_reports"><BusinessImportanceComparison /></ProtectedRoute>} />
|
||||
|
||||
{/* Apps routes */}
|
||||
<Route path="/apps/bia-sync" element={<BIASyncDashboard />} />
|
||||
<Route path="/apps/bia-sync" element={<ProtectedRoute requirePermission="search"><BIASyncDashboard /></ProtectedRoute>} />
|
||||
|
||||
{/* Settings routes */}
|
||||
<Route path="/settings/fte-config" element={<ConfigurationV25 />} />
|
||||
<Route path="/settings/data-model" element={<DataModelDashboard />} />
|
||||
<Route path="/settings/data-completeness-config" element={<DataCompletenessConfig />} />
|
||||
<Route path="/settings/fte-config" element={<ProtectedRoute requirePermission="manage_settings"><ConfigurationV25 /></ProtectedRoute>} />
|
||||
<Route path="/settings/data-model" element={<ProtectedRoute requirePermission="manage_settings"><DataModelDashboard /></ProtectedRoute>} />
|
||||
<Route path="/settings/data-completeness-config" element={<ProtectedRoute requirePermission="manage_settings"><DataCompletenessConfig /></ProtectedRoute>} />
|
||||
<Route path="/settings/profile" element={<ProtectedRoute><ProfileSettings /></ProtectedRoute>} />
|
||||
{/* Legacy redirects for old routes */}
|
||||
<Route path="/settings/user-settings" element={<Navigate to="/settings/profile" replace />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin/users" element={<ProtectedRoute requirePermission="manage_users"><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="/admin/roles" element={<ProtectedRoute requirePermission="manage_roles"><RoleManagement /></ProtectedRoute>} />
|
||||
|
||||
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
|
||||
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
|
||||
@@ -336,7 +388,7 @@ function AppContent() {
|
||||
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
|
||||
<Route path="/reports/data-model" element={<Navigate to="/settings/data-model" replace />} />
|
||||
<Route path="/reports/bia-sync" element={<Navigate to="/apps/bia-sync" replace />} />
|
||||
<Route path="/teams" element={<TeamDashboard />} />
|
||||
<Route path="/teams" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
|
||||
<Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
@@ -345,39 +397,180 @@ function AppContent() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, isLoading, checkAuth, fetchConfig, config } = useAuthStore();
|
||||
const { isAuthenticated, checkAuth, fetchConfig, config, user, authMethod, isInitialized, setInitialized, setConfig } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch auth config first, then check auth status
|
||||
const init = async () => {
|
||||
await fetchConfig();
|
||||
await checkAuth();
|
||||
};
|
||||
init();
|
||||
}, [fetchConfig, checkAuth]);
|
||||
// Use singleton pattern to ensure initialization happens only once
|
||||
// This works across React StrictMode remounts
|
||||
|
||||
// Check if already initialized by checking store state
|
||||
const currentState = useAuthStore.getState();
|
||||
if (currentState.config && currentState.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
// If already initializing, wait for existing promise
|
||||
if (initializationPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create singleton initialization promise
|
||||
// OPTIMIZATION: Run config and auth checks in parallel instead of sequentially
|
||||
initializationPromise = (async () => {
|
||||
try {
|
||||
const state = useAuthStore.getState();
|
||||
const defaultConfig = {
|
||||
appName: 'CMDB Insight',
|
||||
appTagline: 'Management console for Jira Assets',
|
||||
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
||||
authMethod: 'local' as const,
|
||||
oauthEnabled: false,
|
||||
serviceAccountEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
jiraHost: '',
|
||||
};
|
||||
|
||||
// Parallelize API calls - this is the key optimization!
|
||||
// Instead of waiting for config then auth (sequential), do both at once
|
||||
await Promise.allSettled([
|
||||
state.config ? Promise.resolve() : fetchConfig(),
|
||||
checkAuth(),
|
||||
]);
|
||||
|
||||
// Ensure config is set (use fetched or default)
|
||||
const stateAfterInit = useAuthStore.getState();
|
||||
if (!stateAfterInit.config) {
|
||||
setConfig(defaultConfig);
|
||||
}
|
||||
|
||||
// Ensure isLoading is false
|
||||
const finalState = useAuthStore.getState();
|
||||
if (finalState.isLoading) {
|
||||
const { setLoading } = useAuthStore.getState();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('[App] Initialization error:', error);
|
||||
// Always mark as initialized to prevent infinite loading
|
||||
const state = useAuthStore.getState();
|
||||
if (!state.config) {
|
||||
setConfig({
|
||||
appName: 'CMDB Insight',
|
||||
appTagline: 'Management console for Jira Assets',
|
||||
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
||||
authMethod: 'local',
|
||||
oauthEnabled: false,
|
||||
serviceAccountEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
jiraHost: '',
|
||||
});
|
||||
}
|
||||
setInitialized(true);
|
||||
}
|
||||
})();
|
||||
|
||||
// Reduced timeout since we're optimizing - 1.5 seconds should be plenty
|
||||
const timeoutId = setTimeout(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (!state.config) {
|
||||
setConfig({
|
||||
appName: 'CMDB Insight',
|
||||
appTagline: 'Management console for Jira Assets',
|
||||
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
||||
authMethod: 'local',
|
||||
oauthEnabled: false,
|
||||
serviceAccountEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
jiraHost: '',
|
||||
});
|
||||
}
|
||||
setInitialized(true);
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty deps - functions from store are stable
|
||||
|
||||
// Auth routes that should render outside the main layout
|
||||
const isAuthRoute = ['/login', '/forgot-password', '/reset-password', '/accept-invitation'].includes(location.pathname);
|
||||
|
||||
// Handle missing config after initialization using useEffect
|
||||
useEffect(() => {
|
||||
if (isInitialized && !config) {
|
||||
setConfig({
|
||||
appName: 'CMDB Insight',
|
||||
appTagline: 'Management console for Jira Assets',
|
||||
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
||||
authMethod: 'local',
|
||||
oauthEnabled: false,
|
||||
serviceAccountEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
jiraHost: '',
|
||||
});
|
||||
}
|
||||
}, [isInitialized, config, setConfig]);
|
||||
|
||||
// Get current config from store (might be updated by useEffect above)
|
||||
const currentConfig = config || useAuthStore.getState().config;
|
||||
|
||||
// If on an auth route, render it directly (no layout) - don't wait for config
|
||||
if (isAuthRoute) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/accept-invitation" element={<AcceptInvitation />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// For non-auth routes, we need config
|
||||
// Show loading ONLY if we don't have config
|
||||
// Once initialized and we have config, proceed even if isLoading is true
|
||||
// (isLoading might be stuck due to StrictMode duplicate calls)
|
||||
if (!currentConfig) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-slate-400">Laden...</p>
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 font-medium">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login if OAuth is enabled and not authenticated
|
||||
if (config?.authMethod === 'oauth' && !isAuthenticated) {
|
||||
return <Login />;
|
||||
// STRICT AUTHENTICATION CHECK:
|
||||
// Service accounts are NOT used for application authentication
|
||||
// They are only for Jira API access (JIRA_SERVICE_ACCOUNT_TOKEN in .env)
|
||||
// Application authentication ALWAYS requires a real user session (local or OAuth)
|
||||
|
||||
// Check if this is a service account user (should never happen, but reject if it does)
|
||||
const isServiceAccount = user?.accountId === 'service-account' || authMethod === 'service-account';
|
||||
|
||||
// Check if user is a real authenticated user (has id, not service account)
|
||||
const isRealUser = isAuthenticated && user && user.id && !isServiceAccount;
|
||||
|
||||
// ALWAYS reject service account users - they are NOT valid for application authentication
|
||||
if (isServiceAccount) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Show login if nothing is configured
|
||||
if (config?.authMethod === 'none') {
|
||||
return <Login />;
|
||||
// If not authenticated as a real user, redirect to login
|
||||
if (!isRealUser) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Real user authenticated - allow access
|
||||
|
||||
// At this point, user is either:
|
||||
// 1. Authenticated (isAuthenticated === true), OR
|
||||
// 2. Service account is explicitly allowed (allowServiceAccount === true)
|
||||
// Show main app
|
||||
return <AppContent />;
|
||||
}
|
||||
|
||||
281
frontend/src/components/AcceptInvitation.tsx
Normal file
281
frontend/src/components/AcceptInvitation.tsx
Normal 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
@@ -569,7 +569,7 @@ export default function ApplicationList() {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string | null }) {
|
||||
export function StatusBadge({ status, variant = 'default' }: { status: string | null; variant?: 'default' | 'header' }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
'Closed': 'badge-dark-red',
|
||||
'Deprecated': 'badge-yellow',
|
||||
@@ -582,7 +582,38 @@ export function StatusBadge({ status }: { status: string | null }) {
|
||||
'Undefined': 'badge-gray',
|
||||
};
|
||||
|
||||
if (!status) return <span className="text-sm text-gray-400">-</span>;
|
||||
// Header variant colors - matching blue/indigo palette of the header
|
||||
const headerStatusColors: Record<string, { bg: string; text: string }> = {
|
||||
'Closed': { bg: 'bg-slate-600', text: 'text-white' },
|
||||
'Deprecated': { bg: 'bg-amber-500', text: 'text-white' },
|
||||
'End of life': { bg: 'bg-red-500', text: 'text-white' },
|
||||
'End of support': { bg: 'bg-red-400', text: 'text-white' },
|
||||
'Implementation': { bg: 'bg-blue-500', text: 'text-white' },
|
||||
'In Production': { bg: 'bg-emerald-600', text: 'text-white' },
|
||||
'Proof of Concept': { bg: 'bg-teal-500', text: 'text-white' },
|
||||
'Shadow IT': { bg: 'bg-slate-800', text: 'text-white' },
|
||||
'Undefined': { bg: 'bg-slate-400', text: 'text-white' },
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
if (variant === 'header') {
|
||||
return <span className="text-lg lg:text-xl text-white/70">-</span>;
|
||||
}
|
||||
return <span className="text-sm text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
if (variant === 'header') {
|
||||
const colors = headerStatusColors[status] || { bg: 'bg-slate-400', text: 'text-white' };
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-4 py-1.5 rounded-lg text-base lg:text-lg font-semibold backdrop-blur-sm shadow-sm',
|
||||
colors.bg,
|
||||
colors.text
|
||||
)}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx('badge', statusColors[status] || 'badge-gray')}>
|
||||
|
||||
60
frontend/src/components/AuthLayout.tsx
Normal file
60
frontend/src/components/AuthLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { searchApplications } from '../services/api';
|
||||
import type { ApplicationListItem, ReferenceValue, ApplicationStatus } from '../types';
|
||||
import type { ReferenceValue, ApplicationStatus } from '../types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const ALL_STATUSES: ApplicationStatus[] = [
|
||||
@@ -116,7 +116,7 @@ export default function ComplexityDynamicsBubbleChart() {
|
||||
x: complexity,
|
||||
y: dynamics,
|
||||
z: Math.max(0.1, fte), // Minimum size for visibility
|
||||
bia: app.businessImpactAnalyse?.name || null,
|
||||
bia: app.businessImpactAnalyse?.name || '',
|
||||
biaId: app.businessImpactAnalyse?.objectId || 'none',
|
||||
name: app.name,
|
||||
id: app.id,
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function Configuration() {
|
||||
<div className="space-y-4">
|
||||
{[...config.governanceModelRules]
|
||||
.sort((a, b) => a.governanceModel.localeCompare(b.governanceModel))
|
||||
.map((rule, originalIndex) => {
|
||||
.map((rule) => {
|
||||
// Find the original index in the unsorted array
|
||||
const index = config.governanceModelRules.findIndex(r => r === rule);
|
||||
return (
|
||||
@@ -329,8 +329,9 @@ function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting
|
||||
// Check if rule is a simple EffortRule or ApplicationTypeRule
|
||||
const isSimpleRule = 'result' in rule && !('applicationTypes' in rule);
|
||||
|
||||
if (isSimpleRule) {
|
||||
if (isSimpleRule && typeof rule === 'object' && 'result' in rule) {
|
||||
// Simple EffortRule
|
||||
const simpleRule = rule as { result: number };
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -339,8 +340,8 @@ function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
value={simpleRule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 } as any)}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">FTE</span>
|
||||
@@ -485,7 +486,7 @@ function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
updateDefaultRule(newRules);
|
||||
}}
|
||||
onRemove={rule.default.length > 1 ? () => {
|
||||
onRemove={rule.default && Array.isArray(rule.default) && rule.default.length > 1 ? () => {
|
||||
const newRules = (rule.default as any[]).filter((_, i) => i !== index);
|
||||
updateDefaultRule(newRules.length === 1 ? newRules[0] : newRules);
|
||||
} : undefined}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type GovernanceModelConfigV25,
|
||||
type ApplicationTypeConfigV25,
|
||||
type BIALevelConfig,
|
||||
type FTERange,
|
||||
} from '../services/api';
|
||||
import type { ReferenceValue } from '../types';
|
||||
|
||||
@@ -25,7 +24,7 @@ export default function ConfigurationV25() {
|
||||
const [hostingOptions, setHostingOptions] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTypeOptions, setApplicationTypeOptions] = useState<ReferenceValue[]>([]);
|
||||
const [biaOptions, setBiaOptions] = useState<ReferenceValue[]>([]);
|
||||
const [governanceOptions, setGovernanceOptions] = useState<ReferenceValue[]>([]);
|
||||
const [, setGovernanceOptions] = useState<ReferenceValue[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
@@ -241,7 +240,7 @@ function RegieModelEditor({
|
||||
code,
|
||||
model,
|
||||
hostingOptions,
|
||||
applicationTypeOptions,
|
||||
applicationTypeOptions: _appTypes,
|
||||
biaOptions,
|
||||
onUpdate,
|
||||
onUpdateAppType
|
||||
@@ -325,7 +324,7 @@ interface ApplicationTypeEditorProps {
|
||||
onUpdate: (updates: Partial<ApplicationTypeConfigV25>) => void;
|
||||
}
|
||||
|
||||
function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions, onUpdate }: ApplicationTypeEditorProps) {
|
||||
function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions: _bia, onUpdate }: ApplicationTypeEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -445,7 +444,7 @@ interface BIALevelEditorProps {
|
||||
onUpdate: (updates: Partial<BIALevelConfig>) => void;
|
||||
}
|
||||
|
||||
function BIALevelEditor({ biaLevel, config, hostingOptions, onUpdate }: BIALevelEditorProps) {
|
||||
function BIALevelEditor({ biaLevel, config, hostingOptions: _hosting, onUpdate }: BIALevelEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ function generateId(): string {
|
||||
|
||||
export default function DataCompletenessConfig() {
|
||||
const [config, setConfig] = useState<DataCompletenessConfig | null>(null);
|
||||
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||
const [, setSchema] = useState<SchemaResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getDataCompletenessConfig, type DataCompletenessConfig } from '../services/api';
|
||||
import type { DataCompletenessConfig } from '../services/api';
|
||||
|
||||
interface FieldCompleteness {
|
||||
field: string;
|
||||
@@ -79,19 +79,18 @@ export default function DataCompletenessScore() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch config and data in parallel
|
||||
const [configResult, dataResponse] = await Promise.all([
|
||||
getDataCompletenessConfig(),
|
||||
fetch(`${API_BASE}/dashboard/data-completeness`)
|
||||
]);
|
||||
|
||||
setConfig(configResult);
|
||||
// Fetch data (config is included in the response)
|
||||
const dataResponse = await fetch(`${API_BASE}/dashboard/data-completeness`);
|
||||
|
||||
if (!dataResponse.ok) {
|
||||
throw new Error('Failed to fetch data completeness data');
|
||||
}
|
||||
const result = await dataResponse.json();
|
||||
setData(result);
|
||||
// Config is now included in the response
|
||||
if (result.config) {
|
||||
setConfig(result.config);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
|
||||
@@ -44,7 +44,7 @@ export function EffortDisplay({
|
||||
onOverrideChange,
|
||||
}: EffortDisplayProps) {
|
||||
const hasOverride = overrideFte !== null && overrideFte !== undefined;
|
||||
const hasBreakdown = breakdown !== null && breakdown !== undefined;
|
||||
// const hasBreakdown = breakdown !== null && breakdown !== undefined; // Unused
|
||||
|
||||
// Extract breakdown values
|
||||
const baseEffort = breakdown?.baseEffort ?? null;
|
||||
|
||||
121
frontend/src/components/ForgotPassword.tsx
Normal file
121
frontend/src/components/ForgotPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function GovernanceModelBadge({
|
||||
className = '',
|
||||
}: GovernanceModelBadgeProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const style = getGovernanceModelStyle(governanceModelName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
getApplicationForEdit,
|
||||
updateApplication,
|
||||
@@ -64,7 +65,7 @@ export default function GovernanceModelHelper() {
|
||||
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||
const [applicationFunctions, setApplicationFunctions] = useState<ReferenceValue[]>([]);
|
||||
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTeams, setApplicationTeams] = useState<ReferenceValue[]>([]);
|
||||
const [, setApplicationTeams] = useState<ReferenceValue[]>([]);
|
||||
const [subteamToTeamMapping, setSubteamToTeamMapping] = useState<Record<string, ReferenceValue | null>>({});
|
||||
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
|
||||
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
|
||||
@@ -268,11 +269,12 @@ export default function GovernanceModelHelper() {
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
const appName = useAuthStore.getState().config?.appName || 'CMDB Insight';
|
||||
if (application) {
|
||||
document.title = `${application.name} - Bewerken | Zuyderland CMDB`;
|
||||
document.title = `${application.name} - Bewerken | ${appName}`;
|
||||
}
|
||||
return () => {
|
||||
document.title = 'Zuyderland CMDB';
|
||||
document.title = appName;
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
@@ -723,7 +725,7 @@ export default function GovernanceModelHelper() {
|
||||
const selectedHasPrimary = aiPrimaryCode && selectedFunctions.some(
|
||||
(f) => f.key === aiPrimaryCode
|
||||
);
|
||||
const source = aiSuggestion
|
||||
const source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL' = aiSuggestion
|
||||
? selectedHasPrimary
|
||||
? 'AI_ACCEPTED'
|
||||
: 'AI_MODIFIED'
|
||||
@@ -809,7 +811,7 @@ export default function GovernanceModelHelper() {
|
||||
const selectedHasPrimary = aiPrimaryCode && selectedFunctions.some(
|
||||
(f) => f.key === aiPrimaryCode
|
||||
);
|
||||
const source = aiSuggestion
|
||||
const source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL' = aiSuggestion
|
||||
? selectedHasPrimary
|
||||
? 'AI_ACCEPTED'
|
||||
: 'AI_MODIFIED'
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore, getLoginUrl } from '../stores/authStore';
|
||||
import AuthLayout from './AuthLayout';
|
||||
|
||||
export default function Login() {
|
||||
const { config, error, isLoading, fetchConfig, checkAuth } = useAuthStore();
|
||||
const { config, error, isLoading, isAuthenticated, fetchConfig, checkAuth, localLogin, setError } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [authChoice, setAuthChoice] = useState<'local' | 'oauth' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
@@ -15,59 +22,212 @@ export default function Login() {
|
||||
if (loginSuccess === 'success') {
|
||||
// Remove query params and check auth
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
checkAuth();
|
||||
checkAuth().then(() => {
|
||||
// After checkAuth completes, redirect if authenticated
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.user) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (loginError) {
|
||||
useAuthStore.getState().setError(decodeURIComponent(loginError));
|
||||
setError(decodeURIComponent(loginError));
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}, [fetchConfig, checkAuth]);
|
||||
|
||||
// Auto-select auth method if only one is available
|
||||
if (config) {
|
||||
if (config.localAuthEnabled && !config.oauthEnabled) {
|
||||
setAuthChoice('local');
|
||||
} else if (config.oauthEnabled && !config.localAuthEnabled) {
|
||||
setAuthChoice('oauth');
|
||||
}
|
||||
}
|
||||
}, [fetchConfig, checkAuth, setError, config, navigate]);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleJiraLogin = () => {
|
||||
window.location.href = getLoginUrl();
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await localLogin(email, password);
|
||||
// Success - checkAuth will be called automatically
|
||||
await checkAuth();
|
||||
// Redirect to dashboard after successful login
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
// Error is already set in the store
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-slate-400">Laden...</p>
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 font-medium">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl mb-4 shadow-lg shadow-cyan-500/25">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">CMDB Editor</h1>
|
||||
<p className="text-slate-400">ZiRA Classificatie Tool</p>
|
||||
</div>
|
||||
const showLocalAuth = config?.localAuthEnabled;
|
||||
const showOAuth = config?.oauthEnabled;
|
||||
const showBoth = showLocalAuth && showOAuth;
|
||||
|
||||
// PAT mode should NOT be shown to users - it's only for backend configuration
|
||||
// Always show login form by default - local auth should be available even if not explicitly enabled
|
||||
// (users can be created and local auth will be auto-enabled when first user exists)
|
||||
// Only hide login form if explicitly disabled via config
|
||||
const shouldShowLogin = showLocalAuth !== false; // Default to true unless explicitly false
|
||||
const shouldShowLocalLogin = showLocalAuth !== false; // Always show local login unless explicitly disabled
|
||||
|
||||
// Debug logging
|
||||
console.log('[Login] Config:', {
|
||||
authMethod: config?.authMethod,
|
||||
localAuthEnabled: config?.localAuthEnabled,
|
||||
oauthEnabled: config?.oauthEnabled,
|
||||
shouldShowLogin,
|
||||
shouldShowLocalLogin,
|
||||
showLocalAuth,
|
||||
showOAuth,
|
||||
});
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">Inloggen</h2>
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2 text-center">Welkom terug</h2>
|
||||
<p className="text-sm text-gray-600 text-center mb-8">Log in om toegang te krijgen tot de applicatie</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.authMethod === 'oauth' ? (
|
||||
{/* Auth Method Selection (if both are available) */}
|
||||
{showBoth && !authChoice && shouldShowLogin && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600 text-center mb-4">Kies een inlogmethode:</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setAuthChoice('local')}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-white border-2 border-gray-300 hover:border-blue-500 text-gray-700 hover:text-blue-700 font-medium rounded-xl transition-all duration-200 shadow-sm hover:shadow-md group"
|
||||
>
|
||||
<svg className="w-5 h-5 group-hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>Lokaal Inloggen</span>
|
||||
<span className="text-xs text-gray-500 ml-auto">E-mail & Wachtwoord</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAuthChoice('oauth')}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.005 1.005 0 0 0 23.013 0z"/>
|
||||
</svg>
|
||||
<span>Inloggen met Jira</span>
|
||||
<span className="text-xs text-blue-200 ml-auto">OAuth 2.0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Login Form - Always show unless explicitly disabled */}
|
||||
{/* Show if: user selected local, OR local auth is enabled, OR no auth is configured (default to local) */}
|
||||
{(authChoice === 'local' || shouldShowLocalLogin || (!showOAuth && !config?.authMethod)) && (
|
||||
<form onSubmit={handleLocalLogin} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
E-mailadres of gebruikersnaam
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="jouw@email.nl of gebruikersnaam"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Wachtwoord
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
|
||||
>
|
||||
Wachtwoord vergeten?
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Inloggen...
|
||||
</span>
|
||||
) : (
|
||||
'Inloggen'
|
||||
)}
|
||||
</button>
|
||||
{showBoth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthChoice(null)}
|
||||
className="w-full text-sm text-gray-600 hover:text-gray-900 font-medium transition-colors mt-2"
|
||||
>
|
||||
← Terug naar keuze
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OAuth Login */}
|
||||
{(authChoice === 'oauth' || (showOAuth && !showLocalAuth)) && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleJiraLogin}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white font-medium rounded-xl transition-all duration-200 shadow-lg shadow-blue-600/25 hover:shadow-blue-500/40"
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.005 1.005 0 0 0 23.013 0z"/>
|
||||
@@ -75,49 +235,53 @@ export default function Login() {
|
||||
Inloggen met Jira
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-center text-slate-500 text-sm">
|
||||
<p className="mt-4 text-center text-gray-600 text-sm">
|
||||
Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
|
||||
</p>
|
||||
{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">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">Personal Access Token Modus</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
|
||||
</p>
|
||||
<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">
|
||||
)}
|
||||
|
||||
{/* Not Configured - Only show if both auth methods are explicitly disabled */}
|
||||
{/* PAT mode should NEVER be shown - it's only for backend Jira API configuration */}
|
||||
{/* Users always authenticate via local auth or OAuth */}
|
||||
{config?.localAuthEnabled === false && config?.oauthEnabled === false && (
|
||||
<div className="text-center py-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.
|
||||
<p className="text-gray-900 font-semibold mb-2">Authenticatie niet geconfigureerd</p>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Lokale authenticatie of OAuth moet worden ingeschakeld om gebruikers in te laten loggen.
|
||||
Neem contact op met de beheerder om authenticatie te configureren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-8 text-center text-slate-600 text-sm">
|
||||
Zuyderland Medisch Centrum • CMDB Editor v1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Not Configured */}
|
||||
{config?.authMethod === 'none' && (
|
||||
<div className="text-center py-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-900 font-semibold mb-2">Niet geconfigureerd</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Neem contact op met de beheerder om authenticatie te configureren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
305
frontend/src/components/Profile.tsx
Normal file
305
frontend/src/components/Profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1045
frontend/src/components/ProfileSettings.tsx
Normal file
1045
frontend/src/components/ProfileSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
110
frontend/src/components/ProtectedRoute.tsx
Normal file
110
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
222
frontend/src/components/ResetPassword.tsx
Normal file
222
frontend/src/components/ResetPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
410
frontend/src/components/RoleManagement.tsx
Normal file
410
frontend/src/components/RoleManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
|
||||
|
||||
const ITEMS_PER_PAGE = 25;
|
||||
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent';
|
||||
const APPLICATION_COMPONENT_JIRA_NAME = 'Application Component'; // Jira API returns this name with space
|
||||
|
||||
// Helper to strip HTML tags from description
|
||||
function stripHtml(html: string): string {
|
||||
@@ -43,6 +44,74 @@ function getStatusInfo(status: string | null): { color: string; bg: string } {
|
||||
return { color: 'text-gray-600', bg: 'bg-gray-100' };
|
||||
}
|
||||
|
||||
// Helper to get icon component for object type
|
||||
function getObjectTypeIcon(objectTypeName: string | undefined, className: string = "w-6 h-6") {
|
||||
const name = objectTypeName?.toLowerCase() || '';
|
||||
|
||||
// Application Component - application window icon
|
||||
if (name.includes('application component') || name === 'applicationcomponent') {
|
||||
return (
|
||||
<svg className={`${className} text-blue-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
|
||||
{/* Window frame */}
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
{/* Title bar separator */}
|
||||
<line x1="3" y1="8" x2="21" y2="8" />
|
||||
{/* Window control dots */}
|
||||
<circle cx="6" cy="6" r="0.8" fill="currentColor" />
|
||||
<circle cx="9" cy="6" r="0.8" fill="currentColor" />
|
||||
<circle cx="12" cy="6" r="0.8" fill="currentColor" />
|
||||
{/* Content lines */}
|
||||
<line x1="5" y1="11" x2="19" y2="11" strokeWidth="1.5" />
|
||||
<line x1="5" y1="14" x2="15" y2="14" strokeWidth="1.5" />
|
||||
<line x1="5" y1="17" x2="17" y2="17" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Flows - pijlen/flow icoon
|
||||
if (name.includes('flow')) {
|
||||
return (
|
||||
<svg className={`${className} text-indigo-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Server - server icoon
|
||||
if (name.includes('server')) {
|
||||
return (
|
||||
<svg className={`${className} text-green-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Application Function - functie/doel icoon (bar chart)
|
||||
if (name.includes('application function') || name.includes('function')) {
|
||||
return (
|
||||
<svg className={`${className} text-purple-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// User/Privileged User - gebruiker icoon
|
||||
if (name.includes('user') || name.includes('privileged')) {
|
||||
return (
|
||||
<svg className={`${className} text-orange-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Default - generiek object icoon (grid)
|
||||
return (
|
||||
<svg className={`${className} text-gray-500`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -90,11 +159,20 @@ export default function SearchDashboard() {
|
||||
return map;
|
||||
}, [searchResults]);
|
||||
|
||||
// Get sorted object types (by result count, descending)
|
||||
// Get sorted object types (Application Component first if exists, then by result count, descending)
|
||||
const sortedObjectTypes = useMemo(() => {
|
||||
if (!searchResults?.objectTypes) return [];
|
||||
|
||||
return [...searchResults.objectTypes].sort((a, b) => {
|
||||
// Check if either is Application Component
|
||||
const aIsAppComponent = a.name === APPLICATION_COMPONENT_TYPE_NAME || a.name === APPLICATION_COMPONENT_JIRA_NAME;
|
||||
const bIsAppComponent = b.name === APPLICATION_COMPONENT_TYPE_NAME || b.name === APPLICATION_COMPONENT_JIRA_NAME;
|
||||
|
||||
// If one is Application Component and the other isn't, Application Component comes first
|
||||
if (aIsAppComponent && !bIsAppComponent) return -1;
|
||||
if (!aIsAppComponent && bIsAppComponent) return 1;
|
||||
|
||||
// Otherwise, sort by count (descending)
|
||||
const countA = resultsByType.get(a.id)?.length || 0;
|
||||
const countB = resultsByType.get(b.id)?.length || 0;
|
||||
return countB - countA;
|
||||
@@ -178,14 +256,25 @@ export default function SearchDashboard() {
|
||||
setSearchResults(results);
|
||||
|
||||
// Auto-select first tab if results exist
|
||||
// Prioritize Application Component if it exists, otherwise select the tab with most results
|
||||
if (results.objectTypes && results.objectTypes.length > 0) {
|
||||
// Sort by count and select the first one
|
||||
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);
|
||||
// Find Application Component type if it exists
|
||||
const appComponentType = results.objectTypes.find(
|
||||
ot => ot.name === APPLICATION_COMPONENT_TYPE_NAME || ot.name === APPLICATION_COMPONENT_JIRA_NAME
|
||||
);
|
||||
|
||||
if (appComponentType) {
|
||||
// Application Component exists, select it
|
||||
setSelectedTab(appComponentType.id);
|
||||
} else {
|
||||
// No Application Component, sort by count and select the first one
|
||||
const sorted = [...results.objectTypes].sort((a, b) => {
|
||||
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
|
||||
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
|
||||
return countB - countA;
|
||||
});
|
||||
setSelectedTab(sorted[0].id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.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)
|
||||
// Jira API returns "Application Component" (with space), but internal typeName is "ApplicationComponent" (no space)
|
||||
const isApplicationComponent = useCallback((result: CMDBSearchResult) => {
|
||||
const objectType = objectTypeMap.get(result.objectTypeId);
|
||||
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME;
|
||||
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME ||
|
||||
objectType?.name === APPLICATION_COMPONENT_JIRA_NAME;
|
||||
}, [objectTypeMap]);
|
||||
|
||||
// Handle result click (for Application Components)
|
||||
@@ -231,318 +322,419 @@ export default function SearchDashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">CMDB Zoeken</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB.
|
||||
</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">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
|
||||
{/* Header Section */}
|
||||
<div className="text-center mb-10 lg:mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 lg:w-20 lg:h-20 bg-gradient-to-br from-blue-600 via-blue-500 to-indigo-600 rounded-2xl mb-6 shadow-xl shadow-blue-500/20">
|
||||
<svg className="w-8 h-8 lg:w-10 lg:h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Zoek op naam, key, of beschrijving..."
|
||||
className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none"
|
||||
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>
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-3 tracking-tight">
|
||||
CMDB Zoeken
|
||||
</h1>
|
||||
<p className="text-base lg:text-lg text-gray-600 max-w-4xl mx-auto">
|
||||
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB van Zuyderland
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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" />
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-4xl mx-auto mb-8">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none">
|
||||
<svg className="h-6 w-6 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{/* Object Type Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs">
|
||||
{sortedObjectTypes.map((objectType) => {
|
||||
const count = resultsByType.get(objectType.id)?.length || 0;
|
||||
const isActive = selectedTab === objectType.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={objectType.id}
|
||||
onClick={() => handleTabChange(objectType.id)}
|
||||
className={`
|
||||
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors
|
||||
${isActive
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}
|
||||
`}
|
||||
>
|
||||
{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>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Zoek op naam, key, beschrijving of andere attributen..."
|
||||
className="w-full pl-14 pr-32 py-4 lg:py-5 text-base lg:text-lg border-2 border-gray-200 rounded-2xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100/50 transition-all outline-none shadow-sm hover:shadow-md focus:shadow-lg bg-white"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !searchQuery.trim()}
|
||||
className="absolute inset-y-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>
|
||||
|
||||
{/* 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;
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="max-w-4xl mx-auto mb-8">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-6">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Zoeken...</h3>
|
||||
<p className="text-sm text-gray-600">We zoeken in de CMDB naar "{searchQuery}"</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="max-w-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;
|
||||
|
||||
return (
|
||||
<option key={status} value={status}>
|
||||
{status} ({count})
|
||||
</option>
|
||||
<button
|
||||
key={objectType.id}
|
||||
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>
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Wis filter
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results List */}
|
||||
<div className="space-y-2">
|
||||
{paginatedResults.map((result) => {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusDisplay = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
const statusInfo = getStatusInfo(statusDisplay);
|
||||
const description = getAttributeValue(result, 'Description');
|
||||
const isClickable = isApplicationComponent(result);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
onClick={() => isClickable && handleResultClick(result)}
|
||||
className={`
|
||||
bg-white border border-gray-200 rounded-lg p-4
|
||||
${isClickable
|
||||
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all'
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
{/* Status Filter */}
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="px-4 lg:px-6 py-4 bg-white border-b border-gray-100">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filter op status:
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-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>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-400 font-mono">{result.key}</span>
|
||||
{statusDisplay && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}>
|
||||
{statusDisplay}
|
||||
</span>
|
||||
)}
|
||||
{isClickable && (
|
||||
<span className="text-xs text-blue-500 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
)}
|
||||
{/* Results List */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{paginatedResults.map((result) => {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusDisplay = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
const statusInfo = getStatusInfo(statusDisplay);
|
||||
const description = getAttributeValue(result, 'Description');
|
||||
const isClickable = isApplicationComponent(result);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
onClick={() => isClickable && handleResultClick(result)}
|
||||
className={`
|
||||
px-4 lg:px-6 py-5 transition-all group
|
||||
${isClickable
|
||||
? 'cursor-pointer hover:bg-blue-50/50 hover:shadow-sm'
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar/Icon */}
|
||||
<div className="relative flex-shrink-0 w-12 h-12 bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl flex items-center justify-center overflow-hidden shadow-sm group-hover:shadow-md transition-shadow border border-blue-200/50">
|
||||
{(() => {
|
||||
const objectType = objectTypeMap.get(result.objectTypeId);
|
||||
const typeIcon = getObjectTypeIcon(objectType?.name);
|
||||
|
||||
// Show type-specific icon (more meaningful than generic placeholder)
|
||||
// If Jira provides an avatar, it will be shown via CSS background-image if needed
|
||||
return typeIcon;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Quick Links (only show when no search has been performed) */}
|
||||
{!hasSearched && (
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
|
||||
<a
|
||||
href="/app-components"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 lg:px-6 py-4 bg-gray-50/50 border-t border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-600 font-medium">
|
||||
Pagina <span className="text-gray-900">{pageForCurrentTab}</span> van <span className="text-gray-900">{totalPages}</span>
|
||||
<span className="text-gray-500 ml-2">({filteredResults.length} items)</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab - 1)}
|
||||
disabled={pageForCurrentTab === 1}
|
||||
className="px-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>
|
||||
<p className="font-medium text-gray-900">Application Components</p>
|
||||
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/reports/team-dashboard"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Rapporten</p>
|
||||
<p className="text-sm text-gray-500">Team-indeling & analyses</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Quick Links (only show when no search has been performed) */}
|
||||
{!hasSearched && (
|
||||
<div className="mt-12 lg:mt-16">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Snelle toegang</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<Link
|
||||
to="/app-components"
|
||||
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-blue-200 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-transform">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<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
|
||||
href="/app-components/fte-config"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Link
|
||||
to="/reports/team-dashboard"
|
||||
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-green-200 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-green-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-green-500/20 group-hover:scale-110 transition-transform">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
<p className="font-medium text-gray-900">Configuratie</p>
|
||||
<p className="text-sm text-gray-500">FTE berekening</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function TeamDashboard() {
|
||||
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
|
||||
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Hover handlers with delayed hide to prevent flickering when moving between badges
|
||||
const handleGovModelMouseEnter = useCallback((hoverKey: string) => {
|
||||
|
||||
@@ -40,25 +40,26 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
// Risk calculation: High BIA (F, E, D) + EOL/EOS/Deprecated = critical risk
|
||||
function calculateRiskLevel(status: string | null, bia: string | null): 'critical' | 'high' | 'medium' | 'low' {
|
||||
if (!status || !bia) return 'low';
|
||||
|
||||
const isHighBIA = ['F', 'E', 'D'].includes(bia);
|
||||
const isEOL = status === 'End of life';
|
||||
const isEOS = status === 'End of support';
|
||||
const isDeprecated = status === 'Deprecated';
|
||||
|
||||
if (isHighBIA && (isEOL || isEOS || isDeprecated)) {
|
||||
if (isEOL && ['F', 'E'].includes(bia)) return 'critical';
|
||||
if (isEOL || (isEOS && bia === 'F')) return 'critical';
|
||||
if (isHighBIA) return 'high';
|
||||
}
|
||||
|
||||
if (isEOL || isEOS) return 'high';
|
||||
if (isDeprecated) return 'medium';
|
||||
|
||||
return 'low';
|
||||
}
|
||||
// Unused function - kept for reference
|
||||
// function calculateRiskLevel(status: string | null, bia: string | null): 'critical' | 'high' | 'medium' | 'low' {
|
||||
// if (!status || !bia) return 'low';
|
||||
//
|
||||
// const isHighBIA = ['F', 'E', 'D'].includes(bia);
|
||||
// const isEOL = status === 'End of life';
|
||||
// const isEOS = status === 'End of support';
|
||||
// const isDeprecated = status === 'Deprecated';
|
||||
//
|
||||
// if (isHighBIA && (isEOL || isEOS || isDeprecated)) {
|
||||
// if (isEOL && ['F', 'E'].includes(bia)) return 'critical';
|
||||
// if (isEOL || (isEOS && bia === 'F')) return 'critical';
|
||||
// if (isHighBIA) return 'high';
|
||||
// }
|
||||
//
|
||||
// if (isEOL || isEOS) return 'high';
|
||||
// if (isDeprecated) return 'medium';
|
||||
//
|
||||
// return 'low';
|
||||
// }
|
||||
|
||||
function getRiskColor(riskLevel: string): string {
|
||||
switch (riskLevel) {
|
||||
|
||||
740
frontend/src/components/UserManagement.tsx
Normal file
740
frontend/src/components/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
frontend/src/components/UserSettings.tsx
Normal file
308
frontend/src/components/UserSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/hooks/usePermissions.ts
Normal file
55
frontend/src/hooks/usePermissions.ts
Normal 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));
|
||||
}
|
||||
@@ -8,6 +8,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@@ -112,4 +139,13 @@
|
||||
.badge-lighter-blue {
|
||||
@apply bg-blue-300 text-white;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -466,24 +466,24 @@ export interface EffortCalculationConfig {
|
||||
[key: string]: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
} | Array<{
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
default?: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
} | Array<{
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -491,7 +491,7 @@ export interface EffortCalculationConfig {
|
||||
default?: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -2,18 +2,28 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface User {
|
||||
accountId: string;
|
||||
id?: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
emailAddress?: string;
|
||||
avatarUrl?: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
interface AuthConfig {
|
||||
// Application branding
|
||||
appName: string;
|
||||
appTagline: string;
|
||||
appCopyright: string;
|
||||
// The configured authentication method
|
||||
authMethod: 'pat' | 'oauth' | 'none';
|
||||
authMethod: 'pat' | 'oauth' | 'local' | 'none';
|
||||
// Legacy fields (for backward compatibility)
|
||||
oauthEnabled: boolean;
|
||||
serviceAccountEnabled: boolean;
|
||||
localAuthEnabled: boolean;
|
||||
jiraHost: string;
|
||||
}
|
||||
|
||||
@@ -21,19 +31,24 @@ interface AuthState {
|
||||
// State
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
authMethod: 'oauth' | 'service-account' | null;
|
||||
authMethod: 'oauth' | 'local' | 'service-account' | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
config: AuthConfig | null;
|
||||
isInitialized: boolean; // Track if initialization has completed
|
||||
|
||||
// Actions
|
||||
setUser: (user: User | null, method: 'oauth' | 'service-account' | null) => void;
|
||||
setUser: (user: User | null, method: 'oauth' | 'local' | 'service-account' | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setConfig: (config: AuthConfig) => void;
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
fetchConfig: () => Promise<void>;
|
||||
localLogin: (email: string, password: string) => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
hasRole: (role: string) => boolean;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
@@ -48,6 +63,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: true,
|
||||
error: null,
|
||||
config: null,
|
||||
isInitialized: false,
|
||||
|
||||
// Actions
|
||||
setUser: (user, method) => set({
|
||||
@@ -63,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
setConfig: (config) => set({ config }),
|
||||
|
||||
setInitialized: (initialized) => set({ isInitialized: initialized }),
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/auth/logout`, {
|
||||
@@ -81,19 +99,49 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
// Use a simple flag to prevent concurrent calls
|
||||
const currentState = get();
|
||||
if (currentState.isLoading) {
|
||||
// Wait for the existing call to complete (max 1 second)
|
||||
let waitCount = 0;
|
||||
while (get().isLoading && waitCount < 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
waitCount++;
|
||||
}
|
||||
const stateAfterWait = get();
|
||||
// If previous call completed and we have auth state, we're done
|
||||
if (!stateAfterWait.isLoading && (stateAfterWait.isAuthenticated || stateAfterWait.user)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Auth check failed');
|
||||
// Handle rate limiting (429) gracefully
|
||||
if (response.status === 429) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
authMethod: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`Auth check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
if (data.authenticated && data.user) {
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
@@ -110,7 +158,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
console.error('[checkAuth] Auth check error:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -122,18 +170,98 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
fetchConfig: async () => {
|
||||
// Check if config is already loaded to prevent duplicate calls
|
||||
const currentState = get();
|
||||
if (currentState.config) {
|
||||
return; // Config already loaded, skip API call
|
||||
}
|
||||
|
||||
const defaultConfig = {
|
||||
appName: 'CMDB Insight',
|
||||
appTagline: 'Management console for Jira Assets',
|
||||
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
||||
authMethod: 'local' as const,
|
||||
oauthEnabled: false,
|
||||
serviceAccountEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
jiraHost: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/config`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
set({ config });
|
||||
const configData = await response.json();
|
||||
set({ config: configData });
|
||||
} else {
|
||||
// Any non-OK response - set default config
|
||||
set({ config: defaultConfig });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auth config:', error);
|
||||
console.error('[fetchConfig] Failed to fetch auth config:', error);
|
||||
// Set default config to allow app to proceed
|
||||
set({ config: defaultConfig });
|
||||
}
|
||||
|
||||
// Final verification - ensure config is set
|
||||
const finalState = get();
|
||||
if (!finalState.config) {
|
||||
set({ config: defaultConfig });
|
||||
}
|
||||
},
|
||||
|
||||
localLogin: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Local login error:', error);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Login failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (permission: string) => {
|
||||
const { user } = get();
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
return user.permissions.includes(permission);
|
||||
},
|
||||
|
||||
hasRole: (role: string) => {
|
||||
const { user } = get();
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
return user.roles.includes(role);
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -150,4 +278,3 @@ export const useAuthStore = create<AuthState>()(
|
||||
export function getLoginUrl(): string {
|
||||
return `${API_BASE}/api/auth/login`;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,12 @@ export interface ApplicationDetails {
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
dataCompletenessPercentage?: number; // Data completeness percentage (0-100)
|
||||
reference?: string | null; // Reference field (Enterprise Architect GUID)
|
||||
confluenceSpace?: string | null; // Confluence Space URL
|
||||
supplierTechnical?: ReferenceValue | null; // Supplier Technical
|
||||
supplierImplementation?: ReferenceValue | null; // Supplier Implementation
|
||||
supplierConsultancy?: ReferenceValue | null; // Supplier Consultancy
|
||||
_jiraUpdatedAt?: string | null; // Internal field for conflict detection (not exposed in API)
|
||||
}
|
||||
|
||||
// Search filters
|
||||
|
||||
5
frontend/src/types/node.d.ts
vendored
Normal file
5
frontend/src/types/node.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// Type definitions for Node.js types used in frontend
|
||||
// In browser context, setTimeout returns number, not NodeJS.Timeout
|
||||
declare namespace NodeJS {
|
||||
interface Timeout extends ReturnType<typeof setTimeout> {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user