production hardening: future-date guard, per-day hour cap, totalHours in history, header cleanup
This commit is contained in:
@@ -32,6 +32,7 @@ app.use(
|
|||||||
if (!origin || allowedOrigins.includes(origin)) {
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
} else {
|
} else {
|
||||||
|
console.error(`CORS rejected origin: ${origin} (allowed: ${allowedOrigins.join(', ')})`);
|
||||||
callback(new Error('Not allowed by CORS'));
|
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({
|
const apiLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 100,
|
max: 300,
|
||||||
message: { error: 'Too many requests, please try again later.' },
|
message: { error: 'Too many requests, please try again later.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
|||||||
+42
-16
@@ -9,8 +9,12 @@ const {
|
|||||||
createHomeownerSchema,
|
createHomeownerSchema,
|
||||||
updateHomeownerSchema,
|
updateHomeownerSchema,
|
||||||
reportQuerySchema,
|
reportQuerySchema,
|
||||||
|
adminTimesheetQuerySchema,
|
||||||
|
adminOvertimeQuerySchema,
|
||||||
|
adminReportPdfQuerySchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
|
validateIdParam,
|
||||||
} = require('../utils/validation');
|
} = require('../utils/validation');
|
||||||
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
||||||
|
|
||||||
@@ -24,9 +28,9 @@ router.use(authenticate, requireAdmin);
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
|
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
|
||||||
router.get('/timesheets', async (req, res) => {
|
router.get('/timesheets', validateQuery(adminTimesheetQuerySchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, userId, page = '1', limit = '50' } = req.query;
|
const { status, userId, page = '1', limit = '50' } = req.validatedQuery;
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
@@ -90,7 +94,7 @@ router.get('/timesheets', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/timesheets/:id ───────────────
|
// ─────────────── GET /api/admin/timesheets/:id ───────────────
|
||||||
router.get('/timesheets/:id', async (req, res) => {
|
router.get('/timesheets/:id', validateIdParam, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -196,6 +200,7 @@ router.post('/timesheets/bulk-reject', validateBody(bulkOperationSchema), async
|
|||||||
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/timesheets/:id/approve',
|
'/timesheets/:id/approve',
|
||||||
|
validateIdParam,
|
||||||
validateBody(approveRejectSchema),
|
validateBody(approveRejectSchema),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -243,6 +248,7 @@ router.put(
|
|||||||
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/timesheets/:id/reject',
|
'/timesheets/:id/reject',
|
||||||
|
validateIdParam,
|
||||||
validateBody(approveRejectSchema),
|
validateBody(approveRejectSchema),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -287,7 +293,7 @@ router.put(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
|
||||||
router.put('/timesheets/:id/reopen', async (req, res) => {
|
router.put('/timesheets/:id/reopen', validateIdParam, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
|
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 ───────────────
|
// ─────────────── 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 {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, role, isActive, password } = req.validated;
|
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) ───────────────
|
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
|
||||||
router.delete('/users/:id', async (req, res) => {
|
router.delete('/users/:id', validateIdParam, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (id === req.user.id) {
|
if (id === req.user.id) {
|
||||||
@@ -517,6 +523,7 @@ router.post(
|
|||||||
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
|
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/homeowners/:id',
|
'/homeowners/:id',
|
||||||
|
validateIdParam,
|
||||||
validateBody(updateHomeownerSchema),
|
validateBody(updateHomeownerSchema),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -691,13 +698,9 @@ router.get('/reports', validateQuery(reportQuerySchema), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/reports/pdf ───────────────
|
// ─────────────── GET /api/admin/reports/pdf ───────────────
|
||||||
router.get('/reports/pdf', async (req, res) => {
|
router.get('/reports/pdf', validateQuery(adminReportPdfQuerySchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId, weekStart } = req.query;
|
const { userId, weekStart } = req.validatedQuery;
|
||||||
|
|
||||||
if (!userId || !weekStart) {
|
|
||||||
return res.status(400).json({ error: 'userId and weekStart are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await req.prisma.user.findUnique({
|
const user = await req.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -757,11 +760,11 @@ router.get('/reports/pdf', async (req, res) => {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ─────────────── GET /api/admin/overtime ───────────────
|
// ─────────────── GET /api/admin/overtime ───────────────
|
||||||
router.get('/overtime', async (req, res) => {
|
router.get('/overtime', validateQuery(adminOvertimeQuerySchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { from, to, userId } = req.query;
|
const { from, to, userId } = req.validatedQuery;
|
||||||
const weeklyThreshold = parseFloat(req.query.threshold || '40');
|
const weeklyThreshold = parseFloat(req.validatedQuery.threshold || '40');
|
||||||
const overtimeRate = parseFloat(req.query.rate || '1.5');
|
const overtimeRate = parseFloat(req.validatedQuery.rate || '1.5');
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (userId) where.userId = userId;
|
if (userId) where.userId = userId;
|
||||||
@@ -853,4 +856,27 @@ router.get('/overtime', async (req, res) => {
|
|||||||
// ─────────────── GET /api/timesheets/overtime ───────────────
|
// ─────────────── GET /api/timesheets/overtime ───────────────
|
||||||
// (mounted at /api/timesheets/overtime in timesheets router)
|
// (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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
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({
|
const authLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 50,
|
max: 10,
|
||||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
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 ───────────────
|
// ─────────────── POST /api/auth/refresh ───────────────
|
||||||
router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
|
router.post('/refresh', refreshLimiter, validateBody(refreshSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { refreshToken } = req.validated;
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
copyWeekSchema,
|
copyWeekSchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
|
validateIdParam,
|
||||||
} = require('../utils/validation');
|
} = require('../utils/validation');
|
||||||
|
|
||||||
const router = express.Router();
|
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' });
|
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({
|
const entry = await req.prisma.timeEntry.create({
|
||||||
data: {
|
data: {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -151,7 +164,7 @@ router.post('/', validateBody(createEntrySchema), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── PUT /api/entries/:id ───────────────
|
// ─────────────── PUT /api/entries/:id ───────────────
|
||||||
router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
|
router.put('/:id', validateIdParam, validateBody(updateEntrySchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -230,7 +243,7 @@ router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── DELETE /api/entries/:id ───────────────
|
// ─────────────── DELETE /api/entries/:id ───────────────
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', validateIdParam, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
weekQuerySchema,
|
weekQuerySchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
|
validateIdParam,
|
||||||
} = require('../utils/validation');
|
} = require('../utils/validation');
|
||||||
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
|
||||||
const { sendTimesheetEmail } = require('../utils/email');
|
const { sendTimesheetEmail } = require('../utils/email');
|
||||||
@@ -114,23 +115,33 @@ router.get('/history', async (req, res) => {
|
|||||||
orderBy: { weekStart: 'desc' },
|
orderBy: { weekStart: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
approver: { select: { name: true } },
|
approver: { select: { name: true } },
|
||||||
|
entries: {
|
||||||
|
include: {
|
||||||
|
timeEntry: { select: { hoursWorked: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
_count: { select: { entries: true } },
|
_count: { select: { entries: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
timesheets: timesheets.map((ts) => ({
|
timesheets: timesheets.map((ts) => {
|
||||||
id: ts.id,
|
const totalHours = ts.entries.reduce(
|
||||||
weekStart: ts.weekStart.toISOString().split('T')[0],
|
(sum, link) => sum + parseFloat(link.timeEntry.hoursWorked || 0), 0
|
||||||
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
);
|
||||||
status: ts.status,
|
return {
|
||||||
submittedAt: ts.submittedAt,
|
id: ts.id,
|
||||||
approvedAt: ts.approvedAt,
|
weekStart: ts.weekStart.toISOString().split('T')[0],
|
||||||
approvedBy: ts.approver?.name || null,
|
weekEnd: ts.weekEnd.toISOString().split('T')[0],
|
||||||
notes: ts.notes,
|
status: ts.status,
|
||||||
totalHours: null, // Could aggregate if needed
|
submittedAt: ts.submittedAt,
|
||||||
entryCount: ts._count.entries,
|
approvedAt: ts.approvedAt,
|
||||||
})),
|
approvedBy: ts.approver?.name || null,
|
||||||
|
notes: ts.notes,
|
||||||
|
totalHours: parseFloat(totalHours.toFixed(2)),
|
||||||
|
entryCount: ts._count.entries,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get history error:', 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' });
|
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
|
// Upsert the timesheet
|
||||||
const timesheet = await req.prisma.timesheet.upsert({
|
const timesheet = await req.prisma.timesheet.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -213,7 +240,7 @@ router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
|
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
|
||||||
router.get('/:id/pdf', async (req, res) => {
|
router.get('/:id/pdf', validateIdParam, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -255,7 +282,7 @@ router.get('/:id/pdf', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────── POST /api/timesheets/:id/email ───────────────
|
// ─────────────── 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 {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { to, subject, message } = req.validated;
|
const { to, subject, message } = req.validated;
|
||||||
|
|||||||
@@ -86,8 +86,13 @@ async function sendTimesheetEmail({
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Sanitize fromName to prevent email header injection
|
||||||
|
const safeName = fromName
|
||||||
|
? fromName.replace(/[\r\n"\\]/g, '').slice(0, 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
const result = await transporter.sendMail({
|
const result = await transporter.sendMail({
|
||||||
from: fromName ? `"${fromName}" <${fromAddress}>` : fromAddress,
|
from: safeName ? `"${safeName}" <${fromAddress}>` : fromAddress,
|
||||||
to,
|
to,
|
||||||
subject: subject || 'Timesheet – Coastal Contracting of FL',
|
subject: subject || 'Timesheet – Coastal Contracting of FL',
|
||||||
text: message || 'Your timesheet is attached.',
|
text: message || 'Your timesheet is attached.',
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ const refreshSchema = z.object({
|
|||||||
// ──────────────────────────── Entries ────────────────────────────
|
// ──────────────────────────── Entries ────────────────────────────
|
||||||
|
|
||||||
const createEntrySchema = z.object({
|
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'),
|
homeownerId: z.string().uuid('Invalid homeowner ID'),
|
||||||
hoursWorked: z
|
hoursWorked: z
|
||||||
.number()
|
.number()
|
||||||
@@ -107,6 +111,34 @@ const reportQuerySchema = z.object({
|
|||||||
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
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 ────────────────────────────
|
// ──────────────────────────── 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 = {
|
module.exports = {
|
||||||
loginSchema,
|
loginSchema,
|
||||||
registerSchema,
|
registerSchema,
|
||||||
@@ -162,6 +205,10 @@ module.exports = {
|
|||||||
createHomeownerSchema,
|
createHomeownerSchema,
|
||||||
updateHomeownerSchema,
|
updateHomeownerSchema,
|
||||||
reportQuerySchema,
|
reportQuerySchema,
|
||||||
|
adminTimesheetQuerySchema,
|
||||||
|
adminOvertimeQuerySchema,
|
||||||
|
adminReportPdfQuerySchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
|
validateIdParam,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -2,5 +2,5 @@ DB_PASSWORD=coastal_secret
|
|||||||
JWT_SECRET=Ts8pLm3QvK5nRw9sYzB4jFc7hE0aGd2U
|
JWT_SECRET=Ts8pLm3QvK5nRw9sYzB4jFc7hE0aGd2U
|
||||||
JWT_REFRESH_SECRET=Rf6kMn2QpT8wLs4vYzA7jFb9hD0eGc5U
|
JWT_REFRESH_SECRET=Rf6kMn2QpT8wLs4vYzA7jFb9hD0eGc5U
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
CORS_ORIGINS=https://ts.bizzle.cloud
|
CORS_ORIGINS=https://ts.bizzle.cloud,http://localhost:5173,http://localhost:3000
|
||||||
ENABLE_BACKUPS=false
|
ENABLE_BACKUPS=false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: coastal
|
POSTGRES_USER: coastal
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}
|
||||||
POSTGRES_DB: coastal_timesheet
|
POSTGRES_DB: coastal_timesheet
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
@@ -34,12 +34,12 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet
|
DATABASE_URL: postgresql://coastal:${DB_PASSWORD:?DB_PASSWORD must be set}@db:5432/coastal_timesheet
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env}
|
||||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026}
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?JWT_REFRESH_SECRET must be set in .env}
|
||||||
PORT: '3004'
|
PORT: '3004'
|
||||||
NODE_ENV: production
|
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_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
@@ -47,8 +47,7 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:3004:3004'
|
- '127.0.0.1:3005:3004'
|
||||||
- '172.18.0.1:3004:3004'
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'node', '-e', "fetch('http://localhost:3004/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
|
test: ['CMD', 'node', '-e', "fetch('http://localhost:3004/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ server {
|
|||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# Security headers
|
# Security headers (X-Frame-Options, HSTS, X-Content-Type-Options set by Caddy)
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "0" always;
|
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 compression
|
||||||
gzip on;
|
gzip on;
|
||||||
|
|||||||
@@ -281,6 +281,20 @@ function ManageUsers() {
|
|||||||
<option value="office">Office Staff</option>
|
<option value="office">Office Staff</option>
|
||||||
{user?.role === 'super_admin' && <option value="super_admin">Admin</option>}
|
{user?.role === 'super_admin' && <option value="super_admin">Admin</option>}
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const newPass = prompt(`Reset password for ${u.name}?\nEnter new password (min 8 characters):`);
|
||||||
|
if (!newPass || newPass.length < 8) { if (newPass) alert('Password must be at least 8 characters'); return; }
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/users/${u.id}/reset-password`, { newPassword: newPass });
|
||||||
|
alert(`Password reset for ${u.name}. They can now log in with the new password.`);
|
||||||
|
} catch (err) { alert(err.response?.data?.error || 'Failed to reset password'); }
|
||||||
|
}}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-500/20 font-medium"
|
||||||
|
title="Reset Password"
|
||||||
|
>
|
||||||
|
🔑 Reset PW
|
||||||
|
</button>
|
||||||
{u.isActive === false ? (
|
{u.isActive === false ? (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user