fix: security, validation, and data integrity fixes across 53 files

- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 08:40:38 +02:00
parent 7f07032bf2
commit d7c7fbad88
52 changed files with 927 additions and 573 deletions

View File

@@ -4,21 +4,14 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
/** Get active users whose role has attendance.record permission (or admin role) */
/** Get active users whose role has attendance.record permission */
async function getAttendanceUsers() {
return prisma.users.findMany({
where: {
is_active: true,
roles: {
is: {
OR: [
{ name: "admin" },
{
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
],
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
},
@@ -73,11 +66,13 @@ function calcWorkedHours(
}
const roundUp = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms);
};
const roundDown = (d: Date, minutes: number) => {
if (!minutes || minutes <= 0) return d;
const ms = minutes * 60 * 1000;
return new Date(Math.floor(d.getTime() / ms) * ms);
};
@@ -1143,48 +1138,50 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
let inserted = 0;
let skipped = 0;
for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day);
const dateStr = localDateStr(date);
const dow = date.getDay();
await prisma.$transaction(async (tx) => {
for (const userId of data.user_ids.map(Number)) {
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(yr, mo - 1, day);
const dateStr = localDateStr(date);
const dow = date.getDay();
if (dow === 0 || dow === 6) continue;
if (dow === 0 || dow === 6) continue;
if (existingSet.has(`${userId}:${dateStr}`)) {
skipped++;
continue;
}
if (existingSet.has(`${userId}:${dateStr}`)) {
skipped++;
continue;
}
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
if (isHoliday(dateStr)) {
await prisma.attendance.create({
if (isHoliday(dateStr)) {
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: "holiday",
leave_hours: 8,
},
});
inserted++;
continue;
}
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: "holiday",
leave_hours: 8,
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
leave_type: "work",
},
});
inserted++;
continue;
}
await prisma.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
leave_type: "work",
},
});
inserted++;
}
}
});
let msg = `Vytvořeno ${inserted} záznamů`;
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
@@ -1212,70 +1209,83 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const end = new Date(dateTo);
let created = 0;
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
);
const duplicate = await prisma.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
return { error: "Pro zvolené datumy již existují záznamy docházky" };
try {
await prisma.$transaction(async (tx) => {
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
);
const duplicate = await tx.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
throw new Error("Pro zvolené datumy již existují záznamy docházky");
}
await tx.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
}
current.setDate(current.getDate() + 1);
}
await prisma.attendance.create({
data: {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
}
current.setDate(current.getDate() + 1);
}
const totalLeaveHours =
created * (data.leave_hours ? Number(data.leave_hours) : 8);
if (
(leaveType === "vacation" || leaveType === "sick") &&
totalLeaveHours > 0
) {
const year = new Date(dateFrom).getFullYear();
const existingBalance = await prisma.leave_balances.findFirst({
where: { user_id: userId, year },
const totalLeaveHours =
created * (data.leave_hours ? Number(data.leave_hours) : 8);
if (
(leaveType === "vacation" || leaveType === "sick") &&
totalLeaveHours > 0
) {
const year = new Date(dateFrom).getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year },
});
if (existingBalance) {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]:
Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(),
},
});
} else {
await tx.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
},
});
}
}
});
if (existingBalance) {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
await prisma.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(),
},
});
} else {
await prisma.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
},
});
} catch (err) {
if (
err instanceof Error &&
err.message === "Pro zvolené datumy již existují záznamy docházky"
) {
return { error: err.message };
}
throw err;
}
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
@@ -1418,8 +1428,17 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
const msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
let msRound = settings.clock_rounding_minutes * 60 * 1000;
if (
!settings.clock_rounding_minutes ||
settings.clock_rounding_minutes <= 0
) {
msRound = 0;
}
const breakStart =
msRound > 0
? new Date(Math.round(now.getTime() / msRound) * msRound)
: now;
const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);