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:
@@ -75,6 +75,19 @@ function NativeInput({
|
||||
disabled,
|
||||
}: NativeInputProps) {
|
||||
const type = modeToInputType[mode] || "date";
|
||||
// For time inputs, min/max must be in HH:mm format, not date format
|
||||
const formatTimeMinMax = (val: string | undefined): string | undefined => {
|
||||
if (!val) return undefined;
|
||||
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
|
||||
// otherwise it's not a valid time min/max — return undefined
|
||||
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
|
||||
if (val.includes(":")) return val.substring(0, 5);
|
||||
return undefined;
|
||||
};
|
||||
const minProp =
|
||||
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
|
||||
const maxProp =
|
||||
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
@@ -84,8 +97,8 @@ function NativeInput({
|
||||
className="admin-form-input"
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
min={minDate || undefined}
|
||||
max={maxDate || undefined}
|
||||
min={minProp}
|
||||
max={maxProp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function AttendanceShiftTable({
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento mÄ›sĂc nejsou žádnĂ© záznamy.</p>
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,15 +137,15 @@ export default function AttendanceShiftTable({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Typ</th>
|
||||
<th>PĹ™Ăchod</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekt</th>
|
||||
<th>GPS</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -57,7 +57,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
{ id, message, type: type as Alert["type"] },
|
||||
]);
|
||||
if (duration > 0) {
|
||||
const timeoutId = setTimeout(() => removeAlert(id), duration);
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutsRef.current.delete(timeoutId);
|
||||
removeAlert(id);
|
||||
}, duration);
|
||||
timeoutsRef.current.add(timeoutId);
|
||||
}
|
||||
return id;
|
||||
|
||||
@@ -268,6 +268,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
login_token: loginToken,
|
||||
totp_code: code,
|
||||
remember_me: remember,
|
||||
isBackup,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function useListData<T = unknown>(
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const extraParamsKey = Object.entries(extraParams)
|
||||
@@ -100,8 +101,10 @@ export default function useListData<T = unknown>(
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
if (!mountedRef.current) return;
|
||||
alert.error(errorMsg);
|
||||
} finally {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(false);
|
||||
setInitialLoad(false);
|
||||
}
|
||||
@@ -117,8 +120,10 @@ export default function useListData<T = unknown>(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchData();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
@@ -634,7 +634,8 @@ export default function Attendance() {
|
||||
{projectLogs.length > 0 && (
|
||||
<div className="attendance-project-logs">
|
||||
{projectLogs.map((log, i) => {
|
||||
const start = new Date(log.started_at!);
|
||||
if (!log.started_at) return null;
|
||||
const start = new Date(log.started_at);
|
||||
const end = log.ended_at
|
||||
? new Date(log.ended_at)
|
||||
: new Date();
|
||||
@@ -786,11 +787,12 @@ export default function Attendance() {
|
||||
}}
|
||||
>
|
||||
{shiftLogs.map((log, i) => {
|
||||
if (!log.started_at) return null;
|
||||
const mins = log.ended_at
|
||||
? Math.floor(
|
||||
(new Date(log.ended_at).getTime() -
|
||||
new Date(
|
||||
log.started_at!,
|
||||
log.started_at,
|
||||
).getTime()) /
|
||||
60000,
|
||||
)
|
||||
|
||||
@@ -449,8 +449,15 @@ export default function InvoiceDetail() {
|
||||
}>({ show: false, status: null });
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Data loading ───
|
||||
|
||||
useEffect(() => {
|
||||
@@ -915,7 +922,8 @@ export default function InvoiceDetail() {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
|
||||
@@ -163,8 +163,8 @@ export default function LeaveApproval() {
|
||||
: []),
|
||||
].sort(
|
||||
(a: LeaveRequest, b: LeaveRequest) =>
|
||||
new Date(b.reviewed_at!).getTime() -
|
||||
new Date(a.reviewed_at!).getTime(),
|
||||
(b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
|
||||
(a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
|
||||
);
|
||||
|
||||
setProcessedRequests(all);
|
||||
|
||||
@@ -323,6 +323,7 @@ export default function OfferDetail() {
|
||||
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
|
||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [companySettings, setCompanySettings] = useState<{
|
||||
default_currency: string;
|
||||
default_vat_rate: number;
|
||||
@@ -341,6 +342,12 @@ export default function OfferDetail() {
|
||||
|
||||
useModalLock(showOrderModal);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`${API_BASE}/company-settings`)
|
||||
.then((r) => r.json())
|
||||
@@ -829,7 +836,8 @@ export default function OfferDetail() {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba při generování PDF");
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
@@ -121,6 +122,13 @@ export default function OrderDetail() {
|
||||
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
||||
const initialNotesRef = useRef<string | null>(null);
|
||||
const hasSetInitialSnapshot = useRef(false);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
try {
|
||||
@@ -249,7 +257,8 @@ export default function OrderDetail() {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
@@ -293,7 +302,8 @@ export default function OrderDetail() {
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
|
||||
@@ -161,6 +161,7 @@ export default function ReceivedInvoices({
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const slideDirection = useRef(0);
|
||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [slideKey, setSlideKey] = useState(0);
|
||||
const prevMonth = useRef(statsMonth);
|
||||
const prevYear = useRef(statsYear);
|
||||
@@ -185,6 +186,12 @@ export default function ReceivedInvoices({
|
||||
|
||||
useModalLock(uploadOpen || editOpen);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobTimeoutsRef.current.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevYear.current * 12 + prevMonth.current;
|
||||
const curr = statsYear * 12 + statsMonth;
|
||||
@@ -516,7 +523,8 @@ export default function ReceivedInvoices({
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (newWindow) newWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
blobTimeoutsRef.current.push(timeoutId);
|
||||
} catch {
|
||||
newWindow?.close();
|
||||
alert.error("Chyba připojení");
|
||||
|
||||
Reference in New Issue
Block a user