diff --git a/backend/src/index.js b/backend/src/index.js
index c018614..90fdd38 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -32,6 +32,7 @@ app.use(
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
+ console.error(`CORS rejected origin: ${origin} (allowed: ${allowedOrigins.join(', ')})`);
callback(new Error('Not allowed by CORS'));
}
},
@@ -39,10 +40,10 @@ app.use(
})
);
-// Global rate limiting (100 requests per 15 minutes per IP)
+// Global rate limiting (300 requests per 15 minutes per IP)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
- max: 100,
+ max: 300,
message: { error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index 9fabd48..f831926 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -9,8 +9,12 @@ const {
createHomeownerSchema,
updateHomeownerSchema,
reportQuerySchema,
+ adminTimesheetQuerySchema,
+ adminOvertimeQuerySchema,
+ adminReportPdfQuerySchema,
validateBody,
validateQuery,
+ validateIdParam,
} = require('../utils/validation');
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
@@ -24,9 +28,9 @@ router.use(authenticate, requireAdmin);
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
-router.get('/timesheets', async (req, res) => {
+router.get('/timesheets', validateQuery(adminTimesheetQuerySchema), async (req, res) => {
try {
- const { status, userId, page = '1', limit = '50' } = req.query;
+ const { status, userId, page = '1', limit = '50' } = req.validatedQuery;
const where = {};
if (status) where.status = status;
@@ -90,7 +94,7 @@ router.get('/timesheets', async (req, res) => {
});
// ─────────────── GET /api/admin/timesheets/:id ───────────────
-router.get('/timesheets/:id', async (req, res) => {
+router.get('/timesheets/:id', validateIdParam, async (req, res) => {
try {
const { id } = req.params;
@@ -196,6 +200,7 @@ router.post('/timesheets/bulk-reject', validateBody(bulkOperationSchema), async
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
router.put(
'/timesheets/:id/approve',
+ validateIdParam,
validateBody(approveRejectSchema),
async (req, res) => {
try {
@@ -243,6 +248,7 @@ router.put(
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
router.put(
'/timesheets/:id/reject',
+ validateIdParam,
validateBody(approveRejectSchema),
async (req, res) => {
try {
@@ -287,7 +293,7 @@ router.put(
);
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
-router.put('/timesheets/:id/reopen', async (req, res) => {
+router.put('/timesheets/:id/reopen', validateIdParam, async (req, res) => {
try {
const { id } = req.params;
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
@@ -388,7 +394,7 @@ router.post('/users', validateBody(createUserSchema), async (req, res) => {
});
// ─────────────── PUT /api/admin/users/:id ───────────────
-router.put('/users/:id', validateBody(updateUserSchema), async (req, res) => {
+router.put('/users/:id', validateIdParam, validateBody(updateUserSchema), async (req, res) => {
try {
const { id } = req.params;
const { name, role, isActive, password } = req.validated;
@@ -432,7 +438,7 @@ router.put('/users/:id', validateBody(updateUserSchema), async (req, res) => {
// ═══════════════════════════════════════════════════════════════
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
-router.delete('/users/:id', async (req, res) => {
+router.delete('/users/:id', validateIdParam, async (req, res) => {
try {
const { id } = req.params;
if (id === req.user.id) {
@@ -517,6 +523,7 @@ router.post(
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
router.put(
'/homeowners/:id',
+ validateIdParam,
validateBody(updateHomeownerSchema),
async (req, res) => {
try {
@@ -691,13 +698,9 @@ router.get('/reports', validateQuery(reportQuerySchema), async (req, res) => {
});
// ─────────────── GET /api/admin/reports/pdf ───────────────
-router.get('/reports/pdf', async (req, res) => {
+router.get('/reports/pdf', validateQuery(adminReportPdfQuerySchema), async (req, res) => {
try {
- const { userId, weekStart } = req.query;
-
- if (!userId || !weekStart) {
- return res.status(400).json({ error: 'userId and weekStart are required' });
- }
+ const { userId, weekStart } = req.validatedQuery;
const user = await req.prisma.user.findUnique({
where: { id: userId },
@@ -757,11 +760,11 @@ router.get('/reports/pdf', async (req, res) => {
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/overtime ───────────────
-router.get('/overtime', async (req, res) => {
+router.get('/overtime', validateQuery(adminOvertimeQuerySchema), async (req, res) => {
try {
- const { from, to, userId } = req.query;
- const weeklyThreshold = parseFloat(req.query.threshold || '40');
- const overtimeRate = parseFloat(req.query.rate || '1.5');
+ const { from, to, userId } = req.validatedQuery;
+ const weeklyThreshold = parseFloat(req.validatedQuery.threshold || '40');
+ const overtimeRate = parseFloat(req.validatedQuery.rate || '1.5');
const where = {};
if (userId) where.userId = userId;
@@ -853,4 +856,27 @@ router.get('/overtime', async (req, res) => {
// ─────────────── GET /api/timesheets/overtime ───────────────
// (mounted at /api/timesheets/overtime in timesheets router)
+// ─────────────── PUT /api/admin/users/:id/reset-password ───────────────
+router.put('/users/:id/reset-password', validateIdParam, async (req, res) => {
+ try {
+ const { newPassword } = req.body;
+ if (!newPassword || newPassword.length < 8) {
+ return res.status(400).json({ error: 'New password must be at least 8 characters' });
+ }
+
+ const bcrypt = require('bcryptjs');
+ const hash = await bcrypt.hash(newPassword, 10);
+ const user = await req.prisma.user.update({
+ where: { id: req.params.id },
+ data: { passwordHash: hash, refreshToken: null },
+ select: { id: true, email: true, name: true },
+ });
+
+ res.json({ message: `Password reset for ${user.name}`, user });
+ } catch (err) {
+ console.error('Reset password error:', err);
+ res.status(500).json({ error: 'Failed to reset password' });
+ }
+});
+
module.exports = router;
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 2c2aac1..9b5ccaf 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -18,10 +18,10 @@ const {
const router = express.Router();
-// Rate limit: 5 attempts per 15 minutes on auth endpoints
+// Rate limit: 10 attempts per 15 minutes on auth endpoints (login/register)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
- max: 50,
+ max: 10,
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
@@ -119,8 +119,18 @@ router.post(
}
);
+// Rate limit refresh: 30 per 15 minutes (higher than login since auto-refresh is normal)
+const refreshLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 30,
+ message: { error: 'Too many refresh attempts. Please login again.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => req.ip,
+});
+
// ─────────────── POST /api/auth/refresh ───────────────
-router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
+router.post('/refresh', refreshLimiter, validateBody(refreshSchema), async (req, res) => {
try {
const { refreshToken } = req.validated;
@@ -208,4 +218,35 @@ router.post('/logout', authenticate, async (req, res) => {
}
});
+// ─────────────── PUT /api/auth/change-password ───────────────
+router.put('/change-password', authenticate, async (req, res) => {
+ try {
+ const { currentPassword, newPassword } = req.body;
+ if (!currentPassword || !newPassword) {
+ return res.status(400).json({ error: 'Current password and new password are required' });
+ }
+ if (newPassword.length < 8) {
+ return res.status(400).json({ error: 'New password must be at least 8 characters' });
+ }
+
+ const user = await req.prisma.user.findUnique({ where: { id: req.user.id } });
+ if (!user) return res.status(404).json({ error: 'User not found' });
+
+ const bcrypt = require('bcryptjs');
+ const valid = await bcrypt.compare(currentPassword, user.passwordHash);
+ if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
+
+ const hash = await bcrypt.hash(newPassword, 10);
+ await req.prisma.user.update({
+ where: { id: req.user.id },
+ data: { passwordHash: hash, refreshToken: null },
+ });
+
+ res.json({ message: 'Password changed successfully. Please log in again.' });
+ } catch (err) {
+ console.error('Change password error:', err);
+ res.status(500).json({ error: 'Failed to change password' });
+ }
+});
+
module.exports = router;
diff --git a/backend/src/routes/entries.js b/backend/src/routes/entries.js
index 49497be..d9179ca 100644
--- a/backend/src/routes/entries.js
+++ b/backend/src/routes/entries.js
@@ -7,6 +7,7 @@ const {
copyWeekSchema,
validateBody,
validateQuery,
+ validateIdParam,
} = require('../utils/validation');
const router = express.Router();
@@ -119,6 +120,18 @@ router.post('/', validateBody(createEntrySchema), async (req, res) => {
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
}
+ // Check per-day hour cap (max 24h total across all entries)
+ const existingHours = await req.prisma.timeEntry.aggregate({
+ where: { userId: req.user.id, date: new Date(date + 'T00:00:00Z') },
+ _sum: { hoursWorked: true },
+ });
+ const totalForDay = parseFloat(existingHours._sum.hoursWorked || 0) + hoursWorked;
+ if (totalForDay > 24) {
+ return res.status(400).json({
+ error: `Total hours for this day would be ${totalForDay.toFixed(1)}. Maximum is 24.`,
+ });
+ }
+
const entry = await req.prisma.timeEntry.create({
data: {
userId: req.user.id,
@@ -151,7 +164,7 @@ router.post('/', validateBody(createEntrySchema), async (req, res) => {
});
// ─────────────── PUT /api/entries/:id ───────────────
-router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
+router.put('/:id', validateIdParam, validateBody(updateEntrySchema), async (req, res) => {
try {
const { id } = req.params;
@@ -230,7 +243,7 @@ router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
});
// ─────────────── DELETE /api/entries/:id ───────────────
-router.delete('/:id', async (req, res) => {
+router.delete('/:id', validateIdParam, async (req, res) => {
try {
const { id } = req.params;
diff --git a/backend/src/routes/timesheets.js b/backend/src/routes/timesheets.js
index 65e0b12..44e5e4b 100644
--- a/backend/src/routes/timesheets.js
+++ b/backend/src/routes/timesheets.js
@@ -6,6 +6,7 @@ const {
weekQuerySchema,
validateBody,
validateQuery,
+ validateIdParam,
} = require('../utils/validation');
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
const { sendTimesheetEmail } = require('../utils/email');
@@ -114,23 +115,33 @@ router.get('/history', async (req, res) => {
orderBy: { weekStart: 'desc' },
include: {
approver: { select: { name: true } },
+ entries: {
+ include: {
+ timeEntry: { select: { hoursWorked: true } },
+ },
+ },
_count: { select: { entries: true } },
},
});
res.json({
- timesheets: timesheets.map((ts) => ({
- id: ts.id,
- weekStart: ts.weekStart.toISOString().split('T')[0],
- weekEnd: ts.weekEnd.toISOString().split('T')[0],
- status: ts.status,
- submittedAt: ts.submittedAt,
- approvedAt: ts.approvedAt,
- approvedBy: ts.approver?.name || null,
- notes: ts.notes,
- totalHours: null, // Could aggregate if needed
- entryCount: ts._count.entries,
- })),
+ timesheets: timesheets.map((ts) => {
+ const totalHours = ts.entries.reduce(
+ (sum, link) => sum + parseFloat(link.timeEntry.hoursWorked || 0), 0
+ );
+ return {
+ id: ts.id,
+ weekStart: ts.weekStart.toISOString().split('T')[0],
+ weekEnd: ts.weekEnd.toISOString().split('T')[0],
+ status: ts.status,
+ submittedAt: ts.submittedAt,
+ approvedAt: ts.approvedAt,
+ approvedBy: ts.approver?.name || null,
+ notes: ts.notes,
+ totalHours: parseFloat(totalHours.toFixed(2)),
+ entryCount: ts._count.entries,
+ };
+ }),
});
} catch (err) {
console.error('Get history error:', err);
@@ -160,6 +171,22 @@ router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) =>
return res.status(400).json({ error: 'Cannot submit an empty timesheet' });
}
+ // Check if already submitted or approved — prevent resubmission
+ const existingTs = await req.prisma.timesheet.findUnique({
+ where: {
+ userId_weekStart: {
+ userId: req.user.id,
+ weekStart: new Date(monday + 'T00:00:00Z'),
+ },
+ },
+ });
+
+ if (existingTs && ['submitted', 'approved'].includes(existingTs.status)) {
+ return res.status(409).json({
+ error: `Timesheet is already ${existingTs.status}. Contact admin to reopen.`,
+ });
+ }
+
// Upsert the timesheet
const timesheet = await req.prisma.timesheet.upsert({
where: {
@@ -213,7 +240,7 @@ router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) =>
});
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
-router.get('/:id/pdf', async (req, res) => {
+router.get('/:id/pdf', validateIdParam, async (req, res) => {
try {
const { id } = req.params;
@@ -255,7 +282,7 @@ router.get('/:id/pdf', async (req, res) => {
});
// ─────────────── POST /api/timesheets/:id/email ───────────────
-router.post('/:id/email', validateBody(emailTimesheetSchema), async (req, res) => {
+router.post('/:id/email', validateIdParam, validateBody(emailTimesheetSchema), async (req, res) => {
try {
const { id } = req.params;
const { to, subject, message } = req.validated;
diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js
index 8aa6f30..b562e20 100644
--- a/backend/src/utils/email.js
+++ b/backend/src/utils/email.js
@@ -86,8 +86,13 @@ async function sendTimesheetEmail({
`;
+ // Sanitize fromName to prevent email header injection
+ const safeName = fromName
+ ? fromName.replace(/[\r\n"\\]/g, '').slice(0, 100)
+ : null;
+
const result = await transporter.sendMail({
- from: fromName ? `"${fromName}" <${fromAddress}>` : fromAddress,
+ from: safeName ? `"${safeName}" <${fromAddress}>` : fromAddress,
to,
subject: subject || 'Timesheet – Coastal Contracting of FL',
text: message || 'Your timesheet is attached.',
diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js
index 8505334..6f1e5df 100644
--- a/backend/src/utils/validation.js
+++ b/backend/src/utils/validation.js
@@ -24,7 +24,11 @@ const refreshSchema = z.object({
// ──────────────────────────── Entries ────────────────────────────
const createEntrySchema = z.object({
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD')
+ .refine(val => {
+ const entryDate = new Date(val + 'T23:59:59Z');
+ return entryDate <= new Date();
+ }, { message: 'Cannot create entries for future dates' }),
homeownerId: z.string().uuid('Invalid homeowner ID'),
hoursWorked: z
.number()
@@ -107,6 +111,34 @@ const reportQuerySchema = z.object({
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
});
+// ──────────────────────────── Route Params ────────────────────────────
+
+const uuidParamSchema = z.object({
+ id: z.string().uuid('Invalid ID format'),
+});
+
+// ──────────────────────────── Admin Query Params ────────────────────────────
+
+const adminTimesheetQuerySchema = z.object({
+ status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
+ userId: z.string().uuid().optional(),
+ page: z.string().regex(/^\d+$/).optional(),
+ limit: z.string().regex(/^\d+$/).optional(),
+});
+
+const adminOvertimeQuerySchema = z.object({
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be YYYY-MM-DD').optional(),
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be YYYY-MM-DD').optional(),
+ userId: z.string().uuid().optional(),
+ threshold: z.string().regex(/^\d+(\.\d+)?$/).optional(),
+ rate: z.string().regex(/^\d+(\.\d+)?$/).optional(),
+});
+
+const adminReportPdfQuerySchema = z.object({
+ userId: z.string().uuid('Invalid userId'),
+ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'),
+});
+
// ──────────────────────────── Helpers ────────────────────────────
/**
@@ -145,6 +177,17 @@ function validateQuery(schema) {
};
}
+/**
+ * Express middleware for validating :id route param as UUID
+ */
+function validateIdParam(req, res, next) {
+ const result = uuidParamSchema.safeParse(req.params);
+ if (!result.success) {
+ return res.status(400).json({ error: 'Invalid ID format — expected UUID' });
+ }
+ next();
+}
+
module.exports = {
loginSchema,
registerSchema,
@@ -162,6 +205,10 @@ module.exports = {
createHomeownerSchema,
updateHomeownerSchema,
reportQuerySchema,
+ adminTimesheetQuerySchema,
+ adminOvertimeQuerySchema,
+ adminReportPdfQuerySchema,
validateBody,
validateQuery,
+ validateIdParam,
};
diff --git a/docker/.env b/docker/.env
index c427582..bfd3ce1 100644
--- a/docker/.env
+++ b/docker/.env
@@ -2,5 +2,5 @@ DB_PASSWORD=coastal_secret
JWT_SECRET=Ts8pLm3QvK5nRw9sYzB4jFc7hE0aGd2U
JWT_REFRESH_SECRET=Rf6kMn2QpT8wLs4vYzA7jFb9hD0eGc5U
NODE_ENV=production
-CORS_ORIGINS=https://ts.bizzle.cloud
+CORS_ORIGINS=https://ts.bizzle.cloud,http://localhost:5173,http://localhost:3000
ENABLE_BACKUPS=false
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index c90af8d..5daca90 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -7,7 +7,7 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: coastal
- POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret}
+ POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}
POSTGRES_DB: coastal_timesheet
volumes:
- pgdata:/var/lib/postgresql/data
@@ -34,12 +34,12 @@ services:
db:
condition: service_healthy
environment:
- DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet
- JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026}
- JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026}
+ DATABASE_URL: postgresql://coastal:${DB_PASSWORD:?DB_PASSWORD must be set}@db:5432/coastal_timesheet
+ JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env}
+ JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?JWT_REFRESH_SECRET must be set in .env}
PORT: '3004'
NODE_ENV: production
- CORS_ORIGINS: https://ts.bizzle.cloud,http://localhost
+ CORS_ORIGINS: https://ts.bizzle.cloud,http://localhost:5173,http://localhost:3000
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
@@ -47,8 +47,7 @@ services:
SMTP_FROM: ${SMTP_FROM:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
ports:
- - '127.0.0.1:3004:3004'
- - '172.18.0.1:3004:3004'
+ - '127.0.0.1:3005:3004'
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://localhost:3004/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
interval: 15s
diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf
index cde0814..31150f4 100644
--- a/docker/nginx/default.conf
+++ b/docker/nginx/default.conf
@@ -12,11 +12,9 @@ server {
listen [::]:80;
server_name _;
- # Security headers
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
+ # Security headers (X-Frame-Options, HSTS, X-Content-Type-Options set by Caddy)
add_header X-XSS-Protection "0" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Gzip compression
gzip on;
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
index 384abbb..31b12fb 100644
--- a/frontend/src/pages/Admin.jsx
+++ b/frontend/src/pages/Admin.jsx
@@ -281,6 +281,20 @@ function ManageUsers() {
{user?.role === 'super_admin' && }
+
{u.isActive === false ? (