From ea1c84262c0a4231f407ad01fc398bb5bd407bb6 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Tue, 6 Jan 2026 15:40:52 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 52 ++++- backend/package.json | 2 + backend/src/config/env.ts | 22 +++ backend/src/index.ts | 27 ++- backend/src/routes/auth.ts | 190 +++++++++++++++++++ backend/src/services/authService.ts | 283 ++++++++++++++++++++++++++++ backend/src/services/jiraAssets.ts | 26 ++- frontend/src/App.tsx | 120 +++++++++++- frontend/src/components/Login.tsx | 123 ++++++++++++ frontend/src/stores/authStore.ts | 150 +++++++++++++++ package-lock.json | 31 +++ 11 files changed, 1016 insertions(+), 10 deletions(-) create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/services/authService.ts create mode 100644 frontend/src/components/Login.tsx create mode 100644 frontend/src/stores/authStore.ts diff --git a/CLAUDE.md b/CLAUDE.md index aa8bef2..a6cc70f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,9 +134,19 @@ Dutch hospital reference architecture with 90+ application functions organized i ```env # Jira Data Center JIRA_HOST=https://jira.zuyderland.nl -JIRA_PAT= +JIRA_PAT= # Service account PAT (fallback when OAuth disabled) JIRA_SCHEMA_ID= +# Jira OAuth 2.0 (optional - enables user authentication) +JIRA_OAUTH_ENABLED=false # Set to 'true' to enable OAuth +JIRA_OAUTH_CLIENT_ID= # From Jira Application Link +JIRA_OAUTH_CLIENT_SECRET= # From Jira Application Link +JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback +JIRA_OAUTH_SCOPES=READ WRITE + +# Session Configuration +SESSION_SECRET= # Change in production! + # Jira Object Type IDs JIRA_APPLICATION_COMPONENT_TYPE_ID= JIRA_APPLICATION_FUNCTION_TYPE_ID= @@ -156,14 +166,52 @@ JIRA_ATTR_GOVERNANCE_MODEL= JIRA_ATTR_APPLICATION_CLUSTER= JIRA_ATTR_APPLICATION_TYPE= -# Claude AI +# AI Classification ANTHROPIC_API_KEY= +OPENAI_API_KEY= # Optional: alternative to Claude +DEFAULT_AI_PROVIDER=claude # 'claude' or 'openai' # Server PORT=3001 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 1. **Never commit PAT tokens** - Always use .env files (add to .gitignore) diff --git a/backend/package.json b/backend/package.json index 8b39967..e1d6e6f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.32.1", "better-sqlite3": "^11.6.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.9.0", diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 62e0dbc..549b473 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -10,6 +10,16 @@ interface Config { jiraPat: 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 jiraApplicationComponentTypeId: string; jiraApplicationFunctionTypeId: string; @@ -56,6 +66,7 @@ interface Config { nodeEnv: string; isDevelopment: boolean; isProduction: boolean; + frontendUrl: string; // API Configuration jiraApiBatchSize: number; @@ -79,6 +90,16 @@ export const config: Config = { jiraPat: getOptionalEnvVar('JIRA_PAT'), 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 jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'), jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'), @@ -125,6 +146,7 @@ export const config: Config = { nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'), isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development', isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production', + frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'), // API Configuration jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10), diff --git a/backend/src/index.ts b/backend/src/index.ts index 3c327b2..77dc368 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; +import cookieParser from 'cookie-parser'; import { config, validateConfig } from './config/env.js'; import { logger } from './services/logger.js'; import { dataService } from './services/dataService.js'; @@ -10,6 +11,8 @@ import classificationsRouter from './routes/classifications.js'; 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 { jiraAssetsService } from './services/jiraAssets.js'; // Validate configuration validateConfig(); @@ -19,10 +22,13 @@ const app = express(); // Security middleware app.use(helmet()); 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, })); +// Cookie parser for session handling +app.use(cookieParser()); + // Rate limiting const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -41,6 +47,24 @@ app.use((req, res, 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 app.get('/health', async (req, res) => { const jiraConnected = await dataService.testConnection(); @@ -61,6 +85,7 @@ app.get('/api/config', (req, res) => { }); // API routes +app.use('/api/auth', authRouter); app.use('/api/applications', applicationsRouter); app.use('/api/classifications', classificationsRouter); app.use('/api/reference-data', referenceDataRouter); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..4722b1a --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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; + diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts new file mode 100644 index 0000000..fa8facb --- /dev/null +++ b/backend/src/services/authService.ts @@ -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(); + +// 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(); + +// 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 { + 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 { + 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(); + diff --git a/backend/src/services/jiraAssets.ts b/backend/src/services/jiraAssets.ts index 425e694..2a6792d 100644 --- a/backend/src/services/jiraAssets.ts +++ b/backend/src/services/jiraAssets.ts @@ -75,8 +75,10 @@ interface ObjectTypeAttributeDefinition { class JiraAssetsService { private insightBaseUrl: string; private assetsBaseUrl: string; - private headers: Record; + private defaultHeaders: Record; private isDataCenter: boolean | null = null; + // Request-scoped user token (set per request via middleware) + private requestToken: string | null = null; // Cache: objectTypeName -> Map private attributeSchemaCache: Map> = new Map(); // 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) this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`; this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`; - this.headers = { + this.defaultHeaders = { Authorization: `Bearer ${config.jiraPat}`, 'Content-Type': '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 { + const token = this.requestToken || config.jiraPat; + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + } + private getBaseUrl(): string { // Use detected API type or default to Insight (Data Center) return this.isDataCenter === false ? this.assetsBaseUrl : this.insightBaseUrl; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ed6947..bdd945c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,87 @@ +import { useEffect, useState } from 'react'; import { Routes, Route, Link, useLocation } from 'react-router-dom'; import { clsx } from 'clsx'; import Dashboard from './components/Dashboard'; import ApplicationList from './components/ApplicationList'; import ApplicationDetail from './components/ApplicationDetail'; import TeamDashboard from './components/TeamDashboard'; -import Configuration from './components/Configuration'; 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 ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+

{user.displayName}

+ {user.emailAddress && ( +

{user.emailAddress}

+ )} +

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

+
+
+ {authMethod === 'oauth' && ( + + )} +
+
+ + )} +
+ ); +} + +function AppContent() { const location = useLocation(); const navItems = [ @@ -60,9 +134,7 @@ function App() {
-
- ICMT -
+ @@ -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 ( +
+
+
+

Laden...

+
+
+ ); + } + + // Show login if OAuth is enabled and not authenticated + if (config?.oauthEnabled && !isAuthenticated) { + return ; + } + + // Show login if nothing is configured + if (!config?.oauthEnabled && !config?.serviceAccountEnabled) { + return ; + } + + // Show main app + return ; +} + export default App; diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx new file mode 100644 index 0000000..64bf537 --- /dev/null +++ b/frontend/src/components/Login.tsx @@ -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 ( +
+
+
+

Laden...

+
+
+ ); + } + + return ( +
+
+ {/* Logo / Header */} +
+
+ + + +
+

CMDB Editor

+

ZiRA Classificatie Tool

+
+ + {/* Login Card */} +
+

Inloggen

+ + {error && ( +
+

{error}

+
+ )} + + {config?.oauthEnabled ? ( + <> + + +

+ Je wordt doorgestuurd naar Jira om in te loggen +

+ + ) : config?.serviceAccountEnabled ? ( +
+
+ + + +
+

Service Account Modus

+

+ De applicatie gebruikt een geconfigureerd service account voor Jira toegang. +

+ +
+ ) : ( +
+
+ + + +
+

Niet geconfigureerd

+

+ Neem contact op met de beheerder om OAuth of een service account te configureren. +

+
+ )} +
+ + {/* Footer */} +

+ Zuyderland Medisch Centrum • CMDB Editor v1.0 +

+
+
+ ); +} + diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..b59efb4 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -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; + checkAuth: () => Promise; + fetchConfig: () => Promise; +} + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +export const useAuthStore = create()( + 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`; +} + diff --git a/package-lock.json b/package-lock.json index d012c62..9edbd9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.32.1", "better-sqlite3": "^11.6.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", @@ -32,6 +33,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.9.0", @@ -1356,6 +1358,16 @@ "@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": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2211,6 +2223,25 @@ "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": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",