fix: oprava kritických bezpečnostních chyb a bugů z code review

- SEC-1: nahrazen exec('fsutil') za PHP-native is_link()+realpath() v NasFileManager - eliminace command injection
- SEC-2: přidáno ověření aktuálního hesla při změně hesla (profile.php + DashProfile.jsx)
- BUG-1: attendance punch obalen do transakce s SELECT FOR UPDATE - prevence race condition při dvojkliku
- BUG-2: eliminován N+1 SQL dotaz pro VAT v invoice listu - výpočet přesunut do subquery
- BUG-5/6: delete a update attendance záznamů obaleny do transakcí - prevence nekonzistentního stavu
- BUG-7: opravena duplikace nabídky - přidáno chybějící pole unit v offer items

ESLint: 0 errors | PHPCS: 0 errors | Build: OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 13:46:20 +01:00
parent 913344b8c4
commit 5550358b15
45 changed files with 373 additions and 301 deletions

View File

@@ -261,14 +261,17 @@ function handlePunch(PDO $pdo, int $userId): void
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null; $lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null; $lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
$accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null; $accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null;
$address = !empty($input['address']) ? $input['address'] : null; $address = !empty($input['address']) ? mb_substr($input['address'], 0, 500) : null;
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, break_start, break_end, SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at departure_time, notes, project_id, leave_type, created_at
FROM attendance FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
FOR UPDATE
"); ");
$stmt->execute([$userId]); $stmt->execute([$userId]);
$ongoingShift = $stmt->fetch(); $ongoingShift = $stmt->fetch();
@@ -287,6 +290,7 @@ function handlePunch(PDO $pdo, int $userId): void
'location' => $address, 'location' => $address,
], 'Příchod zaznamenán'); ], 'Příchod zaznamenán');
$pdo->commit();
successResponse(null, 'Příchod zaznamenán'); successResponse(null, 'Příchod zaznamenán');
} elseif ($ongoingShift) { } elseif ($ongoingShift) {
switch ($action) { switch ($action) {
@@ -298,8 +302,10 @@ function handlePunch(PDO $pdo, int $userId): void
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
$pdo->commit();
successResponse(null, 'Pauza zaznamenána'); successResponse(null, 'Pauza zaznamenána');
} else { } else {
$pdo->rollBack();
errorResponse('Nelze zadat pauzu'); errorResponse('Nelze zadat pauzu');
} }
break; break;
@@ -319,14 +325,16 @@ function handlePunch(PDO $pdo, int $userId): void
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60))); $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60))); $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); $sql = 'UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
} elseif ($hoursWorked > 6) { } elseif ($hoursWorked > 6) {
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2); $midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60))); $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60))); $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); $sql = 'UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
} }
} }
@@ -350,18 +358,26 @@ function handlePunch(PDO $pdo, int $userId): void
'location' => $address, 'location' => $address,
], 'Odchod zaznamenán'); ], 'Odchod zaznamenán');
$pdo->commit();
successResponse(null, 'Odchod zaznamenán'); successResponse(null, 'Odchod zaznamenán');
} else { } else {
$pdo->rollBack();
errorResponse('Nelze zadat odchod'); errorResponse('Nelze zadat odchod');
} }
break; break;
default: default:
$pdo->rollBack();
errorResponse('Neplatná akce'); errorResponse('Neplatná akce');
} }
} else { } else {
$pdo->rollBack();
errorResponse('Neplatná akce - nemáte aktivní směnu'); errorResponse('Neplatná akce - nemáte aktivní směnu');
} }
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
} }
function handleUpdateAddress(PDO $pdo, int $userId): void function handleUpdateAddress(PDO $pdo, int $userId): void

View File

@@ -213,6 +213,8 @@ function handleGetList(PDO $pdo): void
c.name as customer_name, c.name as customer_name,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as vat_amount,
o.order_number o.order_number
{$from} {$where} {$from} {$where}
ORDER BY {$p['sort']} {$p['order']}", ORDER BY {$p['sort']} {$p['order']}",
@@ -222,20 +224,11 @@ function handleGetList(PDO $pdo): void
$invoices = $result['items']; $invoices = $result['items'];
// Dopocitat celkovou castku s DPH
foreach ($invoices as &$inv) { foreach ($invoices as &$inv) {
$subtotal = (float) $inv['subtotal']; $subtotal = (float) $inv['subtotal'];
if ($inv['apply_vat']) { $inv['total'] = $inv['apply_vat']
$vatStmt = $pdo->prepare(' ? $subtotal + (float) $inv['vat_amount']
SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0) : $subtotal;
FROM invoice_items WHERE invoice_id = ?
');
$vatStmt->execute([$inv['id']]);
$vatAmount = (float) $vatStmt->fetchColumn();
$inv['total'] = $subtotal + $vatAmount;
} else {
$inv['total'] = $subtotal;
}
} }
unset($inv); unset($inv);

