mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 15:05:45 +01:00
Compare commits
4 Commits
feature/de
...
v1.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d836d037 | |||
| 99d5105768 | |||
| a8dc11b024 | |||
| 0473a3b5bf |
@@ -29,7 +29,7 @@
|
||||
"helmet": "8.1.0",
|
||||
"express-validator": "7.3.0",
|
||||
"exceljs": "4.4.0",
|
||||
"playwright": "^1.37.0"
|
||||
"playwright-chromium": "^1.37.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { chromium } from 'playwright';
|
||||
import { chromium } from 'playwright-chromium';
|
||||
|
||||
async function getPlanWithDetails(planId: string) {
|
||||
const plan = await db.get<any>(`
|
||||
@@ -989,6 +989,20 @@ interface ExportTimetableData {
|
||||
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 {
|
||||
const weekdays = [
|
||||
{ id: 1, name: 'Montag' },
|
||||
@@ -1032,9 +1046,16 @@ function getTimetableDataForExport(plan: any): ExportTimetableData {
|
||||
Object.keys(shiftsByDay).forEach(day => {
|
||||
const dayNum = parseInt(day);
|
||||
shiftsByDay[dayNum].sort((a: any, b: any) => {
|
||||
const timeA = a.startTime || '';
|
||||
const timeB = b.startTime || '';
|
||||
return timeA.localeCompare(timeB);
|
||||
// Use numeric comparison for proper time sorting
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1073,15 +1094,12 @@ function getTimetableDataForExport(plan: any): ExportTimetableData {
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by start time
|
||||
const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a: ExportTimeSlot, b: ExportTimeSlot) => {
|
||||
return (a.startTime || '').localeCompare(b.startTime || '');
|
||||
});
|
||||
// Convert to array and sort by start time using numeric comparison
|
||||
const allTimeSlots = sortTimeSlotsByStartTime(Array.from(allTimeSlotsMap.values()));
|
||||
|
||||
return { days, allTimeSlots };
|
||||
}
|
||||
|
||||
// Export shift plan to Excel
|
||||
// Export shift plan to Excel
|
||||
export const exportShiftPlanToExcel = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Request } from 'express';
|
||||
const getClientIP = (req: Request): string => {
|
||||
// Read from environment which header to trust
|
||||
const trustedHeader = process.env.TRUSTED_PROXY_HEADER || 'x-forwarded-for';
|
||||
|
||||
|
||||
const forwarded = req.headers[trustedHeader];
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
const cfConnectingIp = req.headers['cf-connecting-ip']; // Cloudflare
|
||||
|
||||
|
||||
// If we have a forwarded header and trust proxy is configured
|
||||
if (forwarded) {
|
||||
if (Array.isArray(forwarded)) {
|
||||
@@ -22,66 +22,96 @@ const getClientIP = (req: Request): string => {
|
||||
return firstIP;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cloudflare support
|
||||
if (cfConnectingIp) {
|
||||
console.log(`🔍 Using Cloudflare IP: ${cfConnectingIp}`);
|
||||
return cfConnectingIp.toString();
|
||||
}
|
||||
|
||||
|
||||
// Fallback to x-real-ip
|
||||
if (realIp) {
|
||||
console.log(`🔍 Using x-real-ip: ${realIp}`);
|
||||
return realIp.toString();
|
||||
}
|
||||
|
||||
|
||||
// Final fallback to connection remote address
|
||||
const remoteAddress = req.socket.remoteAddress || req.ip || 'unknown';
|
||||
console.log(`🔍 Using remote address: ${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
|
||||
const shouldSkipLimit = (req: Request): boolean => {
|
||||
const skipPaths = [
|
||||
'/api/health',
|
||||
'/api/health',
|
||||
'/api/setup/status',
|
||||
'/api/auth/validate'
|
||||
'/api/auth/validate',
|
||||
'/api/auth/me',
|
||||
];
|
||||
|
||||
|
||||
// Skip for successful GET requests (data fetching)
|
||||
if (req.method === 'GET' && req.path.startsWith('/api/')) {
|
||||
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
|
||||
const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || [];
|
||||
const clientIP = getClientIP(req);
|
||||
if (whitelist.includes(clientIP)) {
|
||||
console.log(`✅ IP whitelisted: ${clientIP}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return skipPaths.includes(req.path);
|
||||
};
|
||||
|
||||
// Environment-based configuration
|
||||
const getRateLimitConfig = () => {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
|
||||
return {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default
|
||||
max: isProduction
|
||||
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000') // Stricter in production
|
||||
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000'), // More lenient in development
|
||||
|
||||
max: isProduction
|
||||
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '50') // Stricter in production
|
||||
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'), // More lenient in development
|
||||
|
||||
// Development-specific relaxations
|
||||
skip: (req: Request) => {
|
||||
// Skip all GET requests in development for easier testing
|
||||
if (!isProduction && req.method === 'GET') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return shouldSkipLimit(req);
|
||||
}
|
||||
};
|
||||
@@ -90,8 +120,8 @@ const getRateLimitConfig = () => {
|
||||
// Main API limiter - nur für POST/PUT/DELETE
|
||||
export const apiLimiter = rateLimit({
|
||||
...getRateLimitConfig(),
|
||||
message: {
|
||||
error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen'
|
||||
message: {
|
||||
error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
@@ -99,8 +129,8 @@ export const apiLimiter = rateLimit({
|
||||
handler: (req, res) => {
|
||||
const clientIP = getClientIP(req);
|
||||
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',
|
||||
message: 'Bitte versuchen Sie es später erneut',
|
||||
retryAfter: '15 Minuten',
|
||||
@@ -112,9 +142,9 @@ export const apiLimiter = rateLimit({
|
||||
// Strict limiter for auth endpoints
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '100'),
|
||||
message: {
|
||||
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '50'),
|
||||
message: {
|
||||
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
@@ -123,8 +153,8 @@ export const authLimiter = rateLimit({
|
||||
handler: (req, res) => {
|
||||
const clientIP = getClientIP(req);
|
||||
console.warn(`🚨 Auth rate limit exceeded for IP: ${clientIP}`);
|
||||
|
||||
res.status(429).json({
|
||||
|
||||
res.status(429).json({
|
||||
error: 'Zu viele Login-Versuche',
|
||||
message: 'Aus Sicherheitsgründen wurde Ihr Konto temporär gesperrt',
|
||||
retryAfter: '15 Minuten'
|
||||
@@ -135,7 +165,7 @@ export const authLimiter = rateLimit({
|
||||
// Separate limiter for expensive endpoints
|
||||
export const expensiveEndpointLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '100'),
|
||||
max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '20'),
|
||||
message: {
|
||||
error: 'Zu viele Anfragen für diese Ressource'
|
||||
},
|
||||
|
||||
@@ -86,7 +86,8 @@ export async function seedTestData(): Promise<void> {
|
||||
console.log('🌱 Starting test data seeding...');
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -95,9 +96,10 @@ export async function seedTestData(): Promise<void> {
|
||||
|
||||
// Try alternative paths
|
||||
const alternativePaths = [
|
||||
path.resolve(__dirname, '../../../test.json'),
|
||||
path.resolve(process.cwd(), '../test.json'),
|
||||
path.resolve(__dirname, '../../test.json')
|
||||
//path.resolve(__dirname, '../../../test.json'),
|
||||
//path.resolve(process.cwd(), '../test.json'),
|
||||
//path.resolve(__dirname, '../../test.json'),
|
||||
path.resolve(__dirname, './test.json')
|
||||
];
|
||||
|
||||
for (const altPath of alternativePaths) {
|
||||
@@ -136,7 +138,7 @@ export async function seedTestData(): Promise<void> {
|
||||
|
||||
const [firstname, lastname = ''] = name.split(' ');
|
||||
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 employeeType = testData.employee_info.employee_types[name];
|
||||
|
||||
@@ -317,7 +317,17 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
|
||||
// Convert to array and sort by start time
|
||||
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 (
|
||||
|
||||
@@ -126,7 +126,7 @@ const ShiftPlanView: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (dropdownRef.current) {
|
||||
setDropdownWidth(dropdownRef.current.offsetWidth);
|
||||
setDropdownWidth(dropdownRef.current.offsetWidth / 40); // Adjust divisor for desired slide distance
|
||||
}
|
||||
}, [exportType]);
|
||||
|
||||
@@ -200,7 +200,17 @@ const ShiftPlanView: React.FC = () => {
|
||||
|
||||
// Convert to array and sort by start time - SAME LOGIC AS AVAILABILITYMANAGER
|
||||
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 };
|
||||
@@ -1436,17 +1446,17 @@ const ShiftPlanView: React.FC = () => {
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
marginLeft: '10px'
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: '20px',
|
||||
gap: '10px'
|
||||
}}>
|
||||
{/* Export Dropdown */}
|
||||
{/* Export Dropdown Container */}
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
transform: exportType ? `translateX(-${dropdownWidth}px)` : 'translateX(0)',
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
position: exportType ? 'absolute' : 'relative',
|
||||
right: exportType ? `-${dropdownWidth}px` : '0'
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<select
|
||||
@@ -1467,7 +1477,7 @@ const ShiftPlanView: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
{/* Export Button - erscheint nur wenn eine Option ausgewählt ist */}
|
||||
{exportType && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
@@ -1480,7 +1490,6 @@ const ShiftPlanView: React.FC = () => {
|
||||
borderRadius: '4px',
|
||||
cursor: exporting ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 'bold',
|
||||
marginLeft: '10px',
|
||||
opacity: exporting ? 0.7 : 1,
|
||||
transition: 'opacity 0.2s ease'
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user