Add role-based authorization system with admin functionality
- Implement authorization middleware and composables for role checking - Add groups/roles support to authentication and session management - Create admin dashboard pages and API endpoints - Add audit logging utility for tracking user actions - Enhance expense page with role-based access control - Improve session caching with authorization state management
This commit is contained in:
parent
2774b4050f
commit
f8d5e4d7e2
|
|
@ -0,0 +1,198 @@
|
|||
export interface UserWithGroups {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
name: string;
|
||||
authMethod: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: UserWithGroups | null;
|
||||
authenticated: boolean;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization composable for role-based access control
|
||||
*/
|
||||
export const useAuthorization = () => {
|
||||
// Get the current user state from Nuxt
|
||||
const nuxtApp = useNuxtApp();
|
||||
|
||||
/**
|
||||
* Get current user groups from session
|
||||
*/
|
||||
const getUserGroups = (): string[] => {
|
||||
const authState = nuxtApp.payload.data?.authState as AuthState;
|
||||
return authState?.groups || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
const getCurrentUser = (): UserWithGroups | null => {
|
||||
const authState = nuxtApp.payload.data?.authState as AuthState;
|
||||
return authState?.user || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has specific role/group
|
||||
*/
|
||||
const hasRole = (role: string): boolean => {
|
||||
const groups = getUserGroups();
|
||||
return groups.includes(role);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified roles
|
||||
*/
|
||||
const hasAnyRole = (roles: string[]): boolean => {
|
||||
const groups = getUserGroups();
|
||||
return roles.some(role => groups.includes(role));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has all of the specified roles
|
||||
*/
|
||||
const hasAllRoles = (roles: string[]): boolean => {
|
||||
const groups = getUserGroups();
|
||||
return roles.every(role => groups.includes(role));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user can access a specific resource/feature
|
||||
*/
|
||||
const canAccess = (resource: string): boolean => {
|
||||
const groups = getUserGroups();
|
||||
|
||||
// Define access rules for different resources
|
||||
const accessRules: Record<string, string[]> = {
|
||||
'expenses': ['sales', 'admin'],
|
||||
'admin-console': ['admin'],
|
||||
'audit-logs': ['admin'],
|
||||
'system-logs': ['admin'],
|
||||
'duplicate-management': ['admin'],
|
||||
'user-management': ['admin'],
|
||||
'interests': ['user', 'sales', 'admin'],
|
||||
'berths': ['user', 'sales', 'admin'],
|
||||
'dashboard': ['user', 'sales', 'admin'],
|
||||
};
|
||||
|
||||
const requiredRoles = accessRules[resource];
|
||||
if (!requiredRoles) {
|
||||
// If no specific rules defined, allow for authenticated users
|
||||
return groups.length > 0;
|
||||
}
|
||||
|
||||
return hasAnyRole(requiredRoles);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience methods for common role checks
|
||||
*/
|
||||
const isAdmin = (): boolean => hasRole('admin');
|
||||
const isSales = (): boolean => hasRole('sales');
|
||||
const isUser = (): boolean => hasRole('user');
|
||||
const isSalesOrAdmin = (): boolean => hasAnyRole(['sales', 'admin']);
|
||||
const isUserOrAbove = (): boolean => hasAnyRole(['user', 'sales', 'admin']);
|
||||
|
||||
/**
|
||||
* Get user's highest role (for display purposes)
|
||||
*/
|
||||
const getHighestRole = (): string => {
|
||||
const groups = getUserGroups();
|
||||
if (groups.includes('admin')) return 'admin';
|
||||
if (groups.includes('sales')) return 'sales';
|
||||
if (groups.includes('user')) return 'user';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get role display name
|
||||
*/
|
||||
const getRoleDisplayName = (role: string): string => {
|
||||
const roleNames: Record<string, string> = {
|
||||
'admin': 'Administrator',
|
||||
'sales': 'Sales Team',
|
||||
'user': 'User',
|
||||
'none': 'No Access'
|
||||
};
|
||||
return roleNames[role] || role;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get role color for UI display
|
||||
*/
|
||||
const getRoleColor = (role: string): string => {
|
||||
const roleColors: Record<string, string> = {
|
||||
'admin': 'red',
|
||||
'sales': 'purple',
|
||||
'user': 'blue',
|
||||
'none': 'grey'
|
||||
};
|
||||
return roleColors[role] || 'grey';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current route requires specific roles
|
||||
*/
|
||||
const checkRouteAccess = (route: any): boolean => {
|
||||
if (!route.meta?.roles) {
|
||||
// No role requirements, allow access
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredRoles = Array.isArray(route.meta.roles) ? route.meta.roles : [route.meta.roles];
|
||||
return hasAnyRole(requiredRoles);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get navigation items filtered by user permissions
|
||||
*/
|
||||
const getFilteredNavigation = (navigationItems: any[]): any[] => {
|
||||
return navigationItems.filter(item => {
|
||||
if (!item.roles) return true; // No role restrictions
|
||||
return hasAnyRole(item.roles);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update auth state (called by middleware)
|
||||
*/
|
||||
const updateAuthState = (authState: AuthState) => {
|
||||
if (!nuxtApp.payload.data) {
|
||||
nuxtApp.payload.data = {};
|
||||
}
|
||||
nuxtApp.payload.data.authState = authState;
|
||||
};
|
||||
|
||||
return {
|
||||
// State getters
|
||||
getUserGroups,
|
||||
getCurrentUser,
|
||||
|
||||
// Role checking
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasAllRoles,
|
||||
canAccess,
|
||||
|
||||
// Convenience methods
|
||||
isAdmin,
|
||||
isSales,
|
||||
isUser,
|
||||
isSalesOrAdmin,
|
||||
isUserOrAbove,
|
||||
|
||||
// Utility methods
|
||||
getHighestRole,
|
||||
getRoleDisplayName,
|
||||
getRoleColor,
|
||||
checkRouteAccess,
|
||||
getFilteredNavigation,
|
||||
|
||||
// State management
|
||||
updateAuthState
|
||||
};
|
||||
};
|
||||
|
|
@ -23,12 +23,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
const cacheExpiry = 30000; // 30 seconds cache
|
||||
|
||||
// Check if we have a cached session
|
||||
const cachedSession = nuxtApp.payload.data[cacheKey];
|
||||
const cachedSession = nuxtApp.payload.data?.[cacheKey];
|
||||
const now = Date.now();
|
||||
|
||||
if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) {
|
||||
console.log('[MIDDLEWARE] Using cached session');
|
||||
if (cachedSession.authenticated && cachedSession.user) {
|
||||
// Store auth state for components
|
||||
if (!nuxtApp.payload.data) {
|
||||
nuxtApp.payload.data = {};
|
||||
}
|
||||
nuxtApp.payload.data.authState = {
|
||||
user: cachedSession.user,
|
||||
authenticated: cachedSession.authenticated,
|
||||
groups: cachedSession.groups || []
|
||||
};
|
||||
return;
|
||||
}
|
||||
return navigateTo('/login');
|
||||
|
|
@ -48,19 +57,39 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
clearTimeout(timeout);
|
||||
|
||||
// Cache the session data
|
||||
if (!nuxtApp.payload.data) {
|
||||
nuxtApp.payload.data = {};
|
||||
}
|
||||
|
||||
nuxtApp.payload.data[cacheKey] = {
|
||||
...sessionData,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
// Store auth state for components
|
||||
nuxtApp.payload.data.authState = {
|
||||
user: sessionData.user,
|
||||
authenticated: sessionData.authenticated,
|
||||
groups: sessionData.groups || []
|
||||
};
|
||||
|
||||
console.log('[MIDDLEWARE] Session check result:', {
|
||||
authenticated: sessionData.authenticated,
|
||||
hasUser: !!sessionData.user,
|
||||
userId: sessionData.user?.id
|
||||
userId: sessionData.user?.id,
|
||||
groups: sessionData.groups || []
|
||||
});
|
||||
|
||||
if (sessionData.authenticated && sessionData.user) {
|
||||
console.log('[MIDDLEWARE] User authenticated, allowing access');
|
||||
|
||||
// Check for any auth errors from authorization middleware
|
||||
if (nuxtApp.payload.authError) {
|
||||
const toast = useToast();
|
||||
toast.error(String(nuxtApp.payload.authError));
|
||||
delete nuxtApp.payload.authError;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -73,10 +102,19 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
// If it's a network error or timeout, check if we have a recent cached session
|
||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
|
||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
||||
const recentCache = nuxtApp.payload.data[cacheKey];
|
||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
||||
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes
|
||||
console.log('[MIDDLEWARE] Using recent cache despite network error');
|
||||
if (recentCache.authenticated && recentCache.user) {
|
||||
// Store auth state for components
|
||||
if (!nuxtApp.payload.data) {
|
||||
nuxtApp.payload.data = {};
|
||||
}
|
||||
nuxtApp.payload.data.authState = {
|
||||
user: recentCache.user,
|
||||
authenticated: recentCache.authenticated,
|
||||
groups: recentCache.groups || []
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Skip on server-side rendering
|
||||
if (import.meta.server) return;
|
||||
|
||||
// Skip if no auth requirements or roles specified
|
||||
if (!to.meta.roles) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles);
|
||||
|
||||
try {
|
||||
// Get current session data with groups
|
||||
const sessionData = await $fetch('/api/auth/session') as any;
|
||||
|
||||
if (!sessionData.authenticated || !sessionData.user) {
|
||||
console.log('[AUTHORIZATION] User not authenticated, redirecting to login');
|
||||
return navigateTo('/login');
|
||||
}
|
||||
|
||||
// Get required roles for this route
|
||||
const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles];
|
||||
const userGroups = sessionData.groups || [];
|
||||
|
||||
// Check if user has any of the required roles
|
||||
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
|
||||
|
||||
// Store the error in nuxtApp to show toast on redirect
|
||||
const nuxtApp = useNuxtApp();
|
||||
nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`;
|
||||
|
||||
// Redirect to dashboard instead of login since user is authenticated
|
||||
return navigateTo('/dashboard');
|
||||
}
|
||||
|
||||
// Store auth state in nuxtApp for use by components
|
||||
const nuxtApp = useNuxtApp();
|
||||
if (!nuxtApp.payload.data) {
|
||||
nuxtApp.payload.data = {};
|
||||
}
|
||||
nuxtApp.payload.data.authState = {
|
||||
user: sessionData.user,
|
||||
authenticated: sessionData.authenticated,
|
||||
groups: sessionData.groups || []
|
||||
};
|
||||
|
||||
console.log('[AUTHORIZATION] Access granted for route:', to.path);
|
||||
} catch (error) {
|
||||
console.error('[AUTHORIZATION] Error checking route access:', error);
|
||||
|
||||
// If session check fails, redirect to login
|
||||
return navigateTo('/login');
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-6">
|
||||
<div class="flex items-center">
|
||||
<v-icon class="mr-3 text-red-600" size="large">mdi-shield-crown</v-icon>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Admin Console</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">System administration and monitoring</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ currentUser?.name || currentUser?.email }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<v-chip size="x-small" :color="getRoleColor(getHighestRole())" variant="tonal">
|
||||
{{ getRoleDisplayName(getHighestRole()) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<v-avatar size="40" color="red">
|
||||
<span class="text-white font-bold">{{ getInitials(currentUser?.name || currentUser?.email || 'A') }}</span>
|
||||
</v-avatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<v-card class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Audit Events</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats?.totalEvents || '...' }}</p>
|
||||
</div>
|
||||
<v-icon color="blue" size="large">mdi-chart-line</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Success Rate</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ successRate }}%</p>
|
||||
</div>
|
||||
<v-icon color="green" size="large">mdi-check-circle</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Active Users</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats?.topUsers?.length || '...' }}</p>
|
||||
</div>
|
||||
<v-icon color="purple" size="large">mdi-account-multiple</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">System Status</p>
|
||||
<p class="text-2xl font-bold text-green-600">Healthy</p>
|
||||
</div>
|
||||
<v-icon color="green" size="large">mdi-heart-pulse</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<v-card class="p-6 hover:shadow-lg transition-shadow cursor-pointer" @click="navigateTo('/dashboard/admin/audit-logs')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Audit Logs</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">View system audit trail and user activities</p>
|
||||
</div>
|
||||
<v-icon color="blue" size="large">mdi-file-document-multiple</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="p-6 hover:shadow-lg transition-shadow cursor-pointer" @click="navigateTo('/dashboard/admin/system-logs')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">System Logs</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">Real-time system logs and monitoring</p>
|
||||
</div>
|
||||
<v-icon color="green" size="large">mdi-console</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="p-6 hover:shadow-lg transition-shadow cursor-pointer" @click="navigateTo('/dashboard/admin/duplicates')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Duplicate Management</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">Find and merge duplicate interest records</p>
|
||||
<v-chip v-if="duplicateCount > 0" size="small" color="warning" class="mt-2">
|
||||
{{ duplicateCount }} found
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-icon color="orange" size="large">mdi-content-duplicate</v-icon>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-card class="mb-8">
|
||||
<v-card-title class="flex items-center justify-between">
|
||||
<span>Recent Audit Activity</span>
|
||||
<v-btn size="small" variant="text" @click="navigateTo('/dashboard/admin/audit-logs')">
|
||||
View All
|
||||
<v-icon right>mdi-arrow-right</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
<p class="mt-4 text-gray-600">Loading recent activity...</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text v-else-if="recentLogs.length === 0" class="text-center py-8">
|
||||
<v-icon size="48" color="gray">mdi-history</v-icon>
|
||||
<p class="mt-4 text-gray-600">No recent activity</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text v-else class="pa-0">
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="log in recentLogs"
|
||||
:key="log.id"
|
||||
class="border-b border-gray-100 dark:border-gray-700 last:border-b-0"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="small" :color="log.status === 'success' ? 'green' : 'red'">
|
||||
<v-icon color="white" size="small">
|
||||
{{ log.status === 'success' ? 'mdi-check' : 'mdi-alert' }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ log.action.replace(/_/g, ' ') }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="flex items-center space-x-4">
|
||||
<span>{{ log.user_email }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ formatRelativeTime(log.timestamp) }}</span>
|
||||
<span v-if="log.resource_type">•</span>
|
||||
<v-chip v-if="log.resource_type" size="x-small" variant="tonal">
|
||||
{{ log.resource_type }}
|
||||
</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
// Set page metadata for admin access
|
||||
definePageMeta({
|
||||
middleware: 'authentication',
|
||||
layout: 'default',
|
||||
roles: ['admin']
|
||||
});
|
||||
|
||||
// Page head
|
||||
useHead({
|
||||
title: 'Admin Console - Port Nimara'
|
||||
});
|
||||
|
||||
// Get authorization composable
|
||||
const { getCurrentUser, getHighestRole, getRoleDisplayName, getRoleColor } = useAuthorization();
|
||||
|
||||
// Reactive state
|
||||
const loading = ref(true);
|
||||
const stats = ref(null);
|
||||
const recentLogs = ref([]);
|
||||
const duplicateCount = ref(0);
|
||||
|
||||
// Get current user info
|
||||
const currentUser = computed(() => getCurrentUser());
|
||||
|
||||
// Calculate success rate
|
||||
const successRate = computed(() => {
|
||||
if (!stats.value) return 0;
|
||||
const total = stats.value.totalEvents;
|
||||
const success = stats.value.successEvents;
|
||||
return total > 0 ? Math.round((success / total) * 100) : 100;
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
const getInitials = (name: string) => {
|
||||
if (!name) return 'A';
|
||||
const parts = name.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const formatRelativeTime = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffMs = now.getTime() - time.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return time.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Load dashboard data
|
||||
const loadDashboardData = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Load audit stats
|
||||
const statsResponse = await $fetch('/api/admin/audit-logs/stats', {
|
||||
params: { days: 30 }
|
||||
});
|
||||
|
||||
if (statsResponse.success) {
|
||||
stats.value = statsResponse.data;
|
||||
}
|
||||
|
||||
// Load recent audit logs
|
||||
const logsResponse = await $fetch('/api/admin/audit-logs/list', {
|
||||
params: { limit: 10, offset: 0 }
|
||||
});
|
||||
|
||||
if (logsResponse.success) {
|
||||
recentLogs.value = logsResponse.data;
|
||||
}
|
||||
|
||||
// TODO: Load duplicate count when duplicate detection is implemented
|
||||
// const duplicatesResponse = await $fetch('/api/admin/duplicates/check');
|
||||
// duplicateCount.value = duplicatesResponse.count || 0;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadDashboardData();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -268,7 +268,8 @@ const ExpenseDetailsModal = defineAsyncComponent(() => import('@/components/Expe
|
|||
// Page meta
|
||||
definePageMeta({
|
||||
middleware: 'authentication',
|
||||
layout: 'dashboard'
|
||||
layout: 'dashboard',
|
||||
roles: ['sales', 'admin']
|
||||
});
|
||||
|
||||
// Reactive state
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { requireAdmin } from '~/server/utils/auth';
|
||||
import { getAuditLogs } from '~/server/utils/audit-logger';
|
||||
import type { AuditLogFilters } from '~/server/utils/audit-logger';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[ADMIN] Audit logs list request');
|
||||
|
||||
try {
|
||||
// Require admin authentication
|
||||
await requireAdmin(event);
|
||||
|
||||
// Get query parameters
|
||||
const query = getQuery(event);
|
||||
|
||||
const filters: AuditLogFilters = {
|
||||
startDate: query.startDate as string,
|
||||
endDate: query.endDate as string,
|
||||
userId: query.userId as string,
|
||||
userEmail: query.userEmail as string,
|
||||
action: query.action as string,
|
||||
resourceType: query.resourceType as string,
|
||||
status: query.status as 'success' | 'failure',
|
||||
limit: query.limit ? parseInt(query.limit as string) : 50,
|
||||
offset: query.offset ? parseInt(query.offset as string) : 0
|
||||
};
|
||||
|
||||
// Get audit logs
|
||||
const result = await getAuditLogs(filters);
|
||||
|
||||
console.log('[ADMIN] Returning audit logs:', {
|
||||
count: result.list.length,
|
||||
total: result.totalCount,
|
||||
filters
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.list,
|
||||
pagination: {
|
||||
total: result.totalCount,
|
||||
limit: filters.limit || 50,
|
||||
offset: filters.offset || 0,
|
||||
hasMore: (filters.offset || 0) + result.list.length < result.totalCount
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[ADMIN] Failed to get audit logs:', error);
|
||||
|
||||
if (error.statusCode === 403) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Insufficient permissions. Admin access required.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve audit logs'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { requireAdmin } from '~/server/utils/auth';
|
||||
import { getAuditStats } from '~/server/utils/audit-logger';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[ADMIN] Audit stats request');
|
||||
|
||||
try {
|
||||
// Require admin authentication
|
||||
await requireAdmin(event);
|
||||
|
||||
// Get query parameters
|
||||
const query = getQuery(event);
|
||||
const days = query.days ? parseInt(query.days as string) : 30;
|
||||
|
||||
// Get audit statistics
|
||||
const stats = await getAuditStats(days);
|
||||
|
||||
console.log('[ADMIN] Returning audit stats:', {
|
||||
totalEvents: stats.totalEvents,
|
||||
days
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[ADMIN] Failed to get audit stats:', error);
|
||||
|
||||
if (error.statusCode === 403) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Insufficient permissions. Admin access required.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve audit statistics'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
if (!oidcSessionCookie) {
|
||||
console.log('[SESSION] No OIDC session cookie found')
|
||||
return { user: null, authenticated: false }
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
}
|
||||
|
||||
console.log('[SESSION] OIDC session cookie found, parsing...')
|
||||
|
|
@ -19,6 +19,7 @@ export default defineEventHandler(async (event) => {
|
|||
console.log('[SESSION] Session data parsed successfully:', {
|
||||
hasUser: !!sessionData.user,
|
||||
hasAccessToken: !!sessionData.accessToken,
|
||||
hasIdToken: !!sessionData.idToken,
|
||||
expiresAt: sessionData.expiresAt,
|
||||
createdAt: sessionData.createdAt,
|
||||
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
||||
|
|
@ -31,7 +32,7 @@ export default defineEventHandler(async (event) => {
|
|||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
}
|
||||
|
||||
// Validate session structure
|
||||
|
|
@ -45,7 +46,7 @@ export default defineEventHandler(async (event) => {
|
|||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
}
|
||||
|
||||
// Check if session is still valid
|
||||
|
|
@ -61,13 +62,51 @@ export default defineEventHandler(async (event) => {
|
|||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
}
|
||||
|
||||
// Extract groups from ID token
|
||||
let userGroups: string[] = [];
|
||||
if (sessionData.idToken) {
|
||||
try {
|
||||
// Parse JWT payload (base64 decode the middle section)
|
||||
const tokenParts = sessionData.idToken.split('.');
|
||||
if (tokenParts.length >= 2) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]));
|
||||
userGroups = payload.groups || [];
|
||||
console.log('[SESSION] Groups extracted from token:', userGroups);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error('[SESSION] Failed to parse ID token:', tokenError);
|
||||
// Continue without groups - not a fatal error
|
||||
}
|
||||
}
|
||||
|
||||
// Also check access token for groups as fallback
|
||||
if (userGroups.length === 0 && sessionData.accessToken) {
|
||||
try {
|
||||
const tokenParts = sessionData.accessToken.split('.');
|
||||
if (tokenParts.length >= 2) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]));
|
||||
userGroups = payload.groups || [];
|
||||
console.log('[SESSION] Groups extracted from access token:', userGroups);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error('[SESSION] Failed to parse access token:', tokenError);
|
||||
}
|
||||
}
|
||||
|
||||
// Default group assignment if no groups found
|
||||
if (userGroups.length === 0) {
|
||||
console.log('[SESSION] No groups found in token, assigning default "user" group');
|
||||
userGroups = ['user'];
|
||||
}
|
||||
|
||||
console.log('[SESSION] Valid session found for user:', {
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email,
|
||||
username: sessionData.user.username
|
||||
username: sessionData.user.username,
|
||||
groups: userGroups
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -76,9 +115,11 @@ export default defineEventHandler(async (event) => {
|
|||
email: sessionData.user.email,
|
||||
username: sessionData.user.username,
|
||||
name: sessionData.user.name,
|
||||
authMethod: sessionData.user.authMethod || 'keycloak'
|
||||
authMethod: sessionData.user.authMethod || 'keycloak',
|
||||
groups: userGroups
|
||||
},
|
||||
authenticated: true
|
||||
authenticated: true,
|
||||
groups: userGroups
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SESSION] OIDC session check error:', error)
|
||||
|
|
@ -88,6 +129,6 @@ export default defineEventHandler(async (event) => {
|
|||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { requireAuth } from '@/server/utils/auth';
|
||||
import { requireSalesOrAdmin } from '@/server/utils/auth';
|
||||
import { logAuditEvent } from '@/server/utils/audit-logger';
|
||||
import { getExpenseById } from '@/server/utils/nocodb';
|
||||
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||
import { uploadBuffer } from '@/server/utils/minio';
|
||||
import type { Expense } from '@/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event);
|
||||
await requireSalesOrAdmin(event);
|
||||
|
||||
const body = await readBody(event);
|
||||
const { expenseIds } = body;
|
||||
|
|
@ -141,7 +142,7 @@ export default defineEventHandler(async (event) => {
|
|||
// Return CSV for direct download
|
||||
setHeader(event, 'Content-Type', 'text/csv');
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`);
|
||||
setHeader(event, 'Content-Length', csvBuffer.length.toString());
|
||||
setHeader(event, 'Content-Length', csvBuffer.length);
|
||||
|
||||
return csvContent;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
import type { AuthSession } from './auth';
|
||||
import { getAuthSession } from './auth';
|
||||
import { getNocoDbConfiguration } from './nocodb';
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id?: number;
|
||||
timestamp: string;
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_groups: string[];
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id?: string;
|
||||
changes?: any;
|
||||
metadata: {
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
session_id?: string;
|
||||
request_url?: string;
|
||||
request_method?: string;
|
||||
response_status?: number;
|
||||
request_duration?: number;
|
||||
};
|
||||
status: 'success' | 'failure';
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
action?: string;
|
||||
resourceType?: string;
|
||||
status?: 'success' | 'failure';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// NocoDB table ID for audit logs (will be updated when table is created)
|
||||
const AUDIT_LOGS_TABLE_ID = "audit_logs"; // Placeholder - update with actual table ID
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
*/
|
||||
export const logAuditEvent = async (
|
||||
event: any,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
details: {
|
||||
resourceId?: string;
|
||||
changes?: any;
|
||||
status?: 'success' | 'failure';
|
||||
errorMessage?: string;
|
||||
duration?: number;
|
||||
} = {}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Get user info from the event
|
||||
let userInfo = {
|
||||
user_id: 'anonymous',
|
||||
user_email: 'anonymous',
|
||||
user_groups: [] as string[]
|
||||
};
|
||||
|
||||
try {
|
||||
const authSession = await getAuthSession(event);
|
||||
if (authSession.authenticated && authSession.user) {
|
||||
userInfo = {
|
||||
user_id: authSession.user.id,
|
||||
user_email: authSession.user.email,
|
||||
user_groups: authSession.groups || []
|
||||
};
|
||||
}
|
||||
} catch (authError) {
|
||||
// Continue with anonymous user if auth fails
|
||||
console.warn('[AUDIT] Could not get user info:', authError);
|
||||
}
|
||||
|
||||
// Extract request metadata
|
||||
const headers = event.node.req.headers;
|
||||
const metadata = {
|
||||
ip_address: getClientIP(event),
|
||||
user_agent: headers['user-agent'] || 'unknown',
|
||||
session_id: headers['cookie']?.split('nuxt-oidc-auth=')[1]?.split(';')[0] || 'none',
|
||||
request_url: event.node.req.url || 'unknown',
|
||||
request_method: event.node.req.method || 'unknown',
|
||||
response_status: details.status === 'failure' ? 500 : 200,
|
||||
request_duration: details.duration || 0
|
||||
};
|
||||
|
||||
// Create audit log entry
|
||||
const auditEntry: Omit<AuditLogEntry, 'id'> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: userInfo.user_id,
|
||||
user_email: userInfo.user_email,
|
||||
user_groups: userInfo.user_groups,
|
||||
action,
|
||||
resource_type: resourceType,
|
||||
resource_id: details.resourceId,
|
||||
changes: details.changes,
|
||||
metadata,
|
||||
status: details.status || 'success',
|
||||
error_message: details.errorMessage
|
||||
};
|
||||
|
||||
// Store in NocoDB
|
||||
await storeAuditLog(auditEntry);
|
||||
|
||||
console.log('[AUDIT] Logged event:', {
|
||||
action,
|
||||
resourceType,
|
||||
resourceId: details.resourceId,
|
||||
user: userInfo.user_email,
|
||||
status: details.status || 'success'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AUDIT] Failed to log audit event:', error);
|
||||
// Don't throw - audit logging should not break the main request
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store audit log in NocoDB
|
||||
*/
|
||||
const storeAuditLog = async (entry: Omit<AuditLogEntry, 'id'>): Promise<void> => {
|
||||
const config = getNocoDbConfiguration();
|
||||
|
||||
// Prepare data for NocoDB
|
||||
const data = {
|
||||
timestamp: entry.timestamp,
|
||||
user_id: entry.user_id,
|
||||
user_email: entry.user_email,
|
||||
user_groups: JSON.stringify(entry.user_groups),
|
||||
action: entry.action,
|
||||
resource_type: entry.resource_type,
|
||||
resource_id: entry.resource_id || null,
|
||||
changes: entry.changes ? JSON.stringify(entry.changes) : null,
|
||||
metadata: JSON.stringify(entry.metadata),
|
||||
status: entry.status,
|
||||
error_message: entry.error_message || null
|
||||
};
|
||||
|
||||
try {
|
||||
await $fetch(`${config.url}/api/v2/tables/${AUDIT_LOGS_TABLE_ID}/records`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AUDIT] Failed to store audit log in NocoDB:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get audit logs with filtering
|
||||
*/
|
||||
export const getAuditLogs = async (filters: AuditLogFilters = {}): Promise<{
|
||||
list: AuditLogEntry[];
|
||||
totalCount: number;
|
||||
}> => {
|
||||
const config = getNocoDbConfiguration();
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
limit: filters.limit || 100,
|
||||
offset: filters.offset || 0,
|
||||
sort: '-timestamp' // Newest first
|
||||
};
|
||||
|
||||
// Build where clause
|
||||
const whereConditions: string[] = [];
|
||||
|
||||
if (filters.startDate) {
|
||||
whereConditions.push(`(timestamp,gte,${filters.startDate})`);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
whereConditions.push(`(timestamp,lte,${filters.endDate})`);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
whereConditions.push(`(user_id,eq,${filters.userId})`);
|
||||
}
|
||||
|
||||
if (filters.userEmail) {
|
||||
whereConditions.push(`(user_email,like,%${filters.userEmail}%)`);
|
||||
}
|
||||
|
||||
if (filters.action) {
|
||||
whereConditions.push(`(action,eq,${filters.action})`);
|
||||
}
|
||||
|
||||
if (filters.resourceType) {
|
||||
whereConditions.push(`(resource_type,eq,${filters.resourceType})`);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
whereConditions.push(`(status,eq,${filters.status})`);
|
||||
}
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
params.where = whereConditions.join('~and');
|
||||
}
|
||||
|
||||
const response = await $fetch(`${config.url}/api/v2/tables/${AUDIT_LOGS_TABLE_ID}/records`, {
|
||||
headers: {
|
||||
'xc-token': config.token
|
||||
},
|
||||
params
|
||||
}) as any;
|
||||
|
||||
// Parse JSON fields
|
||||
const parsedList = (response.list || []).map((item: any) => ({
|
||||
...item,
|
||||
user_groups: JSON.parse(item.user_groups || '[]'),
|
||||
changes: item.changes ? JSON.parse(item.changes) : null,
|
||||
metadata: JSON.parse(item.metadata || '{}')
|
||||
}));
|
||||
|
||||
return {
|
||||
list: parsedList,
|
||||
totalCount: response.PageInfo?.totalRows || parsedList.length
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AUDIT] Failed to get audit logs:', error);
|
||||
return { list: [], totalCount: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get audit statistics
|
||||
*/
|
||||
export const getAuditStats = async (days: number = 30): Promise<{
|
||||
totalEvents: number;
|
||||
successEvents: number;
|
||||
failureEvents: number;
|
||||
topActions: Array<{ action: string; count: number }>;
|
||||
topUsers: Array<{ user_email: string; count: number }>;
|
||||
dailyActivity: Array<{ date: string; count: number }>;
|
||||
}> => {
|
||||
const endDate = new Date().toISOString();
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
try {
|
||||
const logs = await getAuditLogs({
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 10000 // Get a large sample for stats
|
||||
});
|
||||
|
||||
const totalEvents = logs.totalCount;
|
||||
const successEvents = logs.list.filter(log => log.status === 'success').length;
|
||||
const failureEvents = logs.list.filter(log => log.status === 'failure').length;
|
||||
|
||||
// Calculate top actions
|
||||
const actionCounts = logs.list.reduce((acc, log) => {
|
||||
acc[log.action] = (acc[log.action] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const topActions = Object.entries(actionCounts)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([action, count]) => ({ action, count }));
|
||||
|
||||
// Calculate top users
|
||||
const userCounts = logs.list.reduce((acc, log) => {
|
||||
if (log.user_email !== 'anonymous') {
|
||||
acc[log.user_email] = (acc[log.user_email] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const topUsers = Object.entries(userCounts)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([user_email, count]) => ({ user_email, count }));
|
||||
|
||||
// Calculate daily activity
|
||||
const dailyCounts = logs.list.reduce((acc, log) => {
|
||||
const date = log.timestamp.split('T')[0];
|
||||
acc[date] = (acc[date] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const dailyActivity = Object.entries(dailyCounts)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, count]) => ({ date, count }));
|
||||
|
||||
return {
|
||||
totalEvents,
|
||||
successEvents,
|
||||
failureEvents,
|
||||
topActions,
|
||||
topUsers,
|
||||
dailyActivity
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AUDIT] Failed to get audit stats:', error);
|
||||
return {
|
||||
totalEvents: 0,
|
||||
successEvents: 0,
|
||||
failureEvents: 0,
|
||||
topActions: [],
|
||||
topUsers: [],
|
||||
dailyActivity: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get client IP address from request
|
||||
*/
|
||||
const getClientIP = (event: any): string => {
|
||||
const headers = event.node.req.headers;
|
||||
|
||||
// Check various headers for the real IP
|
||||
const forwarded = headers['x-forwarded-for'];
|
||||
const realIp = headers['x-real-ip'];
|
||||
const clientIp = headers['x-client-ip'];
|
||||
|
||||
if (forwarded) {
|
||||
// x-forwarded-for can be a comma-separated list
|
||||
return forwarded.toString().split(',')[0].trim();
|
||||
}
|
||||
|
||||
if (realIp) {
|
||||
return realIp.toString();
|
||||
}
|
||||
|
||||
if (clientIp) {
|
||||
return clientIp.toString();
|
||||
}
|
||||
|
||||
// Fallback to socket remote address
|
||||
return event.node.req.socket?.remoteAddress || 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience functions for common audit events
|
||||
*/
|
||||
export const auditInterestCreated = (event: any, interestId: string, interestData: any) => {
|
||||
return logAuditEvent(event, 'CREATE_INTEREST', 'interest', {
|
||||
resourceId: interestId,
|
||||
changes: { created: interestData },
|
||||
status: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
export const auditInterestUpdated = (event: any, interestId: string, changes: any) => {
|
||||
return logAuditEvent(event, 'UPDATE_INTEREST', 'interest', {
|
||||
resourceId: interestId,
|
||||
changes,
|
||||
status: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
export const auditInterestDeleted = (event: any, interestId: string) => {
|
||||
return logAuditEvent(event, 'DELETE_INTEREST', 'interest', {
|
||||
resourceId: interestId,
|
||||
status: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
export const auditInterestMerged = (event: any, masterInterestId: string, mergedInterestIds: string[], mergeDetails: any) => {
|
||||
return logAuditEvent(event, 'MERGE_INTERESTS', 'interest', {
|
||||
resourceId: masterInterestId,
|
||||
changes: {
|
||||
master_interest_id: masterInterestId,
|
||||
merged_interest_ids: mergedInterestIds,
|
||||
merge_details: mergeDetails
|
||||
},
|
||||
status: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
export const auditLogin = (event: any, userId: string, userEmail: string) => {
|
||||
return logAuditEvent(event, 'USER_LOGIN', 'authentication', {
|
||||
resourceId: userId,
|
||||
changes: { user_email: userEmail },
|
||||
status: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
export const auditLogout = (event: any, userId: string, userEmail: string) => {
|
||||
return logAuditEvent(event, 'USER_LOGOUT', 'authentication', {
|
||||
resourceId: userId,
|
||||
changes: { user_email: userEmail },
|
||||
status: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
export const auditAccessDenied = (event: any, resource: string, requiredRoles: string[]) => {
|
||||
return logAuditEvent(event, 'ACCESS_DENIED', 'authorization', {
|
||||
resourceId: resource,
|
||||
changes: { required_roles: requiredRoles },
|
||||
status: 'failure',
|
||||
errorMessage: 'Insufficient permissions'
|
||||
});
|
||||
};
|
||||
|
|
@ -1,3 +1,18 @@
|
|||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
name: string;
|
||||
authMethod: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
user: AuthenticatedUser | null;
|
||||
authenticated: boolean;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request is authenticated via Keycloak OIDC session
|
||||
*/
|
||||
|
|
@ -54,24 +69,99 @@ export const isAuthenticated = async (event: any): Promise<boolean> => {
|
|||
}
|
||||
}
|
||||
|
||||
export const requireAuth = async (event: any) => {
|
||||
/**
|
||||
* Get the full authenticated session with user and groups
|
||||
*/
|
||||
export const getAuthSession = async (event: any): Promise<AuthSession> => {
|
||||
try {
|
||||
const sessionData = await $fetch('/api/auth/session', {
|
||||
headers: {
|
||||
cookie: getHeader(event, 'cookie') || ''
|
||||
}
|
||||
}) as AuthSession;
|
||||
|
||||
return sessionData;
|
||||
} catch (error) {
|
||||
console.error('[auth] Failed to get auth session:', error);
|
||||
return { user: null, authenticated: false, groups: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user groups from the session
|
||||
*/
|
||||
export const getUserGroups = async (event: any): Promise<string[]> => {
|
||||
const session = await getAuthSession(event);
|
||||
return session.groups || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific role/group
|
||||
*/
|
||||
export const hasRole = async (event: any, role: string): Promise<boolean> => {
|
||||
const groups = await getUserGroups(event);
|
||||
return groups.includes(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified roles
|
||||
*/
|
||||
export const hasAnyRole = async (event: any, roles: string[]): Promise<boolean> => {
|
||||
const groups = await getUserGroups(event);
|
||||
return roles.some(role => groups.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication and optionally specific roles
|
||||
*/
|
||||
export const requireAuth = async (event: any, requiredRoles?: string[]): Promise<AuthSession> => {
|
||||
console.log('[requireAuth] Checking authentication for:', event.node.req.url, 'Required roles:', requiredRoles);
|
||||
|
||||
// First check for internal API authentication
|
||||
const internalAuth = checkInternalAuth(event);
|
||||
if (internalAuth) {
|
||||
console.log('[requireAuth] Internal API authentication successful');
|
||||
return;
|
||||
return {
|
||||
user: {
|
||||
id: 'system',
|
||||
email: 'system@internal',
|
||||
username: 'system',
|
||||
name: 'System',
|
||||
authMethod: 'internal',
|
||||
groups: ['admin'] // Internal calls have admin privileges
|
||||
},
|
||||
authenticated: true,
|
||||
groups: ['admin']
|
||||
};
|
||||
}
|
||||
|
||||
// Then check user authentication
|
||||
const authenticated = await isAuthenticated(event);
|
||||
if (!authenticated) {
|
||||
// Get full session with groups
|
||||
const session = await getAuthSession(event);
|
||||
|
||||
if (!session.authenticated || !session.user) {
|
||||
console.log('[requireAuth] Authentication failed for:', event.node.req.url);
|
||||
console.log('[requireAuth] Available cookies:', Object.keys(event.node.req.headers.cookie ? parseCookies(event.node.req.headers.cookie) : {}));
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Authentication required. Please login with Keycloak."
|
||||
statusMessage: "Authentication required. Please login."
|
||||
});
|
||||
}
|
||||
|
||||
// Check role requirements if specified
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
const userGroups = session.groups || [];
|
||||
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
console.log('[requireAuth] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Insufficient permissions. This action requires one of the following roles: ' + requiredRoles.join(', ')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[requireAuth] Authentication successful for user:', session.user.email, 'Groups:', session.groups);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -116,33 +206,31 @@ const checkInternalAuth = (event: any): boolean => {
|
|||
/**
|
||||
* Get the authenticated user from the session
|
||||
*/
|
||||
export const getAuthenticatedUser = async (event: any): Promise<any | null> => {
|
||||
export const getAuthenticatedUser = async (event: any): Promise<AuthenticatedUser | null> => {
|
||||
try {
|
||||
const oidcSession = getCookie(event, 'nuxt-oidc-auth');
|
||||
|
||||
if (!oidcSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionData = JSON.parse(oidcSession);
|
||||
|
||||
// Validate session
|
||||
if (!sessionData.user || !sessionData.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if session is still valid
|
||||
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionData.user;
|
||||
const session = await getAuthSession(event);
|
||||
return session.user;
|
||||
} catch (error) {
|
||||
console.error('[getAuthenticatedUser] Error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization helper functions for common roles
|
||||
*/
|
||||
export const requireAdmin = async (event: any): Promise<AuthSession> => {
|
||||
return requireAuth(event, ['admin']);
|
||||
}
|
||||
|
||||
export const requireSalesOrAdmin = async (event: any): Promise<AuthSession> => {
|
||||
return requireAuth(event, ['sales', 'admin']);
|
||||
}
|
||||
|
||||
export const requireUserOrAbove = async (event: any): Promise<AuthSession> => {
|
||||
return requireAuth(event, ['user', 'sales', 'admin']);
|
||||
}
|
||||
|
||||
function parseCookies(cookieString: string): Record<string, string> {
|
||||
return cookieString.split(';').reduce((cookies: Record<string, string>, cookie) => {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
|
|
|
|||
Loading…
Reference in New Issue