View File

@@ -428,6 +428,7 @@ function handleDuplicate(PDO $pdo, int $sourceId): void
'description' => $item['description'], 'description' => $item['description'],
'item_description' => $item['item_description'], 'item_description' => $item['item_description'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit' => $item['unit'] ?? '',
'unit_price' => $item['unit_price'], 'unit_price' => $item['unit_price'],
'is_included_in_total' => $item['is_included_in_total'], 'is_included_in_total' => $item['is_included_in_total'],
'position' => $item['position'], 'position' => $item['position'],

View File

@@ -34,10 +34,10 @@ try {
$pdo = db(); $pdo = db();
$userId = $authData['user_id']; $userId = $authData['user_id'];
// Get existing user // Get existing user (vcetne password_hash pro overeni aktualniho hesla)
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT id, username, email, first_name, last_name, role_id, is_active, SELECT id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at last_login, created_at, password_hash
FROM users WHERE id = ? FROM users WHERE id = ?
'); ');
$stmt->execute([$userId]); $stmt->execute([$userId]);
@@ -75,6 +75,14 @@ try {
// Update user // Update user
if (!empty($input['password'])) { if (!empty($input['password'])) {
// Overeni aktualniho hesla
if (empty($input['current_password'])) {
errorResponse('Pro změnu hesla je nutné zadat aktuální heslo');
}
if (!password_verify($input['current_password'], $existingUser['password_hash'])) {
errorResponse('Aktuální heslo není správné');
}
// Validate password length // Validate password length
if (strlen($input['password']) < 8) { if (strlen($input['password']) < 8) {
errorResponse('Heslo musí mít alespoň 8 znaků'); errorResponse('Heslo musí mít alespoň 8 znaků');

View File

@@ -579,6 +579,8 @@ function handleUpdateAttendance(PDO $pdo, int $recordId): void
$projectLogs = $input['project_logs'] ?? null; $projectLogs = $input['project_logs'] ?? null;
if ($projectLogs !== null) { if ($projectLogs !== null) {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?'); $stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
$stmt->execute([$recordId]); $stmt->execute([$recordId]);
@@ -601,6 +603,11 @@ function handleUpdateAttendance(PDO $pdo, int $recordId): void
$logStmt->execute([$recordId, $pid, $h, $m]); $logStmt->execute([$recordId, $pid, $h, $m]);
} }
} }
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
} }
AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky'); AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky');
@@ -621,6 +628,8 @@ function handleDeleteAttendance(PDO $pdo, int $recordId): void
errorResponse('Záznam nebyl nalezen', 404); errorResponse('Záznam nebyl nalezen', 404);
} }
$pdo->beginTransaction();
try {
$leaveType = $record['leave_type'] ?? 'work'; $leaveType = $record['leave_type'] ?? 'work';
$leaveHours = $record['leave_hours'] ?? 0; $leaveHours = $record['leave_hours'] ?? 0;
if ($leaveType !== 'work' && $leaveHours > 0) { if ($leaveType !== 'work' && $leaveHours > 0) {
@@ -633,6 +642,12 @@ function handleDeleteAttendance(PDO $pdo, int $recordId): void
$stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?'); $stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?');
$stmt->execute([$recordId]); $stmt->execute([$recordId]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky'); AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky');
successResponse(null, 'Záznam byl smazán'); successResponse(null, 'Záznam byl smazán');

View File

@@ -560,12 +560,12 @@ class NasFileManager
return false; return false;
} }
$attr = @exec('fsutil reparsepoint query "' . str_replace('/', '\\', $path) . '" 2>NUL'); // PHP is_link detekuje symlinky
if ($attr !== false && $attr !== '') { if (is_link($path)) {
return true; return true;
} }
// Fallback - realpath se lisi od puvodniho path u junction // Junction detekce pres porovnani realpath vs zadana cesta
$real = realpath($path); $real = realpath($path);
$normalized = str_replace('\\', '/', $path); $normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', (string) $real); $normalReal = str_replace('\\', '/', (string) $real);

