Compare commits

..

6 Commits

9 changed files with 249 additions and 270 deletions

View File

@@ -7,12 +7,13 @@ export const validateLogin = [
.isEmail() .isEmail()
.withMessage('Must be a valid email') .withMessage('Must be a valid email')
.normalizeEmail(), .normalizeEmail(),
body('password') body('password')
.isLength({ min: 6 }) .optional()
.withMessage('Password must be at least 6 characters') .isLength({ min: 8 })
.trim() .withMessage('Password must be at least 8 characters')
.escape() .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
export const validateRegister = [ export const validateRegister = [
@@ -21,18 +22,19 @@ export const validateRegister = [
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('password') body('password')
.optional()
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('Password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase and number') .withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
// ===== EMPLOYEE VALIDATION ===== // ===== EMPLOYEE VALIDATION =====
@@ -42,49 +44,49 @@ export const validateEmployee = [
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('password') body('password')
.optional() .optional()
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('Password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase and number'), .withMessage('Password must contain uppercase, lowercase, number and special character'),
body('employeeType') body('employeeType')
.isIn(['manager', 'personell', 'apprentice', 'guest']) .isIn(['manager', 'personell', 'apprentice', 'guest'])
.withMessage('Employee type must be manager, personell, apprentice or guest'), .withMessage('Employee type must be manager, personell, apprentice or guest'),
body('contractType') body('contractType')
.optional() .optional()
.isIn(['small', 'large', 'flexible']) .isIn(['small', 'large', 'flexible'])
.withMessage('Contract type must be small, large or flexible'), .withMessage('Contract type must be small, large or flexible'),
body('roles') body('roles')
.optional() .optional()
.isArray() .isArray()
.withMessage('Roles must be an array'), .withMessage('Roles must be an array'),
body('roles.*') body('roles.*')
.optional() .optional()
.isIn(['admin', 'maintenance', 'user']) .isIn(['admin', 'maintenance', 'user'])
.withMessage('Invalid role. Allowed: admin, maintenance, user'), .withMessage('Invalid role. Allowed: admin, maintenance, user'),
body('canWorkAlone') body('canWorkAlone')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('canWorkAlone must be a boolean'), .withMessage('canWorkAlone must be a boolean'),
body('isTrainee') body('isTrainee')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('isTrainee must be a boolean'), .withMessage('isTrainee must be a boolean'),
body('isActive') body('isActive')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -98,44 +100,44 @@ export const validateEmployeeUpdate = [
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.optional() .optional()
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('employeeType') body('employeeType')
.optional() .optional()
.isIn(['manager', 'personell', 'apprentice', 'guest']) .isIn(['manager', 'personell', 'apprentice', 'guest'])
.withMessage('Employee type must be manager, personell, apprentice or guest'), .withMessage('Employee type must be manager, personell, apprentice or guest'),
body('contractType') body('contractType')
.optional() .optional()
.isIn(['small', 'large', 'flexible']) .isIn(['small', 'large', 'flexible'])
.withMessage('Contract type must be small, large or flexible'), .withMessage('Contract type must be small, large or flexible'),
body('roles') body('roles')
.optional() .optional()
.isArray() .isArray()
.withMessage('Roles must be an array'), .withMessage('Roles must be an array'),
body('roles.*') body('roles.*')
.optional() .optional()
.isIn(['admin', 'maintenance', 'user']) .isIn(['admin', 'maintenance', 'user'])
.withMessage('Invalid role. Allowed: admin, maintenance, user'), .withMessage('Invalid role. Allowed: admin, maintenance, user'),
body('canWorkAlone') body('canWorkAlone')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('canWorkAlone must be a boolean'), .withMessage('canWorkAlone must be a boolean'),
body('isTrainee') body('isTrainee')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('isTrainee must be a boolean'), .withMessage('isTrainee must be a boolean'),
body('isActive') body('isActive')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -145,14 +147,15 @@ export const validateEmployeeUpdate = [
export const validateChangePassword = [ export const validateChangePassword = [
body('currentPassword') body('currentPassword')
.optional() .optional()
.isLength({ min: 6 })
.withMessage('Current password must be at least 6 characters'),
body('newPassword')
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('New password must be at least 8 characters') .withMessage('Current password must be at least 8 characters'),
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('New password must contain uppercase, lowercase and number') body('password')
.optional()
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
// ===== SHIFT PLAN VALIDATION ===== // ===== SHIFT PLAN VALIDATION =====
@@ -162,77 +165,77 @@ export const validateShiftPlan = [
.withMessage('Name must be between 1-200 characters') .withMessage('Name must be between 1-200 characters')
.trim() .trim()
.escape(), .escape(),
body('description') body('description')
.optional() .optional()
.isLength({ max: 1000 }) .isLength({ max: 1000 })
.withMessage('Description cannot exceed 1000 characters') .withMessage('Description cannot exceed 1000 characters')
.trim() .trim()
.escape(), .escape(),
body('startDate') body('startDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('endDate') body('endDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('isTemplate') body('isTemplate')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('isTemplate must be a boolean'), .withMessage('isTemplate must be a boolean'),
body('status') body('status')
.optional() .optional()
.isIn(['draft', 'published', 'archived', 'template']) .isIn(['draft', 'published', 'archived', 'template'])
.withMessage('Status must be draft, published, archived or template'), .withMessage('Status must be draft, published, archived or template'),
body('timeSlots') body('timeSlots')
.optional() .optional()
.isArray() .isArray()
.withMessage('Time slots must be an array'), .withMessage('Time slots must be an array'),
body('timeSlots.*.name') body('timeSlots.*.name')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Time slot name must be between 1-100 characters') .withMessage('Time slot name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('timeSlots.*.startTime') body('timeSlots.*.startTime')
.matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/)
.withMessage('Start time must be in HH:MM format'), .withMessage('Start time must be in HH:MM format'),
body('timeSlots.*.endTime') body('timeSlots.*.endTime')
.matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/)
.withMessage('End time must be in HH:MM format'), .withMessage('End time must be in HH:MM format'),
body('timeSlots.*.description') body('timeSlots.*.description')
.optional() .optional()
.isLength({ max: 500 }) .isLength({ max: 500 })
.withMessage('Time slot description cannot exceed 500 characters') .withMessage('Time slot description cannot exceed 500 characters')
.trim() .trim()
.escape(), .escape(),
body('shifts') body('shifts')
.optional() .optional()
.isArray() .isArray()
.withMessage('Shifts must be an array'), .withMessage('Shifts must be an array'),
body('shifts.*.dayOfWeek') body('shifts.*.dayOfWeek')
.isInt({ min: 1, max: 7 }) .isInt({ min: 1, max: 7 })
.withMessage('Day of week must be between 1-7 (Monday-Sunday)'), .withMessage('Day of week must be between 1-7 (Monday-Sunday)'),
body('shifts.*.timeSlotId') body('shifts.*.timeSlotId')
.isUUID() .isUUID()
.withMessage('Time slot ID must be a valid UUID'), .withMessage('Time slot ID must be a valid UUID'),
body('shifts.*.requiredEmployees') body('shifts.*.requiredEmployees')
.isInt({ min: 0 }) .isInt({ min: 0 })
.withMessage('Required employees must be a positive integer'), .withMessage('Required employees must be a positive integer'),
body('shifts.*.color') body('shifts.*.color')
.optional() .optional()
.isHexColor() .isHexColor()
@@ -246,34 +249,34 @@ export const validateShiftPlanUpdate = [
.withMessage('Name must be between 1-200 characters') .withMessage('Name must be between 1-200 characters')
.trim() .trim()
.escape(), .escape(),
body('description') body('description')
.optional() .optional()
.isLength({ max: 1000 }) .isLength({ max: 1000 })
.withMessage('Description cannot exceed 1000 characters') .withMessage('Description cannot exceed 1000 characters')
.trim() .trim()
.escape(), .escape(),
body('startDate') body('startDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('endDate') body('endDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('status') body('status')
.optional() .optional()
.isIn(['draft', 'published', 'archived', 'template']) .isIn(['draft', 'published', 'archived', 'template'])
.withMessage('Status must be draft, published, archived or template'), .withMessage('Status must be draft, published, archived or template'),
body('timeSlots') body('timeSlots')
.optional() .optional()
.isArray() .isArray()
.withMessage('Time slots must be an array'), .withMessage('Time slots must be an array'),
body('shifts') body('shifts')
.optional() .optional()
.isArray() .isArray()
@@ -284,25 +287,25 @@ export const validateCreateFromPreset = [
body('presetName') body('presetName')
.isLength({ min: 1 }) .isLength({ min: 1 })
.withMessage('Preset name is required') .withMessage('Preset name is required')
.isIn(['standardWeek', 'extendedWeek', 'weekendFocused', 'morningOnly', 'eveningOnly', 'ZEBRA_STANDARD']) .isIn(['GENERAL_STANDARD', 'ZEBRA_STANDARD'])
.withMessage('Invalid preset name'), .withMessage('Invalid preset name'),
body('name') body('name')
.isLength({ min: 1, max: 200 }) .isLength({ min: 1, max: 200 })
.withMessage('Name must be between 1-200 characters') .withMessage('Name must be between 1-200 characters')
.trim() .trim()
.escape(), .escape(),
body('startDate') body('startDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('endDate') body('endDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('isTemplate') body('isTemplate')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -314,11 +317,11 @@ export const validateScheduledShiftUpdate = [
body('assignedEmployees') body('assignedEmployees')
.isArray() .isArray()
.withMessage('assignedEmployees must be an array'), .withMessage('assignedEmployees must be an array'),
body('assignedEmployees.*') body('assignedEmployees.*')
.isUUID() .isUUID()
.withMessage('Each assigned employee must be a valid UUID'), .withMessage('Each assigned employee must be a valid UUID'),
body('requiredEmployees') body('requiredEmployees')
.optional() .optional()
.isInt({ min: 0 }) .isInt({ min: 0 })
@@ -332,18 +335,19 @@ export const validateSetupAdmin = [
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('password') body('password')
.optional()
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('Password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase and number') .withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
// ===== SCHEDULING VALIDATION ===== // ===== SCHEDULING VALIDATION =====
@@ -351,23 +355,23 @@ export const validateSchedulingRequest = [
body('shiftPlan') body('shiftPlan')
.isObject() .isObject()
.withMessage('Shift plan is required'), .withMessage('Shift plan is required'),
body('shiftPlan.id') body('shiftPlan.id')
.isUUID() .isUUID()
.withMessage('Shift plan ID must be a valid UUID'), .withMessage('Shift plan ID must be a valid UUID'),
body('employees') body('employees')
.isArray({ min: 1 }) .isArray({ min: 1 })
.withMessage('At least one employee is required'), .withMessage('At least one employee is required'),
body('employees.*.id') body('employees.*.id')
.isUUID() .isUUID()
.withMessage('Each employee must have a valid UUID'), .withMessage('Each employee must have a valid UUID'),
body('availabilities') body('availabilities')
.isArray() .isArray()
.withMessage('Availabilities must be an array'), .withMessage('Availabilities must be an array'),
body('constraints') body('constraints')
.optional() .optional()
.isArray() .isArray()
@@ -379,19 +383,19 @@ export const validateAvailabilities = [
body('planId') body('planId')
.isUUID() .isUUID()
.withMessage('Plan ID must be a valid UUID'), .withMessage('Plan ID must be a valid UUID'),
body('availabilities') body('availabilities')
.isArray() .isArray()
.withMessage('Availabilities must be an array'), .withMessage('Availabilities must be an array'),
body('availabilities.*.shiftId') body('availabilities.*.shiftId')
.isUUID() .isUUID()
.withMessage('Each shift ID must be a valid UUID'), .withMessage('Each shift ID must be a valid UUID'),
body('availabilities.*.preferenceLevel') body('availabilities.*.preferenceLevel')
.isInt({ min: 0, max: 2 }) .isInt({ min: 0, max: 2 })
.withMessage('Preference level must be 0 (unavailable), 1 (available), or 2 (preferred)'), .withMessage('Preference level must be 0 (unavailable), 1 (available), or 2 (preferred)'),
body('availabilities.*.notes') body('availabilities.*.notes')
.optional() .optional()
.isLength({ max: 500 }) .isLength({ max: 500 })
@@ -424,12 +428,12 @@ export const validatePagination = [
.optional() .optional()
.isInt({ min: 1 }) .isInt({ min: 1 })
.withMessage('Page must be a positive integer'), .withMessage('Page must be a positive integer'),
query('limit') query('limit')
.optional() .optional()
.isInt({ min: 1, max: 100 }) .isInt({ min: 1, max: 100 })
.withMessage('Limit must be between 1-100'), .withMessage('Limit must be between 1-100'),
query('includeInactive') query('includeInactive')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -439,19 +443,19 @@ export const validatePagination = [
// ===== MIDDLEWARE TO CHECK VALIDATION RESULTS ===== // ===== MIDDLEWARE TO CHECK VALIDATION RESULTS =====
export const handleValidationErrors = (req: Request, res: Response, next: NextFunction) => { export const handleValidationErrors = (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
const errorMessages = errors.array().map(error => ({ const errorMessages = errors.array().map(error => ({
field: error.type === 'field' ? error.path : error.type, field: error.type === 'field' ? error.path : error.type,
message: error.msg, message: error.msg,
value: error.msg value: error.msg
})); }));
return res.status(400).json({ return res.status(400).json({
error: 'Validation failed', error: 'Validation failed',
details: errorMessages details: errorMessages
}); });
} }
next(); next();
}; };

View File

@@ -22,6 +22,8 @@ const app = express();
const PORT = 3002; const PORT = 3002;
const isDevelopment = process.env.NODE_ENV === 'development'; const isDevelopment = process.env.NODE_ENV === 'development';
app.set('trust proxy', true);
// Security configuration // Security configuration
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
console.info('Checking for JWT_SECRET'); console.info('Checking for JWT_SECRET');
@@ -34,14 +36,20 @@ if (process.env.NODE_ENV === 'production') {
// Security headers // Security headers
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: isDevelopment ? false : { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"], imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
}, },
}, },
hsts: false,
crossOriginEmbedderPolicy: false crossOriginEmbedderPolicy: false
})); }));

View File

@@ -9,41 +9,42 @@ generate_secret() {
tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length
} }
# Prüfe ob .env existiert, falls nicht erstelle sie # Prüfe ob .env existiert
if [ ! -f /app/.env ]; then if [ ! -f /app/.env ]; then
echo "📝 Erstelle .env Datei..." echo "📝 Erstelle .env Datei..."
# Generiere automatisch ein sicheres JWT Secret falls nicht gesetzt # Verwende vorhandenes JWT_SECRET oder generiere ein neues
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-secret-key-please-change" ]; then if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-secret-key-please-change" ]; then
export JWT_SECRET=$(generate_secret 64) export JWT_SECRET=$(generate_secret 64)
echo "🔑 Automatisch generiertes JWT Secret wurde erstellt" echo "🔑 Automatisch sicheres JWT Secret generiert"
else
echo "🔑 Verwende vorhandenes JWT Secret aus Umgebungsvariable"
fi fi
# Erstelle .env aus Template # Erstelle .env aus Template mit envsubst
envsubst < /app/.env.template > /app/.env envsubst < /app/.env.template > /app/.env
echo "✅ .env Datei erstellt"
# Logge die ersten Zeilen (ohne Secrets)
echo "✅ .env Datei erstellt mit folgenden Einstellungen:"
head -n 5 /app/.env
else else
echo " .env Datei existiert bereits" echo " .env Datei existiert bereits"
# Validiere bestehende .env Datei # Wenn .env existiert, aber JWT_SECRET Umgebungsvariable gesetzt ist, aktualisiere sie
if ! grep -q "JWT_SECRET=" /app/.env; then if [ -n "$JWT_SECRET" ] && [ "$JWT_SECRET" != "your-secret-key-please-change" ]; then
echo "❌ Fehler: JWT_SECRET nicht in .env gefunden" echo "🔑 Aktualisiere JWT Secret in .env Datei"
exit 1 # Aktualisiere nur das JWT_SECRET in der .env Datei
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" /app/.env
fi fi
fi fi
# Sicherheitsüberprüfungen # Validiere dass JWT_SECERT nicht der Standardwert ist
if grep -q "your-secret-key" /app/.env; then if grep -q "JWT_SECRET=your-secret-key-please-change" /app/.env; then
echo "❌ FEHLER: Standard JWT Secret in .env gefunden - bitte ändern!" echo "❌ FEHLER: Standard JWT Secret in .env gefunden!"
echo "❌ Bitte setzen Sie JWT_SECRET Umgebungsvariable"
exit 1 exit 1
fi fi
# Setze sichere Berechtigungen # Setze sichere Berechtigungen
chmod 600 /app/.env chmod 600 /app/.env
chown -R schichtplaner:nodejs /app
echo "🔧 Starte Anwendung..." echo "🔧 Starte Anwendung..."
exec "$@" exec "$@"

View File

@@ -1,4 +1,4 @@
// frontend/src/pages/Features/Features.tsx // frontend/src/components/Layou/FooterLinks/Features/Features.tsx
import React from 'react'; import React from 'react';
const Features: React.FC = () => { const Features: React.FC = () => {

View File

@@ -20,7 +20,7 @@ interface AuthContextType {
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
interface AuthProviderProps { interface AuthProviderProps {
children: ReactNode; children: ReactNode;
@@ -66,7 +66,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
console.log('🔄 Refreshing user, token exists:', !!token); console.log('🔄 Refreshing user, token exists:', !!token);
if (!token) { if (!token) {
console.log(' No token found, user not logged in'); console.log(' No token found, user not logged in');
setUser(null); setUser(null);
@@ -104,7 +104,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const login = async (credentials: LoginRequest): Promise<void> => { const login = async (credentials: LoginRequest): Promise<void> => {
try { try {
console.log('🔐 Attempting login for:', credentials.email); console.log('🔐 Attempting login for:', credentials.email);
const response = await fetch(`${API_BASE_URL}/auth/login`, { const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -120,7 +120,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const data = await response.json(); const data = await response.json();
console.log('✅ Login successful, storing token'); console.log('✅ Login successful, storing token');
setStoredToken(data.token); setStoredToken(data.token);
setUser(data.user); setUser(data.user);
} catch (error) { } catch (error) {
@@ -137,13 +137,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const hasRole = (roles: string[]): boolean => { const hasRole = (roles: string[]): boolean => {
if (!user || !user.roles || user.roles.length === 0) return false; if (!user || !user.roles || user.roles.length === 0) return false;
// Check if user has at least one of the required roles // Check if user has at least one of the required roles
return roles.some(requiredRole => return roles.some(requiredRole =>
user.roles!.includes(requiredRole) user.roles!.includes(requiredRole)
); );
}; };
useEffect(() => { useEffect(() => {
const initializeAuth = async () => { const initializeAuth = async () => {
console.log('🚀 Initializing authentication...'); console.log('🚀 Initializing authentication...');

View File

@@ -107,7 +107,7 @@
.createButton { .createButton {
padding: 10px 20px; padding: 10px 20px;
background-color: #2ecc71; background-color: #51258f;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
@@ -116,7 +116,7 @@
} }
.createButton:hover { .createButton:hover {
background-color: #27ae60; background-color: #51258f;
} }
.createButton:disabled { .createButton:disabled {

View File

@@ -1,6 +1,6 @@
// frontend/src/services/authService.ts // frontend/src/services/authService.ts
import { Employee } from '../models/Employee'; import { Employee } from '../models/Employee';
const API_BASE = process.env.REACT_APP_API_BASE_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
@@ -24,7 +24,7 @@ class AuthService {
private token: string | null = null; private token: string | null = null;
async login(credentials: LoginRequest): Promise<AuthResponse> { async login(credentials: LoginRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/auth/login`, { const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
@@ -39,12 +39,11 @@ class AuthService {
this.token = data.token; this.token = data.token;
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
localStorage.setItem('employee', JSON.stringify(data.employee)); localStorage.setItem('employee', JSON.stringify(data.employee));
return data; return data;
} }
async register(userData: RegisterRequest): Promise<AuthResponse> { async register(userData: RegisterRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/employees`, { const response = await fetch(`${API_BASE_URL}/employees`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -73,7 +72,7 @@ class AuthService {
} }
try { try {
const response = await fetch(`${API_BASE}/auth/me`, { const response = await fetch(`${API_BASE_URL}/auth/me`, {
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }

View File

@@ -1,166 +1,59 @@
// vite.config.ts
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { resolve } from 'path' import { resolve } from 'path'
// Security-focused Vite configuration
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const isProduction = mode === 'production' const isProduction = mode === 'production'
const isDevelopment = mode === 'development' const isDevelopment = mode === 'development'
// Load environment variables securely
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
// Strictly defined client-safe environment variables // 🆕 WICHTIG: Relative Pfade für Production
const clientEnv = { const clientEnv = {
NODE_ENV: mode, NODE_ENV: mode,
ENABLE_PRO: env.ENABLE_PRO || 'false', ENABLE_PRO: env.ENABLE_PRO || 'false',
VITE_APP_TITLE: env.APP_TITLE || 'Shift Planning App', VITE_APP_TITLE: env.APP_TITLE || 'Shift Planning App',
VITE_API_URL: isProduction ? '/api' : 'http://localhost:3002/api', VITE_API_URL: isProduction ? '/api' : '/api',
} }
return { return {
plugins: [ plugins: [react()],
react({
// React specific security settings
jsxRuntime: 'automatic',
babel: {
plugins: [
// Remove console in production
isProduction && ['babel-plugin-transform-remove-console', { exclude: ['error', 'warn'] }]
].filter(Boolean)
}
})
],
server: { server: {
port: 3003, port: 3003,
host: true, host: true,
open: isDevelopment, open: isDevelopment,
// Security headers for dev server
headers: {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), location=()'
},
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3002', target: 'http://localhost:3002',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
} }
}, }
// Security: disable HMR in non-dev environments
hmr: isDevelopment
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
// Security: No source maps in production sourcemap: isDevelopment,
sourcemap: isDevelopment ? 'inline' : false, base: isProduction ? '/' : '/',
// Generate deterministic hashes for better caching and security
assetsDir: 'assets',
rollupOptions: { rollupOptions: {
output: { output: {
// Security: Use content hashes for cache busting and integrity
chunkFileNames: 'assets/[name]-[hash].js', chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js', entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]', assetFileNames: 'assets/[name]-[hash].[ext]',
// Security: Manual chunks to separate vendor code
manualChunks: (id) => {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react'
}
if (id.includes('react-router-dom')) {
return 'vendor-router'
}
return 'vendor'
}
}
} }
}, },
// Minification with security-focused settings
minify: isProduction ? 'terser' : false, minify: isProduction ? 'terser' : false,
terserOptions: isProduction ? { terserOptions: isProduction ? {
compress: { compress: {
drop_console: true, drop_console: true,
drop_debugger: true, drop_debugger: true,
// Security: Remove potentially sensitive code pure_funcs: ['console.log', 'console.debug', 'console.info']
pure_funcs: [
'console.log',
'console.info',
'console.debug',
'console.warn',
'console.trace',
'console.table',
'debugger'
],
dead_code: true,
if_return: true,
comparisons: true,
loops: true,
hoist_funs: true,
hoist_vars: true,
reduce_vars: true,
booleans: true,
conditionals: true,
evaluate: true,
sequences: true,
unused: true
},
mangle: {
// Security: Obfuscate code
toplevel: true,
keep_classnames: false,
keep_fnames: false,
reserved: [
'React',
'ReactDOM',
'useState',
'useEffect',
'useContext',
'createElement'
]
},
format: {
comments: false,
beautify: false,
// Security: ASCII only to prevent encoding attacks
ascii_only: true
} }
} : undefined, } : undefined,
// Security: Report bundle size issues
reportCompressedSize: true,
chunkSizeWarningLimit: 1000,
// Security: Don't expose source paths
assetsInlineLimit: 4096
}, },
preview: {
port: 3004,
headers: {
// Security headers for preview server
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': `
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`.replace(/\s+/g, ' ').trim()
}
},
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
@@ -173,31 +66,10 @@ export default defineConfig(({ mode }) => {
'@/design': resolve(__dirname, './src/design') '@/design': resolve(__dirname, './src/design')
} }
}, },
// ✅ SICHER: Strict environment variable control
define: Object.keys(clientEnv).reduce((acc, key) => { define: Object.keys(clientEnv).reduce((acc, key) => {
acc[`import.meta.env.${key}`] = JSON.stringify(clientEnv[key]) acc[`import.meta.env.${key}`] = JSON.stringify(clientEnv[key])
return acc return acc
}, {} as Record<string, string>), }, {} as Record<string, string>)
// Security: Clear build directory
emptyOutDir: true,
// Security: Optimize dependencies
optimizeDeps: {
include: ['react', 'react-dom', 'react-router-dom'],
exclude: ['@vitejs/plugin-react']
},
// Security: CSS configuration
css: {
devSourcemap: isDevelopment,
modules: {
localsConvention: 'camelCase',
generateScopedName: isProduction
? '[hash:base64:8]'
: '[name]__[local]--[hash:base64:5]'
}
}
} }
}) })

125
package-lock.json generated
View File

@@ -280,6 +280,7 @@
"backend/node_modules/@types/node": { "backend/node_modules/@types/node": {
"version": "24.7.0", "version": "24.7.0",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.14.0" "undici-types": "~7.14.0"
} }
@@ -349,17 +350,6 @@
"license": "ISC", "license": "ISC",
"optional": true "optional": true
}, },
"backend/node_modules/acorn": {
"version": "8.15.0",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"backend/node_modules/acorn-walk": { "backend/node_modules/acorn-walk": {
"version": "8.3.4", "version": "8.3.4",
"dev": true, "dev": true,
@@ -2038,6 +2028,7 @@
"frontend": { "frontend": {
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"date-fns": "4.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
@@ -2048,7 +2039,9 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"babel-plugin-transform-remove-console": "6.9.4",
"esbuild": "^0.21.0", "esbuild": "^0.21.0",
"terser": "5.44.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
@@ -2078,6 +2071,7 @@
"version": "7.28.5", "version": "7.28.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -2338,6 +2332,17 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/source-map": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"dev": true, "dev": true,
@@ -2388,10 +2393,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@schichtenplaner/premium": {
"resolved": "premium",
"link": true
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"dev": true, "dev": true,
@@ -2443,6 +2444,7 @@
"version": "20.19.23", "version": "20.19.23",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2451,6 +2453,7 @@
"version": "19.2.2", "version": "19.2.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -2514,12 +2517,32 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/babel-plugin-transform-remove-console": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz",
"integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==",
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.20", "version": "2.8.20",
"dev": true, "dev": true,
@@ -2585,6 +2608,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@@ -2599,6 +2623,13 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2656,6 +2687,13 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2702,6 +2740,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"devOptional": true, "devOptional": true,
@@ -3368,6 +3416,7 @@
"version": "4.0.3", "version": "4.0.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3457,6 +3506,7 @@
"node_modules/react": { "node_modules/react": {
"version": "19.2.0", "version": "19.2.0",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3464,6 +3514,7 @@
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.0", "version": "19.2.0",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -3730,6 +3781,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"dev": true, "dev": true,
@@ -3738,6 +3799,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -3747,6 +3819,26 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/terser": {
"version": "5.44.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"dev": true, "dev": true,
@@ -3788,6 +3880,7 @@
"version": "5.9.3", "version": "5.9.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3870,6 +3963,7 @@
"version": "6.4.1", "version": "6.4.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -4002,6 +4096,7 @@
"premium": { "premium": {
"name": "@schichtenplaner/premium", "name": "@schichtenplaner/premium",
"version": "1.0.0", "version": "1.0.0",
"extraneous": true,
"workspaces": [ "workspaces": [
"backendPRO", "backendPRO",
"frontendPRO" "frontendPRO"