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:
@@ -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;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user