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

@@ -55,7 +55,10 @@ export const AttendanceLeaveSchema = z.object({
.optional(),
date_from: z.string().min(1, "Datum je povinné"),
date_to: z.string().optional(),
leave_type: z.string().optional().default("vacation"),
leave_type: z
.enum(["vacation", "sick", "unpaid", "holiday"])
.optional()
.default("vacation"),
leave_hours: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
@@ -65,16 +68,8 @@ export const AttendanceLeaveSchema = z.object({
const ProjectLogSchema = z.object({
project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
hours: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.optional()
.default(0),
minutes: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.optional()
.default(0),
hours: z.coerce.number().min(0).default(0),
minutes: z.coerce.number().min(0).default(0),
});
export const AttendancePunchSchema = z.object({
@@ -124,7 +119,10 @@ export const CreateAttendanceSchema = z.object({
.union([z.number(), z.string()])
.transform((v) => Number(v))
.nullish(),
leave_type: z.string().optional().default("work"),
leave_type: z
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
.optional()
.default("work"),
leave_hours: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
@@ -138,8 +136,13 @@ export const UpdateAttendanceSchema = z.object({
break_start: z.union([z.string(), z.null()]).optional(),
break_end: z.union([z.string(), z.null()]).optional(),
notes: z.string().nullish(),
project_id: z.union([z.number(), z.string(), z.null()]).optional(),
leave_type: z.string().optional(),
project_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
leave_type: z
.enum(["work", "vacation", "sick", "holiday", "unpaid"])
.optional(),
leave_hours: z.union([z.number(), z.string(), z.null()]).optional(),
project_logs: z.array(ProjectLogSchema).optional(),
});

View File

@@ -9,6 +9,7 @@ export const LoginSchema = z.object({
export const TotpVerifySchema = z.object({
login_token: z.string().min(1, "Token je povinný"),
totp_code: z.string().length(6, "Kód musí mít 6 číslic"),
remember_me: z.boolean().optional().default(false),
});
export const TotpBackupSchema = z.object({
@@ -16,6 +17,22 @@ export const TotpBackupSchema = z.object({
backup_code: z.string().min(1, "Záložní kód je povinný"),
});
export const TotpEnableSchema = z.object({
secret: z.string().min(1),
code: z.string().min(1),
password: z.string().optional(),
current_code: z.string().optional(),
});
export const TotpDisableSchema = z.object({
code: z.string().min(1),
password: z.string().optional(),
});
export const TotpRequiredSchema = z.object({
required: z.boolean(),
});
export type LoginInput = z.infer<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;

View File

@@ -4,13 +4,21 @@ const InvoiceItemSchema = z.object({
description: z.string().nullish(),
quantity: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 1)
.transform((v) => {
const n = Number(v);
if (isNaN(n) || n <= 0) throw new Error("Invalid quantity");
return n;
})
.optional()
.default(1),
unit: z.string().nullish(),
unit_price: z
.union([z.number(), z.string()])
.transform((v) => Number(v) || 0)
.transform((v) => {
const n = Number(v);
if (isNaN(n)) throw new Error("Invalid unit_price");
return n;
})
.optional()
.default(0),
vat_rate: z
@@ -73,7 +81,10 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().nullish(),
bank_account: z.string().nullish(),
issued_by: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
vat_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -1,14 +1,18 @@
import { z } from "zod";
export const CreateLeaveRequestSchema = z.object({
leave_type: z.string().min(1, "Typ nepřítomnosti je povinný"),
leave_type: z
.enum(["vacation", "sick", "unpaid", "holiday"])
.default("vacation"),
date_from: z.string().min(1, "Datum od je povinné"),
date_to: z.string().min(1, "Datum do je povinné"),
notes: z.string().nullish(),
});
export const ReviewLeaveRequestSchema = z.object({
status: z.string().min(1, "Stav je povinný"),
status: z
.enum(["pending", "approved", "rejected", "cancelled"])
.default("pending"),
reviewer_note: z.string().nullish(),
});

View File

@@ -66,7 +66,7 @@ export const CreateQuotationSchema = z.object({
.optional()
.default(1.0),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"])
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional()
.default("nova"),
scope_title: z.string().nullish(),
@@ -99,7 +99,7 @@ export const UpdateQuotationSchema = z.object({
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
.optional(),
status: z
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena"])
.enum(["nova", "odeslana", "prijata", "odmitnuta", "dokoncena", "active"])
.optional(),
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),

View File

@@ -96,7 +96,10 @@ export const UpdateOrderSchema = z.object({
scope_title: z.string().nullish(),
scope_description: z.string().nullish(),
notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
vat_rate: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -34,10 +34,22 @@ export const UpdateProjectSchema = z.object({
name: z.string().nullish(),
status: z.string().optional(),
notes: z.string().nullish(),
customer_id: z.union([z.number(), z.string(), z.null()]).optional(),
responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(),
quotation_id: z.union([z.number(), z.string(), z.null()]).optional(),
order_id: z.union([z.number(), z.string(), z.null()]).optional(),
customer_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
responsible_user_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
quotation_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
order_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
start_date: z.union([z.string(), z.null()]).optional(),
end_date: z.union([z.string(), z.null()]).optional(),
});

View File

@@ -24,7 +24,7 @@ export const CreateReceivedInvoiceSchema = z.object({
.default(0),
issue_date: z.string().nullish(),
due_date: z.string().nullish(),
status: z.string().optional().default("unpaid"),
status: z.enum(["unpaid", "paid"]).optional().default("unpaid"),
notes: z.string().nullish(),
});
@@ -48,7 +48,7 @@ export const UpdateReceivedInvoiceSchema = z.object({
issue_date: z.union([z.string(), z.null()]).optional(),
due_date: z.union([z.string(), z.null()]).optional(),
paid_date: z.union([z.string(), z.null()]).optional(),
status: z.string().optional(),
status: z.enum(["unpaid", "paid"]).optional(),
notes: z.string().nullish(),
month: z
.union([z.number(), z.string()])

View File

@@ -2,6 +2,8 @@ import { z } from "zod";
export const CreateTripSchema = z.object({
vehicle_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
// user_id is optional here because the route injects it from authData.userId
// when the client doesn't provide it (see POST /trips handler)
user_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -6,7 +6,10 @@ export const CreateUserSchema = z.object({
password: z.string().min(8, "Heslo musí mít alespoň 8 znaků"),
first_name: z.string().min(1, "Jméno je povinné"),
last_name: z.string().min(1, "Příjmení je povinné"),
role_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
role_id: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.optional(),
is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional()
@@ -22,7 +25,10 @@ export const UpdateUserSchema = z.object({
),
first_name: z.string().optional(),
last_name: z.string().optional(),
role_id: z.union([z.number(), z.string(), z.null()]).optional(),
role_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.optional(),
is_active: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional(),