Add OpenID Connect + API Key authentication
Backend: - Cookie + OIDC + API Key authentication schemes - ApiKeyAuthenticationHandler with SHA-256 validation and 24h cache - AuthController with login/logout/profile endpoints - API Key domain model (EventFlow aggregate, events, commands) - ApiKeyReadModel and repository for key validation - Database migration 002_ApiKeys.sql - CORS configuration for frontend Frontend: - authService.ts for login/logout/profile API calls - authStore.ts (Zustand) for user context state - ProtectedRoute component for route guards - Header updated with user display and logout - GraphQL client with credentials: include 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c4a27f0bac
commit
926085eeab
28 changed files with 849 additions and 24 deletions
|
|
@ -1,2 +1 @@
|
|||
# Development environment defaults
|
||||
VITE_GRAPHQL_ENDPOINT=http://localhost:5000/graphql
|
||||
VITE_GRAPHQL_ENDPOINT=https://localhost:5001/graphql
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ import { BrowserRouter } from 'react-router-dom';
|
|||
import { App as AntApp } from 'antd';
|
||||
import AppRoutes from './routes';
|
||||
import AppLayout from './components/layout/AppLayout';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AntApp>
|
||||
<BrowserRouter>
|
||||
<AppLayout>
|
||||
<AppRoutes />
|
||||
</AppLayout>
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<AppRoutes />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
</BrowserRouter>
|
||||
</AntApp>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,22 +4,11 @@ import { QueryClient } from '@tanstack/react-query';
|
|||
// GraphQL endpoint - configure based on environment
|
||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
||||
|
||||
// Create GraphQL client
|
||||
// Create GraphQL client with cookie-based authentication
|
||||
export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
|
||||
headers: {
|
||||
// Add auth headers here when authentication is implemented
|
||||
// 'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include', // Send cookies with requests
|
||||
});
|
||||
|
||||
// Configure headers dynamically (for auth tokens, etc.)
|
||||
export const setAuthHeader = (token: string) => {
|
||||
graphqlClient.setHeader('Authorization', `Bearer ${token}`);
|
||||
};
|
||||
|
||||
export const removeAuthHeader = () => {
|
||||
graphqlClient.setHeader('Authorization', '');
|
||||
};
|
||||
|
||||
// Create TanStack Query client with default options
|
||||
export const queryClient = new QueryClient({
|
||||
|
|
|
|||
64
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
64
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that ensures the user is authenticated
|
||||
* Shows loading spinner while checking auth status
|
||||
* Redirects to login if not authenticated
|
||||
*/
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, login, refreshUser } = useAuthStore();
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#666' }}>Logger ind...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
login();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#666' }}>Omdirigerer til login...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
|
@ -9,11 +9,14 @@ import {
|
|||
import type { MenuProps } from 'antd';
|
||||
import CompanySwitcher from './CompanySwitcher';
|
||||
import FiscalYearSelector from './FiscalYearSelector';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
|
|
@ -39,8 +42,7 @@ export default function Header() {
|
|||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'logout':
|
||||
// Handle logout
|
||||
console.log('Logout clicked');
|
||||
logout();
|
||||
break;
|
||||
case 'settings':
|
||||
// Navigate to settings
|
||||
|
|
@ -103,7 +105,7 @@ export default function Header() {
|
|||
style={{ backgroundColor: '#1677ff' }}
|
||||
/>
|
||||
<Text style={{ maxWidth: 120 }} ellipsis>
|
||||
Bruger
|
||||
{user?.name || user?.email || 'Bruger'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
|
|
|
|||
82
frontend/src/services/authService.ts
Normal file
82
frontend/src/services/authService.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// API URL - extract base from GraphQL endpoint
|
||||
const API_URL = (import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql').replace('/graphql', '');
|
||||
|
||||
export interface UserContext {
|
||||
id: string | null;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
companyId: string | null;
|
||||
isApiKey: boolean;
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
private userContext: UserContext | null = null;
|
||||
|
||||
/**
|
||||
* Fetch user profile from the backend
|
||||
* Returns null if not authenticated
|
||||
*/
|
||||
async refreshUserContext(): Promise<UserContext | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/profile`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.userContext = await response.json();
|
||||
return this.userContext;
|
||||
}
|
||||
|
||||
// 401/403 means not authenticated
|
||||
this.userContext = null;
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user profile:', error);
|
||||
this.userContext = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login page
|
||||
* The backend will handle OIDC authentication and redirect back
|
||||
*/
|
||||
login(returnUrl?: string): void {
|
||||
const url = returnUrl || window.location.href;
|
||||
window.location.href = `${API_URL}/api/login?returnUrl=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear session
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_URL}/api/logout`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
|
||||
this.userContext = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user context
|
||||
*/
|
||||
get user(): UserContext | null {
|
||||
return this.userContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
get isAuthenticated(): boolean {
|
||||
return this.userContext !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const authService = new AuthService();
|
||||
61
frontend/src/stores/authStore.ts
Normal file
61
frontend/src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { create } from 'zustand';
|
||||
import { authService, type UserContext } from '@/services/authService';
|
||||
|
||||
interface AuthState {
|
||||
// User context from backend
|
||||
user: UserContext | null;
|
||||
// Loading state for initial auth check
|
||||
isLoading: boolean;
|
||||
// Whether user is authenticated
|
||||
isAuthenticated: boolean;
|
||||
// Error message if auth failed
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
login: (returnUrl?: string) => void;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
|
||||
login: (returnUrl?: string) => {
|
||||
authService.login(returnUrl);
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await authService.logout();
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
refreshUser: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const user = await authService.refreshUserContext();
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: user !== null,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Authentication failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
// Selector hooks for convenience
|
||||
export const useUser = () => useAuthStore((state) => state.user);
|
||||
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
||||
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue