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:
2026-01-06 15:40:52 +01:00
parent 0b27adc2fb
commit ea1c84262c
11 changed files with 1016 additions and 10 deletions

View File

@@ -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 (
<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 navItems = [
@@ -60,9 +134,7 @@ function App() {
</nav>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-500">ICMT</span>
</div>
<UserMenu />
</div>
</div>
</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;

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

View 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`;
}