View File

@@ -261,14 +261,17 @@ function handlePunch(PDO $pdo, int $userId): void
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null; $lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null; $lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
$accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null; $accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null;
$address = !empty($input['address']) ? $input['address'] : null; $address = !empty($input['address']) ? mb_substr($input['address'], 0, 500) : null;
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT id, user_id, shift_date, arrival_time, break_start, break_end, SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at departure_time, notes, project_id, leave_type, created_at
FROM attendance FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
FOR UPDATE
"); ");
$stmt->execute([$userId]); $stmt->execute([$userId]);
$ongoingShift = $stmt->fetch(); $ongoingShift = $stmt->fetch();
@@ -287,6 +290,7 @@ function handlePunch(PDO $pdo, int $userId): void
'location' => $address, 'location' => $address,
], 'Příchod zaznamenán'); ], 'Příchod zaznamenán');
$pdo->commit();
successResponse(null, 'Příchod zaznamenán'); successResponse(null, 'Příchod zaznamenán');
} elseif ($ongoingShift) { } elseif ($ongoingShift) {
switch ($action) { switch ($action) {
@@ -298,8 +302,10 @@ function handlePunch(PDO $pdo, int $userId): void
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); $stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
$pdo->commit();
successResponse(null, 'Pauza zaznamenána'); successResponse(null, 'Pauza zaznamenána');
} else { } else {
$pdo->rollBack();
errorResponse('Nelze zadat pauzu'); errorResponse('Nelze zadat pauzu');
} }
break; break;
@@ -326,7 +332,8 @@ function handlePunch(PDO $pdo, int $userId): void
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60))); $breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60))); $breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?'); $sql = 'UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]); $stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
} }
} }
@@ -350,18 +357,26 @@ function handlePunch(PDO $pdo, int $userId): void
'location' => $address, 'location' => $address,
], 'Odchod zaznamenán'); ], 'Odchod zaznamenán');
$pdo->commit();
successResponse(null, 'Odchod zaznamenán'); successResponse(null, 'Odchod zaznamenán');
} else { } else {
$pdo->rollBack();
errorResponse('Nelze zadat odchod'); errorResponse('Nelze zadat odchod');
} }
break; break;
default: default:
$pdo->rollBack();
errorResponse('Neplatná akce'); errorResponse('Neplatná akce');
} }
} else { } else {
$pdo->rollBack();
errorResponse('Neplatná akce - nemáte aktivní směnu'); errorResponse('Neplatná akce - nemáte aktivní směnu');
} }
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
} }
function handleUpdateAddress(PDO $pdo, int $userId): void function handleUpdateAddress(PDO $pdo, int $userId): void

View File

@@ -213,6 +213,8 @@ function handleGetList(PDO $pdo): void
c.name as customer_name, c.name as customer_name,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as vat_amount,
o.order_number o.order_number
{$from} {$where} {$from} {$where}
ORDER BY {$p['sort']} {$p['order']}", ORDER BY {$p['sort']} {$p['order']}",
@@ -222,20 +224,11 @@ function handleGetList(PDO $pdo): void
$invoices = $result['items']; $invoices = $result['items'];
// Dopocitat celkovou castku s DPH
foreach ($invoices as &$inv) { foreach ($invoices as &$inv) {
$subtotal = (float) $inv['subtotal']; $subtotal = (float) $inv['subtotal'];
if ($inv['apply_vat']) { $inv['total'] = $inv['apply_vat']
$vatStmt = $pdo->prepare(' ? $subtotal + (float) $inv['vat_amount']
SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0) : $subtotal;
FROM invoice_items WHERE invoice_id = ?
');
$vatStmt->execute([$inv['id']]);
$vatAmount = (float) $vatStmt->fetchColumn();
$inv['total'] = $subtotal + $vatAmount;
} else {
$inv['total'] = $subtotal;
}
} }
unset($inv); unset($inv);

View File

@@ -428,6 +428,7 @@ function handleDuplicate(PDO $pdo, int $sourceId): void
'description' => $item['description'], 'description' => $item['description'],
'item_description' => $item['item_description'], 'item_description' => $item['item_description'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit' => $item['unit'] ?? '',
'unit_price' => $item['unit_price'], 'unit_price' => $item['unit_price'],
'is_included_in_total' => $item['is_included_in_total'], 'is_included_in_total' => $item['is_included_in_total'],
'position' => $item['position'], 'position' => $item['position'],

View File

@@ -34,10 +34,10 @@ try {
$pdo = db(); $pdo = db();
$userId = $authData['user_id']; $userId = $authData['user_id'];
// Get existing user // Get existing user (vcetne password_hash pro overeni aktualniho hesla)
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT id, username, email, first_name, last_name, role_id, is_active, SELECT id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at last_login, created_at, password_hash
FROM users WHERE id = ? FROM users WHERE id = ?
'); ');
$stmt->execute([$userId]); $stmt->execute([$userId]);
@@ -75,6 +75,14 @@ try {
// Update user // Update user
if (!empty($input['password'])) { if (!empty($input['password'])) {
// Overeni aktualniho hesla
if (empty($input['current_password'])) {
errorResponse('Pro změnu hesla je nutné zadat aktuální heslo');
}
if (!password_verify($input['current_password'], $existingUser['password_hash'])) {
errorResponse('Aktuální heslo není správné');
}
// Validate password length // Validate password length
if (strlen($input['password']) < 8) { if (strlen($input['password']) < 8) {
errorResponse('Heslo musí mít alespoň 8 znaků'); errorResponse('Heslo musí mít alespoň 8 znaků');

View File

@@ -579,6 +579,8 @@ function handleUpdateAttendance(PDO $pdo, int $recordId): void
$projectLogs = $input['project_logs'] ?? null; $projectLogs = $input['project_logs'] ?? null;
if ($projectLogs !== null) { if ($projectLogs !== null) {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?'); $stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
$stmt->execute([$recordId]); $stmt->execute([$recordId]);
@@ -601,6 +603,11 @@ function handleUpdateAttendance(PDO $pdo, int $recordId): void
$logStmt->execute([$recordId, $pid, $h, $m]); $logStmt->execute([$recordId, $pid, $h, $m]);
} }
} }
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
} }
AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky'); AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky');
@@ -621,6 +628,8 @@ function handleDeleteAttendance(PDO $pdo, int $recordId): void
errorResponse('Záznam nebyl nalezen', 404); errorResponse('Záznam nebyl nalezen', 404);
} }
$pdo->beginTransaction();
try {
$leaveType = $record['leave_type'] ?? 'work'; $leaveType = $record['leave_type'] ?? 'work';
$leaveHours = $record['leave_hours'] ?? 0; $leaveHours = $record['leave_hours'] ?? 0;
if ($leaveType !== 'work' && $leaveHours > 0) { if ($leaveType !== 'work' && $leaveHours > 0) {
@@ -633,6 +642,12 @@ function handleDeleteAttendance(PDO $pdo, int $recordId): void
$stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?'); $stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?');
$stmt->execute([$recordId]); $stmt->execute([$recordId]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky'); AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky');
successResponse(null, 'Záznam byl smazán'); successResponse(null, 'Záznam byl smazán');

View File

@@ -560,12 +560,12 @@ class NasFileManager
return false; return false;
} }
$attr = @exec('fsutil reparsepoint query "' . str_replace('/', '\\', $path) . '" 2>NUL'); // PHP is_link detekuje symlinky
if ($attr !== false && $attr !== '') { if (is_link($path)) {
return true; return true;
} }
// Fallback - realpath se lisi od puvodniho path u junction // Junction detekce pres porovnani realpath vs zadana cesta
$real = realpath($path); $real = realpath($path);
$normalized = str_replace('\\', '/', $path); $normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', (string) $real); $normalReal = str_replace('\\', '/', (string) $real);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{j as e,m as f}from"./vendor-animation-0s3FMHwK.js";import{r as m}from"./vendor-react-BVs3cwbi.js";import{a9 as T}from"./vendor-utils-Dyr8OjFr.js";import{a as C,u as A,c as O,F as B,A as H}from"./index-fMsy8JiX.js";import{F as I}from"./Forbidden-D25jV3Oq.js";import{c as W,b as k,g as w,d as z,e as S,a as v,h as E,i as y,f as b}from"./attendanceHelpers-D6sLEw0q.js";const L="/api/admin",R=s=>s.break_start&&s.break_end?`${b(s.break_start)} - ${b(s.break_end)}`:s.break_start?`${b(s.break_start)} - ?`:"—",Z=s=>s.project_logs&&s.project_logs.length>0?e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:"0.125rem"},children:s.project_logs.map((n,g)=>{let d,c,o=!1;if(n.hours!==null&&n.hours!==void 0)d=parseInt(n.hours)||0,c=parseInt(n.minutes)||0;else{o=!n.ended_at;const x=n.ended_at?new Date(n.ended_at):new Date,p=Math.floor((x-new Date(n.started_at))/6e4);d=Math.floor(p/60),c=p%60}return e.jsxs("span",{className:"admin-badge",style:{fontSize:"0.7rem",display:"inline-block",background:o?"var(--accent-light)":void 0},children:[n.project_name||`#${n.project_id}`," (",d,":",String(c).padStart(2,"0"),"h",o?" ▸":"",")"]},n.id||g)})}):s.project_name?e.jsx("span",{className:"admin-badge admin-badge-wrap",style:{fontSize:"0.75rem"},children:s.project_name}):"—",Y=s=>s.overtime>0?e.jsxs("span",{className:"leave-badge badge-overtime",children:["+",s.overtime,"h přesčas"]}):s.remaining>0?e.jsxs("span",{style:{color:"#dc2626"},children:["",s.remaining,"h"]}):e.jsx("span",{style:{color:"#16a34a"},children:"splněno"});function Q(){const s=C(),{user:n,hasPermission:g}=A(),[d,c]=m.useState(!0),o=m.useRef(null),[x,p]=m.useState(()=>{const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}),[t,D]=m.useState({records:[],month_name:"",year:new Date().getFullYear(),total_minutes:0,vacation_hours:0,sick_hours:0,holiday_hours:0,unpaid_hours:0,leave_balance:null,monthly_fund:null}),_=m.useCallback(async()=>{c(!0);try{const a=await O(`${L}/attendance.php?action=history&month=${x}`);if(a.status===401)return;const i=await a.json();i.success&&D(i.data)}catch{s.error("Nepodařilo se načíst data")}finally{c(!1)}},[x,s]);if(m.useEffect(()=>{_()},[_]),!g("attendance.history"))return e.jsx(I,{});const $=()=>{if(!o.current)return;const a=window.open("","_blank");a.document.write(` import{j as e,m as f}from"./vendor-animation-0s3FMHwK.js";import{r as m}from"./vendor-react-BVs3cwbi.js";import{a9 as T}from"./vendor-utils-Dyr8OjFr.js";import{a as C,u as A,c as O,F as B,A as H}from"./index-CCZhiEoc.js";import{F as I}from"./Forbidden-D25jV3Oq.js";import{c as W,b as k,g as w,d as z,e as S,a as v,h as E,i as y,f as b}from"./attendanceHelpers-D6sLEw0q.js";const L="/api/admin",R=s=>s.break_start&&s.break_end?`${b(s.break_start)} - ${b(s.break_end)}`:s.break_start?`${b(s.break_start)} - ?`:"—",Z=s=>s.project_logs&&s.project_logs.length>0?e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:"0.125rem"},children:s.project_logs.map((n,g)=>{let d,c,o=!1;if(n.hours!==null&&n.hours!==void 0)d=parseInt(n.hours)||0,c=parseInt(n.minutes)||0;else{o=!n.ended_at;const x=n.ended_at?new Date(n.ended_at):new Date,p=Math.floor((x-new Date(n.started_at))/6e4);d=Math.floor(p/60),c=p%60}return e.jsxs("span",{className:"admin-badge",style:{fontSize:"0.7rem",display:"inline-block",background:o?"var(--accent-light)":void 0},children:[n.project_name||`#${n.project_id}`," (",d,":",String(c).padStart(2,"0"),"h",o?" ▸":"",")"]},n.id||g)})}):s.project_name?e.jsx("span",{className:"admin-badge admin-badge-wrap",style:{fontSize:"0.75rem"},children:s.project_name}):"—",Y=s=>s.overtime>0?e.jsxs("span",{className:"leave-badge badge-overtime",children:["+",s.overtime,"h přesčas"]}):s.remaining>0?e.jsxs("span",{style:{color:"#dc2626"},children:["",s.remaining,"h"]}):e.jsx("span",{style:{color:"#16a34a"},children:"splněno"});function Q(){const s=C(),{user:n,hasPermission:g}=A(),[d,c]=m.useState(!0),o=m.useRef(null),[x,p]=m.useState(()=>{const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}),[t,D]=m.useState({records:[],month_name:"",year:new Date().getFullYear(),total_minutes:0,vacation_hours:0,sick_hours:0,holiday_hours:0,unpaid_hours:0,leave_balance:null,monthly_fund:null}),_=m.useCallback(async()=>{c(!0);try{const a=await O(`${L}/attendance.php?action=history&month=${x}`);if(a.status===401)return;const i=await a.json();i.success&&D(i.data)}catch{s.error("Nepodařilo se načíst data")}finally{c(!1)}},[x,s]);if(m.useEffect(()=>{_()},[_]),!g("attendance.history"))return e.jsx(I,{});const $=()=>{if(!o.current)return;const a=window.open("","_blank");a.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
<head> <head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{j as e,m as p,A as Z}from"./vendor-animation-0s3FMHwK.js";import{r as i,L as J}from"./vendor-react-BVs3cwbi.js";import{a9 as G}from"./vendor-utils-Dyr8OjFr.js";import{a as q,u as Q,c as b,b as X,F as r,A as C,f as l,C as ee}from"./index-fMsy8JiX.js";import{F as se}from"./Forbidden-D25jV3Oq.js";import{b as $}from"./attendanceHelpers-D6sLEw0q.js";const N="/api/admin";function de(){const d=q(),{hasPermission:L}=Q(),[k,D]=i.useState(!0),[j,V]=i.useState(()=>{const s=new Date;return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-01`}),[g,A]=i.useState(()=>{const s=new Date,t=new Date(s.getFullYear(),s.getMonth()+1,0).getDate();return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(t).padStart(2,"0")}`}),[m,F]=i.useState(""),[h,E]=i.useState(""),[P,B]=i.useState({trips:[],vehicles:[],users:[],totals:{total:0,business:0,count:0}}),[n,I]=i.useState(null),w=i.useRef(null),[T,v]=i.useState(!1),[_,U]=i.useState(null),[a,o]=i.useState({vehicle_id:"",trip_date:"",start_km:"",end_km:"",route_from:"",route_to:"",is_business:1,notes:""}),[u,z]=i.useState({show:!1,trip:null}),y=i.useCallback(async(s=!0)=>{s&&D(!0);try{let t=`${N}/trips.php?action=admin&date_from=${j}&date_to=${g}`;m&&(t+=`&vehicle_id=${m}`),h&&(t+=`&user_id=${h}`);const c=await(await b(t)).json();c.success&&B(c.data)}catch{d.error("Nepodařilo se načíst data")}finally{s&&D(!1)}},[j,g,m,h,d]);if(i.useEffect(()=>{y()},[y]),X(T),!L("trips.admin"))return e.jsx(se,{});const H=s=>{U(s),o({vehicle_id:s.vehicle_id,trip_date:s.trip_date,start_km:s.start_km,end_km:s.end_km,route_from:s.route_from,route_to:s.route_to,is_business:s.is_business,notes:s.notes||""}),v(!0)},O=async()=>{if(parseInt(a.end_km)<=parseInt(a.start_km)){d.error("Konečný stav km musí být větší než počáteční");return}try{const t=await(await b(`${N}/trips.php?id=${_.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();t.success?(v(!1),await y(!1),await new Promise(x=>setTimeout(x,300)),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},W=async()=>{if(u.trip)try{const t=await(await b(`${N}/trips.php?id=${u.trip.id}`,{method:"DELETE"})).json();t.success?(z({show:!1,trip:null}),await y(!1),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},K=async()=>{try{let s=`${N}/trips.php?action=print&date_from=${j}&date_to=${g}`;m&&(s+=`&vehicle_id=${m}`),h&&(s+=`&user_id=${h}`);const x=await(await b(s)).json();x.success&&(I(x.data),setTimeout(()=>{if(w.current){const c=window.open("","_blank");c.document.write(` import{j as e,m as p,A as Z}from"./vendor-animation-0s3FMHwK.js";import{r as i,L as J}from"./vendor-react-BVs3cwbi.js";import{a9 as G}from"./vendor-utils-Dyr8OjFr.js";import{a as q,u as Q,c as b,b as X,F as r,A as C,f as l,C as ee}from"./index-CCZhiEoc.js";import{F as se}from"./Forbidden-D25jV3Oq.js";import{b as $}from"./attendanceHelpers-D6sLEw0q.js";const N="/api/admin";function de(){const d=q(),{hasPermission:L}=Q(),[k,D]=i.useState(!0),[j,V]=i.useState(()=>{const s=new Date;return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-01`}),[g,A]=i.useState(()=>{const s=new Date,t=new Date(s.getFullYear(),s.getMonth()+1,0).getDate();return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(t).padStart(2,"0")}`}),[m,F]=i.useState(""),[h,E]=i.useState(""),[P,B]=i.useState({trips:[],vehicles:[],users:[],totals:{total:0,business:0,count:0}}),[n,I]=i.useState(null),w=i.useRef(null),[T,v]=i.useState(!1),[_,U]=i.useState(null),[a,o]=i.useState({vehicle_id:"",trip_date:"",start_km:"",end_km:"",route_from:"",route_to:"",is_business:1,notes:""}),[u,z]=i.useState({show:!1,trip:null}),y=i.useCallback(async(s=!0)=>{s&&D(!0);try{let t=`${N}/trips.php?action=admin&date_from=${j}&date_to=${g}`;m&&(t+=`&vehicle_id=${m}`),h&&(t+=`&user_id=${h}`);const c=await(await b(t)).json();c.success&&B(c.data)}catch{d.error("Nepodařilo se načíst data")}finally{s&&D(!1)}},[j,g,m,h,d]);if(i.useEffect(()=>{y()},[y]),X(T),!L("trips.admin"))return e.jsx(se,{});const H=s=>{U(s),o({vehicle_id:s.vehicle_id,trip_date:s.trip_date,start_km:s.start_km,end_km:s.end_km,route_from:s.route_from,route_to:s.route_to,is_business:s.is_business,notes:s.notes||""}),v(!0)},O=async()=>{if(parseInt(a.end_km)<=parseInt(a.start_km)){d.error("Konečný stav km musí být větší než počáteční");return}try{const t=await(await b(`${N}/trips.php?id=${_.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();t.success?(v(!1),await y(!1),await new Promise(x=>setTimeout(x,300)),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},W=async()=>{if(u.trip)try{const t=await(await b(`${N}/trips.php?id=${u.trip.id}`,{method:"DELETE"})).json();t.success?(z({show:!1,trip:null}),await y(!1),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},K=async()=>{try{let s=`${N}/trips.php?action=print&date_from=${j}&date_to=${g}`;m&&(s+=`&vehicle_id=${m}`),h&&(s+=`&user_id=${h}`);const x=await(await b(s)).json();x.success&&(I(x.data),setTimeout(()=>{if(w.current){const c=window.open("","_blank");c.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
<head> <head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{j as x}from"./vendor-animation-0s3FMHwK.js";import{r as t}from"./vendor-react-BVs3cwbi.js";import{a as L,c as O}from"./index-fMsy8JiX.js";function J({column:e,sort:r,order:n}){return r!==e?null:x.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",style:{marginLeft:4,verticalAlign:"middle"},children:x.jsx("path",{d:n==="ASC"?"M18 15l-6-6-6 6":"M6 9l6 6 6-6"})})}function V(e,r="DESC"){const[n,a]=t.useState(e),[o,c]=t.useState(r),i=t.useRef(!1),S=t.useCallback(u=>{i.current=!0,a(m=>m===u?(c(h=>h==="ASC"?"DESC":"ASC"),m):(c("DESC"),u))},[]),d=i.current?n:null;return{sort:n,order:o,handleSort:S,activeSort:d}}function I(e,r=300){const[n,a]=t.useState(e);return t.useEffect(()=>{const o=setTimeout(()=>a(e),r);return()=>clearTimeout(o)},[e,r]),n}const N="/api/admin";function _(e,{dataKey:r,search:n,sort:a,order:o,page:c,perPage:i,extraParams:S,errorMsg:d="Nepodařilo se načíst data"}={}){const u=L(),[m,h]=t.useState([]),[j,D]=t.useState(!0),[w,k]=t.useState(null),l=t.useRef(null),p=S?JSON.stringify(S):"",b=I(n,300),C=t.useCallback(async()=>{l.current&&l.current.abort();const g=new AbortController;l.current=g;try{const s=new URLSearchParams;if(b&&s.set("search",b),a&&s.set("sort",a),o&&s.set("order",o),c&&s.set("page",c),i&&s.set("per_page",i),p){const R=JSON.parse(p);Object.entries(R).forEach(([y,A])=>{A&&s.set(y,A)})}const E=await O(`${N}/${e}?${s}`,{signal:g.signal});if(E.status===401)return;const f=await E.json();f.success?(h(f.data[r]||[]),f.data.pagination&&k(f.data.pagination)):u.error(f.error||d)}catch(s){if(s.name==="AbortError")return;u.error("Chyba připojení")}finally{D(!1)}},[u,e,r,b,a,o,c,i,p,d]);return t.useEffect(()=>(C(),()=>{l.current&&l.current.abort()}),[C]),{items:m,setItems:h,loading:j,pagination:w,refetch:C}}export{J as S,_ as a,V as u}; import{j as x}from"./vendor-animation-0s3FMHwK.js";import{r as t}from"./vendor-react-BVs3cwbi.js";import{a as L,c as O}from"./index-CCZhiEoc.js";function J({column:e,sort:r,order:n}){return r!==e?null:x.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",style:{marginLeft:4,verticalAlign:"middle"},children:x.jsx("path",{d:n==="ASC"?"M18 15l-6-6-6 6":"M6 9l6 6 6-6"})})}function V(e,r="DESC"){const[n,a]=t.useState(e),[o,c]=t.useState(r),i=t.useRef(!1),S=t.useCallback(u=>{i.current=!0,a(m=>m===u?(c(h=>h==="ASC"?"DESC":"ASC"),m):(c("DESC"),u))},[]),d=i.current?n:null;return{sort:n,order:o,handleSort:S,activeSort:d}}function I(e,r=300){const[n,a]=t.useState(e);return t.useEffect(()=>{const o=setTimeout(()=>a(e),r);return()=>clearTimeout(o)},[e,r]),n}const N="/api/admin";function _(e,{dataKey:r,search:n,sort:a,order:o,page:c,perPage:i,extraParams:S,errorMsg:d="Nepodařilo se načíst data"}={}){const u=L(),[m,h]=t.useState([]),[j,D]=t.useState(!0),[w,k]=t.useState(null),l=t.useRef(null),p=S?JSON.stringify(S):"",b=I(n,300),C=t.useCallback(async()=>{l.current&&l.current.abort();const g=new AbortController;l.current=g;try{const s=new URLSearchParams;if(b&&s.set("search",b),a&&s.set("sort",a),o&&s.set("order",o),c&&s.set("page",c),i&&s.set("per_page",i),p){const R=JSON.parse(p);Object.entries(R).forEach(([y,A])=>{A&&s.set(y,A)})}const E=await O(`${N}/${e}?${s}`,{signal:g.signal});if(E.status===401)return;const f=await E.json();f.success?(h(f.data[r]||[]),f.data.pagination&&k(f.data.pagination)):u.error(f.error||d)}catch(s){if(s.name==="AbortError")return;u.error("Chyba připojení")}finally{D(!1)}},[u,e,r,b,a,o,c,i,p,d]);return t.useEffect(()=>(C(),()=>{l.current&&l.current.abort()}),[C]),{items:m,setItems:h,loading:j,pagination:w,refetch:C}}export{J as S,_ as a,V as u};

2
dist/index.html vendored
View File

@@ -29,7 +29,7 @@
<link <link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap"
rel="stylesheet" /> rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-fMsy8JiX.js"></script> <script type="module" crossorigin src="/assets/index-CCZhiEoc.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BVs3cwbi.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-react-BVs3cwbi.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-animation-0s3FMHwK.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-animation-0s3FMHwK.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-utils-Dyr8OjFr.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-utils-Dyr8OjFr.js">

View File

@@ -22,7 +22,7 @@ export default function DashProfile({
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', email: '', password: '', first_name: '', last_name: '' username: '', email: '', password: '', current_password: '', first_name: '', last_name: ''
}) })
useModalLock(showModal) useModalLock(showModal)
@@ -33,6 +33,7 @@ export default function DashProfile({
username: user?.username || '', username: user?.username || '',
email: user?.email || '', email: user?.email || '',
password: '', password: '',
current_password: '',
first_name: nameParts[0] || '', first_name: nameParts[0] || '',
last_name: nameParts.slice(1).join(' ') || '' last_name: nameParts.slice(1).join(' ') || ''
}) })
@@ -179,6 +180,12 @@ export default function DashProfile({
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label> <label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
<input type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} className="admin-form-input" /> <input type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} className="admin-form-input" />
</div> </div>
{formData.password && (
<div className="admin-form-group">
<label className="admin-form-label required">Aktuální heslo</label>
<input type="password" value={formData.current_password} onChange={(e) => setFormData({ ...formData, current_password: e.target.value })} className="admin-form-input" placeholder="Zadejte aktuální heslo pro potvrzení" />
</div>
)}
</div> </div>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">