Add OAuth 2.0 authentication support for Jira Data Center
- Add OAuth 2.0 configuration options in backend env.ts - Create authService.ts for OAuth flow, token management, and sessions - Create auth.ts routes for login, callback, logout, and user info - Update JiraAssets service to use user tokens when OAuth is enabled - Add cookie-parser for session handling - Create Login.tsx component with Jira OAuth login button - Add authStore.ts (Zustand) for frontend auth state management - Update App.tsx to show login page when OAuth is enabled - Add user menu with logout functionality - Document OAuth setup in CLAUDE.md Supports two modes: 1. Service Account: Uses JIRA_PAT for all requests (default) 2. OAuth 2.0: Each user authenticates with their Jira credentials
This commit is contained in:
52
CLAUDE.md
52
CLAUDE.md
@@ -134,9 +134,19 @@ Dutch hospital reference architecture with 90+ application functions organized i
|
|||||||
```env
|
```env
|
||||||
# Jira Data Center
|
# Jira Data Center
|
||||||
JIRA_HOST=https://jira.zuyderland.nl
|
JIRA_HOST=https://jira.zuyderland.nl
|
||||||
JIRA_PAT=<personal_access_token>
|
JIRA_PAT=<personal_access_token> # Service account PAT (fallback when OAuth disabled)
|
||||||
JIRA_SCHEMA_ID=<schema_id>
|
JIRA_SCHEMA_ID=<schema_id>
|
||||||
|
|
||||||
|
# Jira OAuth 2.0 (optional - enables user authentication)
|
||||||
|
JIRA_OAUTH_ENABLED=false # Set to 'true' to enable OAuth
|
||||||
|
JIRA_OAUTH_CLIENT_ID=<oauth_client_id> # From Jira Application Link
|
||||||
|
JIRA_OAUTH_CLIENT_SECRET=<oauth_secret> # From Jira Application Link
|
||||||
|
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||||
|
JIRA_OAUTH_SCOPES=READ WRITE
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_SECRET=<random_secret_string> # Change in production!
|
||||||
|
|
||||||
# Jira Object Type IDs
|
# Jira Object Type IDs
|
||||||
JIRA_APPLICATION_COMPONENT_TYPE_ID=<type_id>
|
JIRA_APPLICATION_COMPONENT_TYPE_ID=<type_id>
|
||||||
JIRA_APPLICATION_FUNCTION_TYPE_ID=<type_id>
|
JIRA_APPLICATION_FUNCTION_TYPE_ID=<type_id>
|
||||||
@@ -156,14 +166,52 @@ JIRA_ATTR_GOVERNANCE_MODEL=<attr_id>
|
|||||||
JIRA_ATTR_APPLICATION_CLUSTER=<attr_id>
|
JIRA_ATTR_APPLICATION_CLUSTER=<attr_id>
|
||||||
JIRA_ATTR_APPLICATION_TYPE=<attr_id>
|
JIRA_ATTR_APPLICATION_TYPE=<attr_id>
|
||||||
|
|
||||||
# Claude AI
|
# AI Classification
|
||||||
ANTHROPIC_API_KEY=<claude_api_key>
|
ANTHROPIC_API_KEY=<claude_api_key>
|
||||||
|
OPENAI_API_KEY=<openai_api_key> # Optional: alternative to Claude
|
||||||
|
DEFAULT_AI_PROVIDER=claude # 'claude' or 'openai'
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3001
|
PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The application supports two authentication modes:
|
||||||
|
|
||||||
|
### 1. Service Account Mode (Default)
|
||||||
|
- Uses a single PAT (`JIRA_PAT`) for all Jira API calls
|
||||||
|
- Users don't need to log in
|
||||||
|
- All changes are attributed to the service account
|
||||||
|
|
||||||
|
### 2. OAuth 2.0 Mode
|
||||||
|
- Each user logs in with their own Jira credentials
|
||||||
|
- API calls are made under the user's account
|
||||||
|
- Better audit trail and access control
|
||||||
|
|
||||||
|
### Setting up OAuth 2.0 (Jira Data Center 8.14+)
|
||||||
|
|
||||||
|
1. **Create Application Link in Jira:**
|
||||||
|
- Go to Jira Admin → Application Links
|
||||||
|
- Create a new "Incoming Link"
|
||||||
|
- Set Redirect URL: `http://localhost:3001/api/auth/callback`
|
||||||
|
- Note the Client ID and Secret
|
||||||
|
|
||||||
|
2. **Configure Environment:**
|
||||||
|
```env
|
||||||
|
JIRA_OAUTH_ENABLED=true
|
||||||
|
JIRA_OAUTH_CLIENT_ID=your_client_id
|
||||||
|
JIRA_OAUTH_CLIENT_SECRET=your_client_secret
|
||||||
|
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **For Production:**
|
||||||
|
- Update callback URL to production domain
|
||||||
|
- Set `SESSION_SECRET` to a random string
|
||||||
|
- Use HTTPS
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
1. **Never commit PAT tokens** - Always use .env files (add to .gitignore)
|
1. **Never commit PAT tokens** - Always use .env files (add to .gitignore)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.32.1",
|
"@anthropic-ai/sdk": "^0.32.1",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ interface Config {
|
|||||||
jiraPat: string;
|
jiraPat: string;
|
||||||
jiraSchemaId: string;
|
jiraSchemaId: string;
|
||||||
|
|
||||||
|
// Jira OAuth 2.0 Configuration
|
||||||
|
jiraOAuthEnabled: boolean;
|
||||||
|
jiraOAuthClientId: string;
|
||||||
|
jiraOAuthClientSecret: string;
|
||||||
|
jiraOAuthCallbackUrl: string;
|
||||||
|
jiraOAuthScopes: string;
|
||||||
|
|
||||||
|
// Session Configuration
|
||||||
|
sessionSecret: string;
|
||||||
|
|
||||||
// Object Type IDs
|
// Object Type IDs
|
||||||
jiraApplicationComponentTypeId: string;
|
jiraApplicationComponentTypeId: string;
|
||||||
jiraApplicationFunctionTypeId: string;
|
jiraApplicationFunctionTypeId: string;
|
||||||
@@ -56,6 +66,7 @@ interface Config {
|
|||||||
nodeEnv: string;
|
nodeEnv: string;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
isProduction: boolean;
|
isProduction: boolean;
|
||||||
|
frontendUrl: string;
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
jiraApiBatchSize: number;
|
jiraApiBatchSize: number;
|
||||||
@@ -79,6 +90,16 @@ export const config: Config = {
|
|||||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||||
|
|
||||||
|
// Jira OAuth 2.0 Configuration
|
||||||
|
jiraOAuthEnabled: getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true',
|
||||||
|
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
|
||||||
|
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
||||||
|
jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'),
|
||||||
|
jiraOAuthScopes: getOptionalEnvVar('JIRA_OAUTH_SCOPES', 'READ WRITE'),
|
||||||
|
|
||||||
|
// Session Configuration
|
||||||
|
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
|
||||||
|
|
||||||
// Object Type IDs
|
// Object Type IDs
|
||||||
jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'),
|
jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'),
|
||||||
jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'),
|
jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'),
|
||||||
@@ -125,6 +146,7 @@ export const config: Config = {
|
|||||||
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
|
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
|
||||||
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
|
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
|
||||||
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
|
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
|
||||||
|
frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'),
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10),
|
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { config, validateConfig } from './config/env.js';
|
import { config, validateConfig } from './config/env.js';
|
||||||
import { logger } from './services/logger.js';
|
import { logger } from './services/logger.js';
|
||||||
import { dataService } from './services/dataService.js';
|
import { dataService } from './services/dataService.js';
|
||||||
@@ -10,6 +11,8 @@ import classificationsRouter from './routes/classifications.js';
|
|||||||
import referenceDataRouter from './routes/referenceData.js';
|
import referenceDataRouter from './routes/referenceData.js';
|
||||||
import dashboardRouter from './routes/dashboard.js';
|
import dashboardRouter from './routes/dashboard.js';
|
||||||
import configurationRouter from './routes/configuration.js';
|
import configurationRouter from './routes/configuration.js';
|
||||||
|
import authRouter, { authMiddleware } from './routes/auth.js';
|
||||||
|
import { jiraAssetsService } from './services/jiraAssets.js';
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
validateConfig();
|
validateConfig();
|
||||||
@@ -19,10 +22,13 @@ const app = express();
|
|||||||
// Security middleware
|
// Security middleware
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: config.isDevelopment ? '*' : ['http://localhost:5173', 'http://localhost:3000'],
|
origin: config.isDevelopment ? ['http://localhost:5173', 'http://localhost:3000'] : [config.frontendUrl],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Cookie parser for session handling
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -41,6 +47,24 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auth middleware - extract session info for all requests
|
||||||
|
app.use(authMiddleware);
|
||||||
|
|
||||||
|
// Set user token on JiraAssets service for each request
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
// Set user's OAuth token if available
|
||||||
|
if (req.accessToken) {
|
||||||
|
jiraAssetsService.setRequestToken(req.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear token after response is sent
|
||||||
|
res.on('finish', () => {
|
||||||
|
jiraAssetsService.clearRequestToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const jiraConnected = await dataService.testConnection();
|
const jiraConnected = await dataService.testConnection();
|
||||||
@@ -61,6 +85,7 @@ app.get('/api/config', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
app.use('/api/applications', applicationsRouter);
|
app.use('/api/applications', applicationsRouter);
|
||||||
app.use('/api/classifications', classificationsRouter);
|
app.use('/api/classifications', classificationsRouter);
|
||||||
app.use('/api/reference-data', referenceDataRouter);
|
app.use('/api/reference-data', referenceDataRouter);
|
||||||
|
|||||||
190
backend/src/routes/auth.ts
Normal file
190
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { authService, JiraUser } from '../services/authService.js';
|
||||||
|
import { config } from '../config/env.js';
|
||||||
|
import { logger } from '../services/logger.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Extend Express Request to include user info
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
sessionId?: string;
|
||||||
|
user?: JiraUser;
|
||||||
|
accessToken?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth configuration
|
||||||
|
router.get('/config', (req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
oauthEnabled: authService.isOAuthEnabled(),
|
||||||
|
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
||||||
|
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;
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json({ authenticated: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = authService.getUser(sessionId);
|
||||||
|
if (!user) {
|
||||||
|
return res.json({ authenticated: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initiate OAuth login
|
||||||
|
router.get('/login', (req: Request, res: Response) => {
|
||||||
|
if (!authService.isOAuthEnabled()) {
|
||||||
|
return res.status(400).json({ error: 'OAuth is not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url, state } = authService.getAuthorizationUrl();
|
||||||
|
|
||||||
|
// Store state in cookie for verification (httpOnly for security)
|
||||||
|
res.cookie('oauth_state', state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: config.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 10 * 60 * 1000, // 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Redirecting to Jira OAuth...');
|
||||||
|
res.redirect(url);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initiate OAuth login:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to initiate login' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth callback
|
||||||
|
router.get('/callback', async (req: Request, res: Response) => {
|
||||||
|
const { code, state, error, error_description } = req.query;
|
||||||
|
|
||||||
|
// Handle OAuth errors
|
||||||
|
if (error) {
|
||||||
|
logger.error(`OAuth error: ${error} - ${error_description}`);
|
||||||
|
return res.redirect(`${config.frontendUrl}/login?error=${encodeURIComponent(String(error_description || error))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!code || !state) {
|
||||||
|
return res.redirect(`${config.frontendUrl}/login?error=${encodeURIComponent('Missing authorization code or state')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exchange code for tokens
|
||||||
|
const { sessionId, user } = await authService.exchangeCodeForTokens(
|
||||||
|
String(code),
|
||||||
|
String(state)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`OAuth login successful for: ${user.displayName}`);
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
res.cookie('sessionId', sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: config.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to frontend with session info
|
||||||
|
res.redirect(`${config.frontendUrl}?login=success`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OAuth callback error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
|
||||||
|
res.redirect(`${config.frontendUrl}/login?error=${encodeURIComponent(errorMessage)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
router.post('/logout', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
authService.logout(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cookies
|
||||||
|
res.clearCookie('sessionId');
|
||||||
|
res.clearCookie('oauth_state');
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
router.post('/refresh', async (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await authService.refreshAccessToken(sessionId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'Failed to refresh token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware to extract session and attach user to request
|
||||||
|
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const session = authService.getSession(sessionId);
|
||||||
|
if (session) {
|
||||||
|
req.sessionId = sessionId;
|
||||||
|
req.user = session.user;
|
||||||
|
req.accessToken = session.accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
283
backend/src/services/authService.ts
Normal file
283
backend/src/services/authService.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { config } from '../config/env.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { randomBytes, createHash } from 'crypto';
|
||||||
|
|
||||||
|
// Token storage (in production, use Redis or similar)
|
||||||
|
interface UserSession {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt: number;
|
||||||
|
user: JiraUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraUser {
|
||||||
|
accountId: string;
|
||||||
|
displayName: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory session store (replace with Redis in production)
|
||||||
|
const sessionStore = new Map<string, UserSession>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// PKCE helpers for OAuth 2.0
|
||||||
|
export function generateCodeVerifier(): string {
|
||||||
|
return randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeChallenge(verifier: string): string {
|
||||||
|
return createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate state parameter to prevent CSRF
|
||||||
|
export function generateState(): string {
|
||||||
|
return randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for OAuth state and PKCE verifiers (temporary, during auth flow)
|
||||||
|
const authFlowStore = new Map<string, { codeVerifier: string; createdAt: number }>();
|
||||||
|
|
||||||
|
// Clean up old auth flow entries
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 10 * 60 * 1000; // 10 minutes
|
||||||
|
for (const [state, flow] of authFlowStore.entries()) {
|
||||||
|
if (now - flow.createdAt > maxAge) {
|
||||||
|
authFlowStore.delete(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
// Get OAuth authorization URL
|
||||||
|
getAuthorizationUrl(): { url: string; state: string } {
|
||||||
|
const state = generateState();
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
// Store code verifier for later use in token exchange
|
||||||
|
authFlowStore.set(state, { codeVerifier, createdAt: Date.now() });
|
||||||
|
|
||||||
|
// Build authorization URL for Jira Data Center OAuth 2.0
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: config.jiraOAuthClientId,
|
||||||
|
redirect_uri: config.jiraOAuthCallbackUrl,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: config.jiraOAuthScopes,
|
||||||
|
state: state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
const authUrl = `${config.jiraHost}/rest/oauth2/latest/authorize?${params.toString()}`;
|
||||||
|
|
||||||
|
logger.info(`Generated OAuth authorization URL with state: ${state}`);
|
||||||
|
return { url: authUrl, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
async exchangeCodeForTokens(code: string, state: string): Promise<{ sessionId: string; user: JiraUser }> {
|
||||||
|
// Retrieve and validate state
|
||||||
|
const flowData = authFlowStore.get(state);
|
||||||
|
if (!flowData) {
|
||||||
|
throw new Error('Invalid or expired state parameter');
|
||||||
|
}
|
||||||
|
authFlowStore.delete(state);
|
||||||
|
|
||||||
|
const tokenUrl = `${config.jiraHost}/rest/oauth2/latest/token`;
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: config.jiraOAuthClientId,
|
||||||
|
client_secret: config.jiraOAuthClientSecret,
|
||||||
|
code: code,
|
||||||
|
redirect_uri: config.jiraOAuthCallbackUrl,
|
||||||
|
code_verifier: flowData.codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Exchanging authorization code for tokens...');
|
||||||
|
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error(`Token exchange failed: ${response.status} ${errorText}`);
|
||||||
|
throw new Error(`Token exchange failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await response.json() as {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const user = await this.fetchUserInfo(tokenData.access_token);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionId = randomBytes(32).toString('hex');
|
||||||
|
const session: UserSession = {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
refreshToken: tokenData.refresh_token,
|
||||||
|
expiresAt: Date.now() + (tokenData.expires_in * 1000),
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionStore.set(sessionId, session);
|
||||||
|
logger.info(`Created session for user: ${user.displayName}`);
|
||||||
|
|
||||||
|
return { sessionId, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current user info from Jira
|
||||||
|
async fetchUserInfo(accessToken: string): Promise<JiraUser> {
|
||||||
|
const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch user info: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json() as {
|
||||||
|
accountId?: string;
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
displayName: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
avatarUrls?: { '48x48'?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: userData.accountId || userData.key || userData.name || 'unknown',
|
||||||
|
displayName: userData.displayName,
|
||||||
|
emailAddress: userData.emailAddress,
|
||||||
|
avatarUrl: userData.avatarUrls?.['48x48'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session by ID
|
||||||
|
getSession(sessionId: string): UserSession | null {
|
||||||
|
const session = sessionStore.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (session.expiresAt < Date.now()) {
|
||||||
|
sessionStore.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token for a session
|
||||||
|
getAccessToken(sessionId: string): string | null {
|
||||||
|
const session = this.getSession(sessionId);
|
||||||
|
return session?.accessToken || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user for a session
|
||||||
|
getUser(sessionId: string): JiraUser | null {
|
||||||
|
const session = this.getSession(sessionId);
|
||||||
|
return session?.user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh access token
|
||||||
|
async refreshAccessToken(sessionId: string): Promise<boolean> {
|
||||||
|
const session = sessionStore.get(sessionId);
|
||||||
|
if (!session?.refreshToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenUrl = `${config.jiraHost}/rest/oauth2/latest/token`;
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: config.jiraOAuthClientId,
|
||||||
|
client_secret: config.jiraOAuthClientSecret,
|
||||||
|
refresh_token: session.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(`Token refresh failed: ${response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await response.json() as {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
session.accessToken = tokenData.access_token;
|
||||||
|
if (tokenData.refresh_token) {
|
||||||
|
session.refreshToken = tokenData.refresh_token;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)}...`);
|
||||||
|
}
|
||||||
|
return existed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if OAuth is enabled
|
||||||
|
isOAuthEnabled(): boolean {
|
||||||
|
return config.jiraOAuthEnabled && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if using service account (PAT) fallback
|
||||||
|
isUsingServiceAccount(): boolean {
|
||||||
|
return !this.isOAuthEnabled() && !!config.jiraPat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
|
|
||||||
@@ -75,8 +75,10 @@ interface ObjectTypeAttributeDefinition {
|
|||||||
class JiraAssetsService {
|
class JiraAssetsService {
|
||||||
private insightBaseUrl: string;
|
private insightBaseUrl: string;
|
||||||
private assetsBaseUrl: string;
|
private assetsBaseUrl: string;
|
||||||
private headers: Record<string, string>;
|
private defaultHeaders: Record<string, string>;
|
||||||
private isDataCenter: boolean | null = null;
|
private isDataCenter: boolean | null = null;
|
||||||
|
// Request-scoped user token (set per request via middleware)
|
||||||
|
private requestToken: string | null = null;
|
||||||
// Cache: objectTypeName -> Map<attributeId, attributeName>
|
// Cache: objectTypeName -> Map<attributeId, attributeName>
|
||||||
private attributeSchemaCache: Map<string, Map<number, string>> = new Map();
|
private attributeSchemaCache: Map<string, Map<number, string>> = new Map();
|
||||||
// Cache: Application functions with full details (applicationFunctionCategory, description, keywords)
|
// Cache: Application functions with full details (applicationFunctionCategory, description, keywords)
|
||||||
@@ -109,13 +111,33 @@ class JiraAssetsService {
|
|||||||
// Try both API paths - Insight (Data Center) and Assets (Cloud)
|
// Try both API paths - Insight (Data Center) and Assets (Cloud)
|
||||||
this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||||
this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`;
|
this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`;
|
||||||
this.headers = {
|
this.defaultHeaders = {
|
||||||
Authorization: `Bearer ${config.jiraPat}`,
|
Authorization: `Bearer ${config.jiraPat}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set user token for the current request (call this from middleware)
|
||||||
|
setRequestToken(token: string | null): void {
|
||||||
|
this.requestToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear request token (call after request completes)
|
||||||
|
clearRequestToken(): void {
|
||||||
|
this.requestToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get headers with the appropriate token (user token takes precedence)
|
||||||
|
private get headers(): Record<string, string> {
|
||||||
|
const token = this.requestToken || config.jiraPat;
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private getBaseUrl(): string {
|
private getBaseUrl(): string {
|
||||||
// Use detected API type or default to Insight (Data Center)
|
// Use detected API type or default to Insight (Data Center)
|
||||||
return this.isDataCenter === false ? this.assetsBaseUrl : this.insightBaseUrl;
|
return this.isDataCenter === false ? this.assetsBaseUrl : this.insightBaseUrl;
|
||||||
|
|||||||
@@ -1,13 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import ApplicationList from './components/ApplicationList';
|
import ApplicationList from './components/ApplicationList';
|
||||||
import ApplicationDetail from './components/ApplicationDetail';
|
import ApplicationDetail from './components/ApplicationDetail';
|
||||||
import TeamDashboard from './components/TeamDashboard';
|
import TeamDashboard from './components/TeamDashboard';
|
||||||
import Configuration from './components/Configuration';
|
|
||||||
import ConfigurationV25 from './components/ConfigurationV25';
|
import ConfigurationV25 from './components/ConfigurationV25';
|
||||||
|
import Login from './components/Login';
|
||||||
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
|
||||||
function App() {
|
function UserMenu() {
|
||||||
|
const { user, authMethod, logout } = useAuthStore();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const initials = user.displayName
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-medium">{initials}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-700 hidden sm:block">{user.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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
<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-xs text-gray-400 mt-1">
|
||||||
|
{authMethod === 'oauth' ? 'Jira OAuth' : '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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -60,9 +134,7 @@ function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<UserMenu />
|
||||||
<span className="text-sm text-gray-500">ICMT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -81,4 +153,42 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated, isLoading, checkAuth, fetchConfig, config } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch auth config first, then check auth status
|
||||||
|
const init = async () => {
|
||||||
|
await fetchConfig();
|
||||||
|
await checkAuth();
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, [fetchConfig, checkAuth]);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login if OAuth is enabled and not authenticated
|
||||||
|
if (config?.oauthEnabled && !isAuthenticated) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login if nothing is configured
|
||||||
|
if (!config?.oauthEnabled && !config?.serviceAccountEnabled) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show main app
|
||||||
|
return <AppContent />;
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
123
frontend/src/components/Login.tsx
Normal file
123
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuthStore, getLoginUrl } from '../stores/authStore';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { config, error, isLoading, fetchConfig, checkAuth } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
|
||||||
|
// Check for login success/error in URL params
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const loginSuccess = params.get('login');
|
||||||
|
const loginError = params.get('error');
|
||||||
|
|
||||||
|
if (loginSuccess === 'success') {
|
||||||
|
// Remove query params and check auth
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginError) {
|
||||||
|
useAuthStore.getState().setError(decodeURIComponent(loginError));
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
}, [fetchConfig, checkAuth]);
|
||||||
|
|
||||||
|
const handleJiraLogin = () => {
|
||||||
|
window.location.href = getLoginUrl();
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config?.oauthEnabled ? (
|
||||||
|
<>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Inloggen met Jira
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-slate-500 text-sm">
|
||||||
|
Je wordt doorgestuurd naar Jira om in te loggen
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : config?.serviceAccountEnabled ? (
|
||||||
|
<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">Service Account Modus</p>
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
De applicatie gebruikt een geconfigureerd service account voor Jira toegang.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Doorgaan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-500/20 rounded-full mb-4">
|
||||||
|
<svg className="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</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 service account 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
150
frontend/src/stores/authStore.ts
Normal file
150
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
accountId: string;
|
||||||
|
displayName: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfig {
|
||||||
|
oauthEnabled: boolean;
|
||||||
|
serviceAccountEnabled: boolean;
|
||||||
|
jiraHost: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
// State
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
authMethod: 'oauth' | 'service-account' | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
config: AuthConfig | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setUser: (user: User | null, method: 'oauth' | 'service-account' | null) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
setConfig: (config: AuthConfig) => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
fetchConfig: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
config: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setUser: (user, method) => set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
authMethod: method,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setError: (error) => set({ error, isLoading: false }),
|
||||||
|
|
||||||
|
setConfig: (config) => set({ config }),
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/api/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/auth/me`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Auth check failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
set({
|
||||||
|
user: data.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
authMethod: data.authMethod,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check error:', error);
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Authentication check failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchConfig: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/auth/config`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json();
|
||||||
|
set({ config });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch auth config:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Only persist non-sensitive data
|
||||||
|
authMethod: state.authMethod,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to get login URL
|
||||||
|
export function getLoginUrl(): string {
|
||||||
|
return `${API_BASE}/api/auth/login`;
|
||||||
|
}
|
||||||
|
|
||||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.32.1",
|
"@anthropic-ai/sdk": "^0.32.1",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
@@ -1356,6 +1358,16 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie-parser": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cors": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -2211,6 +2223,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user