Compare commits

..

2 Commits

7 changed files with 115 additions and 47 deletions

View File

@@ -29,7 +29,7 @@
"helmet": "8.1.0", "helmet": "8.1.0",
"express-validator": "7.3.0", "express-validator": "7.3.0",
"exceljs": "4.4.0", "exceljs": "4.4.0",
"playwright": "^1.37.0" "playwright-chromium": "^1.37.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",

View File

@@ -9,7 +9,7 @@ import {
import { AuthRequest } from '../middleware/auth.js'; import { AuthRequest } from '../middleware/auth.js';
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js'; import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import { chromium } from 'playwright'; import { chromium } from 'playwright-chromium';
async function getPlanWithDetails(planId: string) { async function getPlanWithDetails(planId: string) {
const plan = await db.get<any>(` const plan = await db.get<any>(`
@@ -989,6 +989,20 @@ interface ExportTimetableData {
allTimeSlots: ExportTimeSlot[]; allTimeSlots: ExportTimeSlot[];
} }
function sortTimeSlotsByStartTime(timeSlots: any[]): any[] {
const timeToMinutes = (timeStr: string) => {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
return [...timeSlots].sort((a, b) => {
const minutesA = timeToMinutes(a.startTime);
const minutesB = timeToMinutes(b.startTime);
return minutesA - minutesB; // Ascending order (earliest first)
});
}
function getTimetableDataForExport(plan: any): ExportTimetableData { function getTimetableDataForExport(plan: any): ExportTimetableData {
const weekdays = [ const weekdays = [
{ id: 1, name: 'Montag' }, { id: 1, name: 'Montag' },
@@ -1032,9 +1046,16 @@ function getTimetableDataForExport(plan: any): ExportTimetableData {
Object.keys(shiftsByDay).forEach(day => { Object.keys(shiftsByDay).forEach(day => {
const dayNum = parseInt(day); const dayNum = parseInt(day);
shiftsByDay[dayNum].sort((a: any, b: any) => { shiftsByDay[dayNum].sort((a: any, b: any) => {
const timeA = a.startTime || ''; // Use numeric comparison for proper time sorting
const timeB = b.startTime || ''; const timeToMinutes = (timeStr: string) => {
return timeA.localeCompare(timeB); if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const minutesA = timeToMinutes(a.startTime);
const minutesB = timeToMinutes(b.startTime);
return minutesA - minutesB;
}); });
}); });
@@ -1073,15 +1094,12 @@ function getTimetableDataForExport(plan: any): ExportTimetableData {
}); });
}); });
// Convert to array and sort by start time // Convert to array and sort by start time using numeric comparison
const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a: ExportTimeSlot, b: ExportTimeSlot) => { const allTimeSlots = sortTimeSlotsByStartTime(Array.from(allTimeSlotsMap.values()));
return (a.startTime || '').localeCompare(b.startTime || '');
});
return { days, allTimeSlots }; return { days, allTimeSlots };
} }
// Export shift plan to Excel
// Export shift plan to Excel // Export shift plan to Excel
export const exportShiftPlanToExcel = async (req: Request, res: Response): Promise<void> => { export const exportShiftPlanToExcel = async (req: Request, res: Response): Promise<void> => {
try { try {

View File

@@ -5,11 +5,11 @@ import { Request } from 'express';
const getClientIP = (req: Request): string => { const getClientIP = (req: Request): string => {
// Read from environment which header to trust // Read from environment which header to trust
const trustedHeader = process.env.TRUSTED_PROXY_HEADER || 'x-forwarded-for'; const trustedHeader = process.env.TRUSTED_PROXY_HEADER || 'x-forwarded-for';
const forwarded = req.headers[trustedHeader]; const forwarded = req.headers[trustedHeader];
const realIp = req.headers['x-real-ip']; const realIp = req.headers['x-real-ip'];
const cfConnectingIp = req.headers['cf-connecting-ip']; // Cloudflare const cfConnectingIp = req.headers['cf-connecting-ip']; // Cloudflare
// If we have a forwarded header and trust proxy is configured // If we have a forwarded header and trust proxy is configured
if (forwarded) { if (forwarded) {
if (Array.isArray(forwarded)) { if (Array.isArray(forwarded)) {
@@ -22,66 +22,95 @@ const getClientIP = (req: Request): string => {
return firstIP; return firstIP;
} }
} }
// Cloudflare support // Cloudflare support
if (cfConnectingIp) { if (cfConnectingIp) {
console.log(`🔍 Using Cloudflare IP: ${cfConnectingIp}`); console.log(`🔍 Using Cloudflare IP: ${cfConnectingIp}`);
return cfConnectingIp.toString(); return cfConnectingIp.toString();
} }
// Fallback to x-real-ip // Fallback to x-real-ip
if (realIp) { if (realIp) {
console.log(`🔍 Using x-real-ip: ${realIp}`); console.log(`🔍 Using x-real-ip: ${realIp}`);
return realIp.toString(); return realIp.toString();
} }
// Final fallback to connection remote address // Final fallback to connection remote address
const remoteAddress = req.socket.remoteAddress || req.ip || 'unknown'; const remoteAddress = req.socket.remoteAddress || req.ip || 'unknown';
console.log(`🔍 Using remote address: ${remoteAddress}`); console.log(`🔍 Using remote address: ${remoteAddress}`);
return remoteAddress; return remoteAddress;
}; };
// Helper to check if an IP is a loopback address (IPv4 or IPv6)
const isLoopbackAddress = (ip: string): boolean => {
// IPv4 loopback: 127.0.0.0/8
if (ip.startsWith('127.') || ip === 'localhost') {
return true;
}
// IPv6 loopback: ::1
// Also handle IPv4-mapped IPv6 addresses like ::ffff:127.0.0.1
if (ip === '::1' || ip === '::ffff:127.0.0.1') {
return true;
}
// Handle full IPv6 loopback notation
if (ip.toLowerCase().startsWith('0000:0000:0000:0000:0000:0000:0000:0001') ||
ip.toLowerCase() === '0:0:0:0:0:0:0:1') {
return true;
}
return false;
};
// Helper to check if request should be limited // Helper to check if request should be limited
const shouldSkipLimit = (req: Request): boolean => { const shouldSkipLimit = (req: Request): boolean => {
const skipPaths = [ const skipPaths = [
'/api/health', '/api/health',
'/api/setup/status', '/api/setup/status',
'/api/auth/validate' '/api/auth/validate'
]; ];
// Skip for successful GET requests (data fetching) // Skip for successful GET requests (data fetching)
if (req.method === 'GET' && req.path.startsWith('/api/')) { if (req.method === 'GET' && req.path.startsWith('/api/')) {
return true; return true;
} }
const clientIP = getClientIP(req);
// Skip for loopback addresses (local development)
if (isLoopbackAddress(clientIP)) {
console.log(`✅ Loopback address skipped: ${clientIP}`);
return true;
}
// Skip for whitelisted IPs from environment // Skip for whitelisted IPs from environment
const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || []; const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || [];
const clientIP = getClientIP(req);
if (whitelist.includes(clientIP)) { if (whitelist.includes(clientIP)) {
console.log(`✅ IP whitelisted: ${clientIP}`); console.log(`✅ IP whitelisted: ${clientIP}`);
return true; return true;
} }
return skipPaths.includes(req.path); return skipPaths.includes(req.path);
}; };
// Environment-based configuration // Environment-based configuration
const getRateLimitConfig = () => { const getRateLimitConfig = () => {
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
return { return {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default
max: isProduction max: isProduction
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000') // Stricter in production ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000') // Stricter in production
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000'), // More lenient in development : parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000'), // More lenient in development
// Development-specific relaxations // Development-specific relaxations
skip: (req: Request) => { skip: (req: Request) => {
// Skip all GET requests in development for easier testing // Skip all GET requests in development for easier testing
if (!isProduction && req.method === 'GET') { if (!isProduction && req.method === 'GET') {
return true; return true;
} }
return shouldSkipLimit(req); return shouldSkipLimit(req);
} }
}; };
@@ -90,8 +119,8 @@ const getRateLimitConfig = () => {
// Main API limiter - nur für POST/PUT/DELETE // Main API limiter - nur für POST/PUT/DELETE
export const apiLimiter = rateLimit({ export const apiLimiter = rateLimit({
...getRateLimitConfig(), ...getRateLimitConfig(),
message: { message: {
error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen' error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen'
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
@@ -99,8 +128,8 @@ export const apiLimiter = rateLimit({
handler: (req, res) => { handler: (req, res) => {
const clientIP = getClientIP(req); const clientIP = getClientIP(req);
console.warn(`🚨 Rate limit exceeded for IP: ${clientIP}, Path: ${req.path}, Method: ${req.method}`); console.warn(`🚨 Rate limit exceeded for IP: ${clientIP}, Path: ${req.path}, Method: ${req.method}`);
res.status(429).json({ res.status(429).json({
error: 'Zu viele Anfragen', error: 'Zu viele Anfragen',
message: 'Bitte versuchen Sie es später erneut', message: 'Bitte versuchen Sie es später erneut',
retryAfter: '15 Minuten', retryAfter: '15 Minuten',
@@ -113,8 +142,8 @@ export const apiLimiter = rateLimit({
export const authLimiter = rateLimit({ export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '100'), max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '100'),
message: { message: {
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut' error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
@@ -123,8 +152,8 @@ export const authLimiter = rateLimit({
handler: (req, res) => { handler: (req, res) => {
const clientIP = getClientIP(req); const clientIP = getClientIP(req);
console.warn(`🚨 Auth rate limit exceeded for IP: ${clientIP}`); console.warn(`🚨 Auth rate limit exceeded for IP: ${clientIP}`);
res.status(429).json({ res.status(429).json({
error: 'Zu viele Login-Versuche', error: 'Zu viele Login-Versuche',
message: 'Aus Sicherheitsgründen wurde Ihr Konto temporär gesperrt', message: 'Aus Sicherheitsgründen wurde Ihr Konto temporär gesperrt',
retryAfter: '15 Minuten' retryAfter: '15 Minuten'

View File

@@ -86,7 +86,8 @@ export async function seedTestData(): Promise<void> {
console.log('🌱 Starting test data seeding...'); console.log('🌱 Starting test data seeding...');
// Read test.json file - adjust path to be relative to project root // Read test.json file - adjust path to be relative to project root
const testDataPath = path.resolve(process.cwd(), 'test.json'); //const testDataPath = path.resolve(process.cwd(), './test.json');
const testDataPath = path.resolve(__dirname, './test.json');
console.log('🔍 Looking for test.json at:', testDataPath); console.log('🔍 Looking for test.json at:', testDataPath);
@@ -95,9 +96,10 @@ export async function seedTestData(): Promise<void> {
// Try alternative paths // Try alternative paths
const alternativePaths = [ const alternativePaths = [
path.resolve(__dirname, '../../../test.json'), //path.resolve(__dirname, '../../../test.json'),
path.resolve(process.cwd(), '../test.json'), //path.resolve(process.cwd(), '../test.json'),
path.resolve(__dirname, '../../test.json') //path.resolve(__dirname, '../../test.json'),
path.resolve(__dirname, './test.json')
]; ];
for (const altPath of alternativePaths) { for (const altPath of alternativePaths) {
@@ -136,7 +138,7 @@ export async function seedTestData(): Promise<void> {
const [firstname, lastname = ''] = name.split(' '); const [firstname, lastname = ''] = name.split(' ');
const email = generateEmail(firstname, lastname || 'Test'); const email = generateEmail(firstname, lastname || 'Test');
const passwordHash = await bcrypt.hash('test1234', 10); const passwordHash = await bcrypt.hash('ZebraAux123!', 10);
const contractType = mapContractType(testData.employee_info.contract_sizes[name]); const contractType = mapContractType(testData.employee_info.contract_sizes[name]);
const employeeType = testData.employee_info.employee_types[name]; const employeeType = testData.employee_info.employee_types[name];

View File

@@ -317,7 +317,17 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
// Convert to array and sort by start time // Convert to array and sort by start time
const sortedTimeSlots = Array.from(allTimeSlots.values()).sort((a, b) => { const sortedTimeSlots = Array.from(allTimeSlots.values()).sort((a, b) => {
return (a.startTime || '').localeCompare(b.startTime || ''); // Convert time strings to minutes for proper numeric comparison
const timeToMinutes = (timeStr: string) => {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const minutesA = timeToMinutes(a.startTime);
const minutesB = timeToMinutes(b.startTime);
return minutesA - minutesB; // Ascending order (earliest first)
}); });
return ( return (

View File

@@ -126,7 +126,7 @@ const ShiftPlanView: React.FC = () => {
useEffect(() => { useEffect(() => {
if (dropdownRef.current) { if (dropdownRef.current) {
setDropdownWidth(dropdownRef.current.offsetWidth); setDropdownWidth(dropdownRef.current.offsetWidth / 40); // Adjust divisor for desired slide distance
} }
}, [exportType]); }, [exportType]);
@@ -200,7 +200,17 @@ const ShiftPlanView: React.FC = () => {
// Convert to array and sort by start time - SAME LOGIC AS AVAILABILITYMANAGER // Convert to array and sort by start time - SAME LOGIC AS AVAILABILITYMANAGER
const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a, b) => { const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a, b) => {
return (a.startTime || '').localeCompare(b.startTime || ''); // Convert time strings to minutes for proper numeric comparison
const timeToMinutes = (timeStr: string) => {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const minutesA = timeToMinutes(a.startTime);
const minutesB = timeToMinutes(b.startTime);
return minutesA - minutesB; // Ascending order (earliest first)
}); });
return { days, shiftsByDay, allTimeSlots }; return { days, shiftsByDay, allTimeSlots };
@@ -1436,17 +1446,17 @@ const ShiftPlanView: React.FC = () => {
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
position: 'relative', justifyContent: 'flex-end',
marginLeft: '10px' marginTop: '20px',
gap: '10px'
}}> }}>
{/* Export Dropdown */} {/* Export Dropdown Container */}
<div <div
ref={dropdownRef} ref={dropdownRef}
style={{ style={{
transform: exportType ? `translateX(-${dropdownWidth}px)` : 'translateX(0)', transform: exportType ? `translateX(-${dropdownWidth}px)` : 'translateX(0)',
transition: 'transform 0.3s ease-in-out', transition: 'transform 0.3s ease-in-out',
position: exportType ? 'absolute' : 'relative', position: 'relative'
right: exportType ? `-${dropdownWidth}px` : '0'
}} }}
> >
<select <select
@@ -1467,7 +1477,7 @@ const ShiftPlanView: React.FC = () => {
</select> </select>
</div> </div>
{/* Export Button */} {/* Export Button - erscheint nur wenn eine Option ausgewählt ist */}
{exportType && ( {exportType && (
<button <button
onClick={handleExport} onClick={handleExport}
@@ -1480,7 +1490,6 @@ const ShiftPlanView: React.FC = () => {
borderRadius: '4px', borderRadius: '4px',
cursor: exporting ? 'not-allowed' : 'pointer', cursor: exporting ? 'not-allowed' : 'pointer',
fontWeight: 'bold', fontWeight: 'bold',
marginLeft: '10px',
opacity: exporting ? 0.7 : 1, opacity: exporting ? 0.7 : 1,
transition: 'opacity 0.2s ease' transition: 'opacity 0.2s ease'
}} }}