From bb2bbb8ff6e91df85a403b3b8f36d936486824de Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 12 Mar 2026 17:33:37 +0100 Subject: [PATCH] feat: mobilni responsivita, testy, klavesove zkratky, drag & drop, univerzalizace - Mobile responsive CSS (touch targets 44px, iOS anti-zoom, reduced motion) - Vitest setup s 39 testy (formatters, attendanceHelpers, useTableSort) - Klavesove zkratky (Shift+? napoveda, Ctrl+S ulozit, navigace) - Drag & drop pro polozky nabidek (@dnd-kit, SortableRow, useSortableList) - Univerzalizace: odstraneni BOHA brandingu z UI, emailu, PDF - Smazany nepotrebne soubory (deploy.sh, AUTH_SYSTEM.md, example_design, .htaccess) - CORS konfigurovatelny pres env promennou Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 10 +- CLAUDE.md | 1 + api/admin/invoices-pdf.php | 4 +- api/admin/offers-pdf.php | 2 +- api/admin/totp.php | 2 +- api/config.php | 2 +- api/includes/AuditLog.php | 2 +- api/includes/JWTAuth.php | 2 +- api/includes/LeaveNotification.php | 9 +- api/includes/Mailer.php | 4 +- api/includes/constants.php | 9 +- deploy.sh | 33 - docs/AUTH_SYSTEM.md | 2621 ----------------- example_design/boha-spa.html | 1519 ---------- index.html | 4 +- package-lock.json | 1856 +++++++++++- package.json | 15 +- public/.htaccess | 81 - public/site.webmanifest | 4 +- src/admin/admin.css | 197 ++ src/admin/components/AdminLayout.jsx | 2 + src/admin/components/OfferItemsSection.jsx | 217 +- src/admin/components/ShortcutsHelp.jsx | 50 + src/admin/components/SortableRow.jsx | 53 + .../hooks/__tests__/useTableSort.test.js | 65 + src/admin/hooks/useKeyboardShortcuts.js | 36 + src/admin/hooks/useSortableList.js | 29 + src/admin/invoices.css | 15 + src/admin/pages/Login.jsx | 2 +- src/admin/pages/OfferDetail.jsx | 12 +- src/admin/settings.css | 10 + .../utils/__tests__/attendanceHelpers.test.js | 132 + src/admin/utils/__tests__/formatters.test.js | 102 + src/test/setup.js | 1 + vite.config.js | 5 + 35 files changed, 2716 insertions(+), 4392 deletions(-) delete mode 100644 deploy.sh delete mode 100644 docs/AUTH_SYSTEM.md delete mode 100644 example_design/boha-spa.html delete mode 100644 public/.htaccess create mode 100644 src/admin/components/ShortcutsHelp.jsx create mode 100644 src/admin/components/SortableRow.jsx create mode 100644 src/admin/hooks/__tests__/useTableSort.test.js create mode 100644 src/admin/hooks/useKeyboardShortcuts.js create mode 100644 src/admin/hooks/useSortableList.js create mode 100644 src/admin/utils/__tests__/attendanceHelpers.test.js create mode 100644 src/admin/utils/__tests__/formatters.test.js create mode 100644 src/test/setup.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3b2ae90..3fba635 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,15 @@ "Read(//tmp/**)", "Bash(read f:*)", "Bash(find D:\\\\Weby\\\\BOHA Website\\\\New\\\\api:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && npx vite build --mode development 2>&1 | grep -E \"built|error|✓\" | head -10)", + "Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && for f in api/admin/*.php; do php -l \"$f\" 2>&1; done)", + "Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && for f in api/admin/handlers/*.php; do php -l \"$f\" 2>&1; done)", + "Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && \\\\\necho \"=== session \\(no auth\\) ===\" && \\\\\ncurl -s http://localhost:8000/api/admin/session.php 2>&1 && \\\\\necho && echo \"=== refresh \\(no cookie\\) ===\" && \\\\\ncurl -s -X POST http://localhost:8000/api/admin/refresh.php 2>&1 && \\\\\necho && echo \"=== logout \\(no auth\\) ===\" && \\\\\ncurl -s -X POST http://localhost:8000/api/admin/logout.php 2>&1)", + "Bash(for f:*)", + "Bash(do echo:*)", + "Read(//d/Claude/BOHA Website/app/**)", + "Bash(done)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 284cea7..d00d0e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Před začátkem práce si načti relevantní soubory z `memory/`: - **Vite proxy:** `/api` -> `http://localhost:8000` - **npm:** při instalaci balíčků vždy `--legacy-peer-deps` - **Git remote:** https://git.boha-automation.cz/boha_admin/app.git +- **Git credentials:** uloženy v `~/.git-credentials` (credential.helper=store), push funguje bez zadávání hesla --- diff --git a/api/admin/invoices-pdf.php b/api/admin/invoices-pdf.php index 898acff..7fdb56a 100644 --- a/api/admin/invoices-pdf.php +++ b/api/admin/invoices-pdf.php @@ -1,7 +1,7 @@ " . htmlspecialchars($notes) . ' ' : '') . " + " . (env('APP_URL', '') ? "

- Přejít ke schvalování -

+

" : "") . "

- Tato zpráva byla automaticky vygenerována systémem BOHA Automation.
+ Tato zpráva byla automaticky vygenerována systémem.
Datum: " . date('d.m.Y H:i:s') . '

diff --git a/api/includes/Mailer.php b/api/includes/Mailer.php index ca53055..2584d47 100644 --- a/api/includes/Mailer.php +++ b/api/includes/Mailer.php @@ -1,7 +1,7 @@ /dev/null || echo "000") -if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then - echo "=== Deploy OK (HTTP $HTTP_CODE) ===" -else - echo "WARNING: Health check returned HTTP $HTTP_CODE (Apache may not be running)" -fi diff --git a/docs/AUTH_SYSTEM.md b/docs/AUTH_SYSTEM.md deleted file mode 100644 index d6f1d81..0000000 --- a/docs/AUTH_SYSTEM.md +++ /dev/null @@ -1,2621 +0,0 @@ -# BOHA Automation - Authentication System Documentation - -Complete reference documentation for the BOHA authentication system, covering PHP backend and React frontend implementation. - ---- - -## Table of Contents - -1. [Overview](#1-overview) -2. [Configuration](#2-configuration) -3. [Database Tables](#3-database-tables) -4. [Complete Code Listings](#4-complete-code-listings) -5. [Security Features](#5-security-features) -6. [Authentication Flows](#6-authentication-flows) -7. [API Endpoints](#7-api-endpoints) - ---- - -## 1. Overview - -The BOHA authentication system is a session-based authentication mechanism with the following architecture: - -``` -Frontend (React) Backend (PHP) ------------------ --------------- -AuthContext.jsx <-- HTTP --> Auth.php (Core logic) -Login.jsx login.php (Endpoint) -AdminLayout.jsx logout.php (Endpoint) -api.js session.php (Endpoint) - csrf.php (Endpoint) - config.php (Configuration) - RateLimiter.php (Rate limiting) -``` - -### Key Features - -- Database-backed sessions stored in `user_sessions` table -- Secure session configuration (HttpOnly, Secure, SameSite cookies) -- Session regeneration on login to prevent session fixation -- Remember me functionality with secure token rotation (selector:token pattern) -- Brute force protection with account lockout -- CSRF protection with token rotation on login -- IP-based rate limiting per endpoint -- Security headers on all responses -- Sliding session expiration -- Automatic cleanup of expired sessions and tokens - ---- - -## 2. Configuration - -All authentication constants are defined in `api/config.php`: - -| Constant | Value | Description | -|----------|-------|-------------| -| `SESSION_NAME` | `'BOHA_ADMIN_SESSION'` | PHP session cookie name | -| `SESSION_LIFETIME_MINUTES` | `60` | Session expires after 60 minutes of inactivity | -| `REMEMBER_ME_DAYS` | `30` | Remember me token valid for 30 days | -| `MAX_LOGIN_ATTEMPTS` | `5` | Lock account after 5 failed attempts | -| `LOCKOUT_MINUTES` | `15` | Account lock duration in minutes | -| `BCRYPT_COST` | `12` | Bcrypt cost factor for password hashing | -| `RATE_LIMIT_STORAGE_PATH` | `__DIR__ . '/rate_limits'` | Directory for rate limit files | - -### CORS Configuration - -```php -define('CORS_ALLOWED_ORIGINS', [ - 'http://localhost:3000', - 'http://localhost:3001', - 'http://127.0.0.1:3000', - 'http://www.boha-automation.cz', - 'https://www.boha-automation.cz' -]); -``` - ---- - -## 3. Database Tables - -### users - -```sql -CREATE TABLE users ( - id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - first_name VARCHAR(100), - last_name VARCHAR(100), - role_id INT NOT NULL, - is_active TINYINT(1) DEFAULT 1, - failed_login_attempts INT DEFAULT 0, - locked_until DATETIME NULL, - last_login DATETIME NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (role_id) REFERENCES roles(id) -); -``` - -### roles - -```sql -CREATE TABLE roles ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE, - display_name VARCHAR(100), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Default roles -INSERT INTO roles (name, display_name) VALUES -('admin', 'Administrator'), -('user', 'User'); -``` - -### user_sessions - -```sql -CREATE TABLE user_sessions ( - id VARCHAR(128) PRIMARY KEY, -- PHP session ID - user_id INT NOT NULL, - ip_address VARCHAR(45), - user_agent VARCHAR(255), - expires_at DATETIME NOT NULL, - is_active TINYINT(1) DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at); -CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); -``` - -### remember_me_tokens - -```sql -CREATE TABLE remember_me_tokens ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - selector VARCHAR(32) NOT NULL UNIQUE, -- Public identifier - token_hash VARCHAR(64) NOT NULL, -- SHA-256 hash of token - expires_at DATETIME NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX idx_remember_selector ON remember_me_tokens(selector); -CREATE INDEX idx_remember_expires ON remember_me_tokens(expires_at); -``` - ---- - -## 4. Complete Code Listings - -### 4.1 api/config.php - -```php - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . DB_CHARSET - ]; - - try { - $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); - } catch (PDOException $e) { - if (DEBUG_MODE) { - throw $e; - } - error_log("Database connection failed: " . $e->getMessage()); - throw new PDOException("Database connection failed"); - } - } - - return $pdo; -} - -/** - * Set CORS headers for API responses - */ -function setCorsHeaders(): void { - $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; - - if (in_array($origin, CORS_ALLOWED_ORIGINS)) { - header("Access-Control-Allow-Origin: $origin"); - } else { - // Allow localhost for development - header("Access-Control-Allow-Origin: http://localhost:3000"); - } - - header('Access-Control-Allow-Credentials: true'); - header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); - header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); - header('Access-Control-Max-Age: 86400'); - - // Handle preflight requests - if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - http_response_code(200); - exit(); - } -} - -/** - * Send JSON response and exit - * - * @param mixed $data Data to send - * @param int $statusCode HTTP status code - */ -function jsonResponse($data, int $statusCode = 200): void { - http_response_code($statusCode); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode($data, JSON_UNESCAPED_UNICODE); - exit(); -} - -/** - * Send error response - * - * @param string $message Error message - * @param int $statusCode HTTP status code - */ -function errorResponse(string $message, int $statusCode = 400): void { - jsonResponse(['success' => false, 'error' => $message], $statusCode); -} - -/** - * Send success response - * - * @param mixed $data Data to include - * @param string $message Optional message - */ -function successResponse($data = null, string $message = ''): void { - $response = ['success' => true]; - if ($message) { - $response['message'] = $message; - } - if ($data !== null) { - $response['data'] = $data; - } - jsonResponse($response); -} - -/** - * Get JSON request body - * - * @return array Decoded JSON data - */ -function getJsonInput(): array { - $input = file_get_contents('php://input'); - $data = json_decode($input, true); - return is_array($data) ? $data : []; -} - -/** - * Sanitize string input - * - * @param string $input Input string - * @return string Sanitized string - */ -function sanitize(string $input): string { - return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8'); -} - -/** - * Validate email format - * - * @param string $email Email to validate - * @return bool True if valid - */ -function isValidEmail(string $email): bool { - return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; -} - -/** - * Generate CSRF token - * - * @return string CSRF token - */ -function generateCsrfToken(): string { - if (empty($_SESSION['csrf_token'])) { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - } - return $_SESSION['csrf_token']; -} - -/** - * Verify CSRF token - * - * @param string $token Token to verify - * @return bool True if valid - */ -function verifyCsrfToken(string $token): bool { - return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); -} - -/** - * Get client IP address - * - * Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP). - * If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future, - * update this function to trust specific proxy headers only from known proxy IPs. - * - * @return string IP address - */ -function getClientIp(): string { - return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; -} - -/** - * Set security headers for API responses - * - * Sets standard security headers to protect against common web vulnerabilities: - * - X-Content-Type-Options: Prevents MIME type sniffing - * - X-Frame-Options: Prevents clickjacking attacks - * - X-XSS-Protection: Enables browser XSS filter - * - Referrer-Policy: Controls referrer information sent with requests - * - * Note: Content-Security-Policy is not set here as it may interfere with the React frontend - */ -function setSecurityHeaders(): void { - header('X-Content-Type-Options: nosniff'); - header('X-Frame-Options: DENY'); - header('X-XSS-Protection: 1; mode=block'); - header('Referrer-Policy: strict-origin-when-cross-origin'); -} - -/** - * Set no-cache headers - * - * Prevents browser caching for sensitive endpoints - */ -function setNoCacheHeaders(): void { - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); -} -``` - -### 4.2 api/includes/Auth.php - -```php - 0, - 'path' => '/', - 'domain' => '', - 'secure' => $secure, - 'httponly' => true, - 'samesite' => 'Lax' - ]); - - session_start(); - } - - self::$initialized = true; - - // Cleanup expired sessions occasionally (1% of requests) - if (rand(1, 100) === 1) { - self::cleanupExpiredSessions(); - } - } - - /** - * Attempt user login - * - * @param string $username Username or email - * @param string $password Plain text password - * @param bool $remember Create remember me token - * @return array Result with success status and error code if failed - */ - public static function login(string $username, string $password, bool $remember = false): array { - try { - $pdo = db(); - - // Find user by username or email - $stmt = $pdo->prepare(" - SELECT u.*, r.name as role_name, r.display_name as role_display_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - WHERE u.username = ? OR u.email = ? - "); - $stmt->execute([$username, $username]); - $user = $stmt->fetch(); - - if (!$user) { - return ['success' => false, 'error' => 'invalid_credentials']; - } - - // Check if account is locked - if ($user['locked_until'] && strtotime($user['locked_until']) > time()) { - $remaining = ceil((strtotime($user['locked_until']) - time()) / 60); - return ['success' => false, 'error' => 'account_locked', 'minutes' => $remaining]; - } - - // Verify password - if (!password_verify($password, $user['password_hash'])) { - self::incrementFailedAttempts($user['id'], $user['failed_login_attempts']); - return ['success' => false, 'error' => 'invalid_credentials']; - } - - // Check if account is active - if (!$user['is_active']) { - return ['success' => false, 'error' => 'account_deactivated']; - } - - // Successful login - regenerate session ID - session_regenerate_id(true); - - // Force new CSRF token generation post-login - // This prevents Login CSRF attacks where an attacker could use a pre-login CSRF token for post-login actions - unset($_SESSION['csrf_token']); - - // Create session data - self::createUserSession($user); - - // Store session in database (with longer expiry if remember me) - self::storeSessionInDatabase($user['id'], $remember); - - // Create remember me token if requested - if ($remember) { - self::createRememberMeToken($user['id']); - } - - // Reset failed attempts and update last login - $stmt = $pdo->prepare(" - UPDATE users - SET last_login = NOW(), failed_login_attempts = 0, locked_until = NULL - WHERE id = ? - "); - $stmt->execute([$user['id']]); - - return [ - 'success' => true, - 'user' => self::getUserData() - ]; - - } catch (PDOException $e) { - error_log("Auth login error: " . $e->getMessage()); - return ['success' => false, 'error' => 'system_error']; - } - } - - /** - * Increment failed login attempts - */ - private static function incrementFailedAttempts(int $userId, int $currentAttempts): void { - try { - $pdo = db(); - $attempts = $currentAttempts + 1; - $lockedUntil = null; - - if ($attempts >= MAX_LOGIN_ATTEMPTS) { - $lockedUntil = date('Y-m-d H:i:s', time() + (LOCKOUT_MINUTES * 60)); - } - - $stmt = $pdo->prepare(" - UPDATE users - SET failed_login_attempts = ?, locked_until = ? - WHERE id = ? - "); - $stmt->execute([$attempts, $lockedUntil, $userId]); - - } catch (PDOException $e) { - error_log("Auth increment attempts error: " . $e->getMessage()); - } - } - - /** - * Create user session data - */ - private static function createUserSession(array $user): void { - $_SESSION['auth'] = [ - 'user_id' => $user['id'], - 'username' => $user['username'], - 'email' => $user['email'], - 'full_name' => trim($user['first_name'] . ' ' . $user['last_name']), - 'role' => $user['role_name'], - 'role_display' => $user['role_display_name'] ?? $user['role_name'], - 'login_time' => time(), - 'last_activity' => time(), - 'ip' => getClientIp(), - 'session_token' => bin2hex(random_bytes(16)) - ]; - - self::$user = $user; - } - - /** - * Store session in database for tracking - * - * @param int $userId User ID - * @param bool $remember If true, session expires in 30 days instead of 1 hour - * @param string|null $expiresAt Optional specific expiry datetime - */ - private static function storeSessionInDatabase(int $userId, bool $remember = false, ?string $expiresAt = null): void { - try { - $pdo = db(); - $sessionId = session_id(); - - // Use provided expiry or calculate based on remember flag - if ($expiresAt === null) { - $expirySeconds = $remember ? (REMEMBER_ME_DAYS * 86400) : (SESSION_LIFETIME_MINUTES * 60); - $expiresAt = date('Y-m-d H:i:s', time() + $expirySeconds); - } - - // Delete existing session with same ID - $stmt = $pdo->prepare("DELETE FROM user_sessions WHERE id = ?"); - $stmt->execute([$sessionId]); - - // Insert new session - $stmt = $pdo->prepare(" - INSERT INTO user_sessions (id, user_id, ip_address, user_agent, expires_at, is_active) - VALUES (?, ?, ?, ?, ?, 1) - "); - $stmt->execute([ - $sessionId, - $userId, - getClientIp(), - substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 255), - $expiresAt - ]); - - } catch (PDOException $e) { - error_log("Auth store session error: " . $e->getMessage()); - } - } - - /** - * Create remember me token - */ - private static function createRememberMeToken(int $userId): void { - try { - $pdo = db(); - - $selector = bin2hex(random_bytes(16)); - $token = bin2hex(random_bytes(32)); - $tokenHash = hash('sha256', $token); - $expiresAt = date('Y-m-d H:i:s', time() + (REMEMBER_ME_DAYS * 86400)); - - // Each device gets its own token - expired tokens are cleaned up when used or periodically - // Insert new token - $stmt = $pdo->prepare(" - INSERT INTO remember_me_tokens (user_id, selector, token_hash, expires_at) - VALUES (?, ?, ?, ?) - "); - $stmt->execute([$userId, $selector, $tokenHash, $expiresAt]); - - // Set cookie - $cookieValue = $selector . ':' . $token; - $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; - - setcookie('boha_remember', $cookieValue, [ - 'expires' => time() + (REMEMBER_ME_DAYS * 86400), - 'path' => '/', - 'domain' => '', - 'secure' => $secure, - 'httponly' => true, - 'samesite' => 'Lax' - ]); - - } catch (PDOException $e) { - error_log("Auth remember me token error: " . $e->getMessage()); - } - } - - /** - * Check if session is valid - * - * @return bool True if authenticated - */ - public static function check(): bool { - $auth = $_SESSION['auth'] ?? null; - - if ($auth && isset($auth['user_id']) && self::validateDatabaseSession($auth['user_id'])) { - self::updateSessionActivity($auth['user_id']); - return true; - } - - // Session invalid - try to restore from remember me cookie - if (isset($_COOKIE['boha_remember']) && self::restoreFromRememberMe()) { - return true; - } - - // Both session and remember me invalid - clear everything - self::clearSessionData(); - - return false; - } - - /** - * Validate session exists in database - * - * Also verifies that the User-Agent matches what was stored when the session - * was created. This prevents session hijacking - if an attacker steals the - * session cookie but uses a different browser, the session will be invalid. - */ - private static function validateDatabaseSession(int $userId): bool { - try { - $pdo = db(); - $sessionId = session_id(); - - $stmt = $pdo->prepare(" - SELECT id FROM user_sessions - WHERE id = ? - AND user_id = ? - AND user_agent = ? - AND is_active = 1 - AND expires_at > NOW() - "); - $userAgent = substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 255); - $stmt->execute([$sessionId, $userId, $userAgent]); - - return $stmt->fetch() !== false; - - } catch (PDOException $e) { - error_log("Auth validate session error: " . $e->getMessage()); - return false; - } - } - - /** - * Update session activity timestamp - */ - private static function updateSessionActivity(int $userId): void { - $_SESSION['auth']['last_activity'] = time(); - - // Update expiry (rate limited to once per request) - static $updated = false; - if ($updated) { - return; - } - $updated = true; - - try { - $pdo = db(); - $hasRememberMe = isset($_COOKIE['boha_remember']); - - if ($hasRememberMe) { - // With remember me: only update remember_me_tokens expiry, not user_sessions - $parts = explode(':', $_COOKIE['boha_remember']); - if (count($parts) === 2) { - $selector = $parts[0]; - $tokenExpiresAt = date('Y-m-d H:i:s', time() + (REMEMBER_ME_DAYS * 86400)); - $stmt = $pdo->prepare("UPDATE remember_me_tokens SET expires_at = ? WHERE selector = ?"); - $stmt->execute([$tokenExpiresAt, $selector]); - } - } else { - // Without remember me: update user_sessions expiry (sliding 1-hour window) - $expiresAt = date('Y-m-d H:i:s', time() + (SESSION_LIFETIME_MINUTES * 60)); - $stmt = $pdo->prepare(" - UPDATE user_sessions - SET expires_at = ? - WHERE id = ? AND user_id = ? - "); - $stmt->execute([$expiresAt, session_id(), $userId]); - } - - } catch (PDOException $e) { - error_log("Auth update activity error: " . $e->getMessage()); - } - } - - /** - * Restore session from remember me cookie - */ - private static function restoreFromRememberMe(): bool { - $cookie = $_COOKIE['boha_remember'] ?? ''; - $parts = explode(':', $cookie); - - if (count($parts) !== 2) { - self::deleteRememberMeCookie(); - return false; - } - - [$selector, $token] = $parts; - - try { - $pdo = db(); - - $stmt = $pdo->prepare(" - SELECT rt.*, u.*, r.name as role_name, r.display_name as role_display_name - FROM remember_me_tokens rt - JOIN users u ON rt.user_id = u.id - LEFT JOIN roles r ON u.role_id = r.id - WHERE rt.selector = ? AND rt.expires_at > NOW() - "); - $stmt->execute([$selector]); - $data = $stmt->fetch(); - - if (!$data) { - // Delete the expired token from database if it exists - $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); - $stmt->execute([$selector]); - self::deleteRememberMeCookie(); - return false; - } - - // Verify token hash - $tokenHash = hash('sha256', $token); - if (!hash_equals($data['token_hash'], $tokenHash)) { - // Token mismatch - possible theft, delete all tokens for user - $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE user_id = ?"); - $stmt->execute([$data['user_id']]); - self::deleteRememberMeCookie(); - return false; - } - - // Check if user is still active - if (!$data['is_active']) { - self::deleteRememberMeCookie(); - return false; - } - - // Token rotation: Delete the old token - $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); - $stmt->execute([$selector]); - - // Create a new token for security (single-use tokens) - self::createRememberMeToken($data['user_id']); - - // Update the expired session with new expiry from remember me token - $sessionId = session_id(); - $stmt = $pdo->prepare("UPDATE user_sessions SET expires_at = ? WHERE id = ? AND user_id = ?"); - $stmt->execute([$data['expires_at'], $sessionId, $data['user_id']]); - - // Recreate session data - self::createUserSession($data); - - return true; - - } catch (PDOException $e) { - error_log("Auth restore remember me error: " . $e->getMessage()); - return false; - } - } - - /** - * Logout user - */ - public static function logout(): void { - $auth = $_SESSION['auth'] ?? null; - $sessionId = session_id(); - - if ($auth && isset($auth['user_id'])) { - try { - $pdo = db(); - - // Delete session from database - $stmt = $pdo->prepare("DELETE FROM user_sessions WHERE id = ?"); - $stmt->execute([$sessionId]); - - // Delete remember me token if exists - if (isset($_COOKIE['boha_remember'])) { - $parts = explode(':', $_COOKIE['boha_remember']); - if (count($parts) === 2) { - $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); - $stmt->execute([$parts[0]]); - } - } - - } catch (PDOException $e) { - error_log("Auth logout error: " . $e->getMessage()); - } - } - - // Delete remember me cookie - self::deleteRememberMeCookie(); - - // Clear session - $_SESSION = []; - - // Delete session cookie - if (isset($_COOKIE[session_name()])) { - setcookie(session_name(), '', [ - 'expires' => time() - 3600, - 'path' => '/', - 'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', - 'httponly' => true, - 'samesite' => 'Lax' - ]); - } - - session_destroy(); - - // Cleanup expired sessions - self::cleanupExpiredSessions(); - } - - /** - * Clear session data and remember me token - */ - private static function clearSessionData(): void { - $sessionId = session_id(); - - try { - $pdo = db(); - $stmt = $pdo->prepare("DELETE FROM user_sessions WHERE id = ?"); - $stmt->execute([$sessionId]); - - // Also delete remember me token if exists - if (isset($_COOKIE['boha_remember'])) { - $parts = explode(':', $_COOKIE['boha_remember']); - if (count($parts) === 2) { - $stmt = $pdo->prepare("DELETE FROM remember_me_tokens WHERE selector = ?"); - $stmt->execute([$parts[0]]); - } - } - } catch (PDOException $e) { - // Ignore cleanup errors - } - - // Delete remember me cookie - self::deleteRememberMeCookie(); - - unset($_SESSION['auth']); - self::$user = null; - } - - /** - * Delete remember me cookie - */ - private static function deleteRememberMeCookie(): void { - if (isset($_COOKIE['boha_remember'])) { - setcookie('boha_remember', '', [ - 'expires' => time() - 3600, - 'path' => '/', - 'domain' => '', - 'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', - 'httponly' => true, - 'samesite' => 'Lax' - ]); - unset($_COOKIE['boha_remember']); - } - } - - /** - * Cleanup expired sessions and tokens - */ - public static function cleanupExpiredSessions(): void { - try { - $pdo = db(); - - $pdo->exec("DELETE FROM user_sessions WHERE expires_at < NOW()"); - $pdo->exec("DELETE FROM remember_me_tokens WHERE expires_at < NOW()"); - - } catch (PDOException $e) { - error_log("Auth cleanup error: " . $e->getMessage()); - } - } - - /** - * Check if user is logged in - */ - public static function isLoggedIn(): bool { - return isset($_SESSION['auth']['user_id']) && $_SESSION['auth']['user_id'] > 0; - } - - /** - * Get current user ID - */ - public static function getUserId(): int { - return $_SESSION['auth']['user_id'] ?? 0; - } - - /** - * Get current username - */ - public static function getUsername(): string { - return $_SESSION['auth']['username'] ?? ''; - } - - /** - * Get current user's full name - */ - public static function getFullName(): string { - return $_SESSION['auth']['full_name'] ?? ''; - } - - /** - * Get current user's email - */ - public static function getEmail(): string { - return $_SESSION['auth']['email'] ?? ''; - } - - /** - * Get current user's role - */ - public static function getRole(): string { - return $_SESSION['auth']['role'] ?? ''; - } - - /** - * Get current user's role display name - */ - public static function getRoleDisplay(): string { - return $_SESSION['auth']['role_display'] ?? ''; - } - - /** - * Check if current user is admin - */ - public static function isAdmin(): bool { - return self::getRole() === 'admin'; - } - - /** - * Get user data for API response - */ - public static function getUserData(): ?array { - if (!self::isLoggedIn()) { - return null; - } - - return [ - 'id' => self::getUserId(), - 'username' => self::getUsername(), - 'email' => self::getEmail(), - 'fullName' => self::getFullName(), - 'role' => self::getRole(), - 'roleDisplay' => self::getRoleDisplay(), - 'isAdmin' => self::isAdmin() - ]; - } - - /** - * Check if user has specific permission - * - * @param string $permission Permission name (e.g., 'projects.create') - * @return bool True if user has permission - */ - public static function hasPermission(string $permission): bool { - if (!self::isLoggedIn()) { - return false; - } - - // Admins have all permissions - if (self::isAdmin()) { - return true; - } - - try { - $pdo = db(); - $stmt = $pdo->prepare(" - SELECT 1 FROM role_permissions rp - INNER JOIN permissions p ON rp.permission_id = p.id - INNER JOIN roles r ON rp.role_id = r.id - INNER JOIN users u ON u.role_id = r.id - WHERE u.id = ? AND p.name = ? - "); - $stmt->execute([self::getUserId(), $permission]); - - return $stmt->fetch() !== false; - - } catch (PDOException $e) { - error_log("Auth permission check error: " . $e->getMessage()); - return false; - } - } - - /** - * Require user to be logged in - */ - public static function requireLogin(): void { - if (!self::check()) { - errorResponse('Vyzadovano prihlaseni', 401); - } - } - - /** - * Require specific permission - * - * @param string $permission Permission name - */ - public static function requirePermission(string $permission): void { - self::requireLogin(); - - if (!self::hasPermission($permission)) { - errorResponse('Pristup odepren', 403); - } - } - - /** - * Get session time remaining in seconds - */ - public static function getSessionTimeRemaining(): int { - if (!self::isLoggedIn()) { - return 0; - } - - $lastActivity = $_SESSION['auth']['last_activity'] ?? 0; - $elapsed = time() - $lastActivity; - $remaining = (SESSION_LIFETIME_MINUTES * 60) - $elapsed; - - return max(0, $remaining); - } -} -``` - -### 4.3 api/includes/RateLimiter.php - -```php -storagePath = $storagePath ?? (defined('RATE_LIMIT_STORAGE_PATH') ? RATE_LIMIT_STORAGE_PATH : __DIR__ . '/../rate_limits'); - - // Ensure storage directory exists - if (!is_dir($this->storagePath)) { - mkdir($this->storagePath, 0755, true); - } - - // Cleanup old files occasionally (1% of requests) - if (rand(1, 100) === 1) { - $this->cleanup(); - } - } - - /** - * Check if the request should be rate limited - * - * @param string $endpoint Endpoint identifier (e.g., 'login', 'session') - * @param int|null $limit Custom limit for this endpoint (requests per minute) - * @return bool True if request is allowed, false if rate limited - */ - public function check(string $endpoint, ?int $limit = null): bool { - $limit = $limit ?? $this->defaultLimit; - $ip = $this->getClientIp(); - $key = $this->getKey($ip, $endpoint); - - $data = $this->getData($key); - $now = time(); - - // Check if we're still in the same time window - if ($data && $data['window_start'] > ($now - $this->windowSeconds)) { - // Same window - check count - if ($data['count'] >= $limit) { - return false; // Rate limited - } - - // Increment counter - $data['count']++; - $this->saveData($key, $data); - return true; - } - - // New window - reset counter - $this->saveData($key, [ - 'window_start' => $now, - 'count' => 1 - ]); - - return true; - } - - /** - * Enforce rate limit and return 429 response if exceeded - * - * @param string $endpoint Endpoint identifier - * @param int|null $limit Custom limit for this endpoint - */ - public function enforce(string $endpoint, ?int $limit = null): void { - if (!$this->check($endpoint, $limit)) { - $this->sendRateLimitResponse(); - } - } - - /** - * Get remaining requests for current window - * - * @param string $endpoint Endpoint identifier - * @param int|null $limit Custom limit for this endpoint - * @return int Remaining requests - */ - public function getRemaining(string $endpoint, ?int $limit = null): int { - $limit = $limit ?? $this->defaultLimit; - $ip = $this->getClientIp(); - $key = $this->getKey($ip, $endpoint); - - $data = $this->getData($key); - $now = time(); - - if (!$data || $data['window_start'] <= ($now - $this->windowSeconds)) { - return $limit; - } - - return max(0, $limit - $data['count']); - } - - /** - * Get time until rate limit resets - * - * @param string $endpoint Endpoint identifier - * @return int Seconds until reset - */ - public function getResetTime(string $endpoint): int { - $ip = $this->getClientIp(); - $key = $this->getKey($ip, $endpoint); - - $data = $this->getData($key); - $now = time(); - - if (!$data) { - return 0; - } - - $windowEnd = $data['window_start'] + $this->windowSeconds; - return max(0, $windowEnd - $now); - } - - /** - * Send 429 Too Many Requests response and exit - */ - private function sendRateLimitResponse(): void { - http_response_code(429); - header('Content-Type: application/json; charset=utf-8'); - header('Retry-After: ' . $this->windowSeconds); - - echo json_encode([ - 'success' => false, - 'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.', - 'retry_after' => $this->windowSeconds - ], JSON_UNESCAPED_UNICODE); - - exit(); - } - - /** - * Get client IP address - * - * @return string IP address - */ - private function getClientIp(): string { - // Use the global helper if available - if (function_exists('getClientIp')) { - return getClientIp(); - } - - // Fallback: use only REMOTE_ADDR (cannot be spoofed) - return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; - } - - /** - * Generate storage key for IP and endpoint - * - * @param string $ip Client IP - * @param string $endpoint Endpoint identifier - * @return string Storage key (filename-safe) - */ - private function getKey(string $ip, string $endpoint): string { - // Create a safe filename from IP and endpoint - return md5($ip . ':' . $endpoint); - } - - /** - * Get rate limit data from storage - * - * @param string $key Storage key - * @return array|null Data array or null if not found - */ - private function getData(string $key): ?array { - $file = $this->storagePath . '/' . $key . '.json'; - - if (!file_exists($file)) { - return null; - } - - $content = @file_get_contents($file); - if ($content === false) { - return null; - } - - $data = json_decode($content, true); - return is_array($data) ? $data : null; - } - - /** - * Save rate limit data to storage - * - * @param string $key Storage key - * @param array $data Data to save - */ - private function saveData(string $key, array $data): void { - $file = $this->storagePath . '/' . $key . '.json'; - @file_put_contents($file, json_encode($data), LOCK_EX); - } - - /** - * Cleanup expired rate limit files - * - * Removes files older than the time window to prevent disk space issues - */ - private function cleanup(): void { - if (!is_dir($this->storagePath)) { - return; - } - - $files = glob($this->storagePath . '/*.json'); - $expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe - - foreach ($files as $file) { - if (filemtime($file) < $expireTime) { - @unlink($file); - } - } - } - - /** - * Clear all rate limit data (useful for testing) - */ - public function clearAll(): void { - if (!is_dir($this->storagePath)) { - return; - } - - $files = glob($this->storagePath . '/*.json'); - foreach ($files as $file) { - @unlink($file); - } - } -} -``` - -### 4.4 api/admin/login.php - -```php -enforce('login', 10); - -// Only accept POST -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda neni povolena', 405); -} - -// Initialize auth -Auth::initialize(); - -// Check if already logged in -if (Auth::check()) { - successResponse([ - 'user' => Auth::getUserData(), - 'sessionTimeRemaining' => Auth::getSessionTimeRemaining() - ], 'Jiz prihlasen'); -} - -// Get input -$input = getJsonInput(); - -// Verify CSRF token -$csrfToken = $input['csrf_token'] ?? ''; -if (empty($csrfToken) || !verifyCsrfToken($csrfToken)) { - errorResponse('Neplatny CSRF token', 403); -} - -$username = trim($input['username'] ?? ''); -$password = $input['password'] ?? ''; -$remember = (bool) ($input['remember'] ?? false); - -// Validate input -if (empty($username)) { - errorResponse('Uzivatelske jmeno je povinne'); -} - -if (empty($password)) { - errorResponse('Heslo je povinne'); -} - -// Attempt login -$result = Auth::login($username, $password, $remember); - -if ($result['success']) { - // Log successful login - AuditLog::logLogin( - Auth::getUserId(), - Auth::getUsername() - ); - - successResponse([ - 'user' => $result['user'], - 'sessionTimeRemaining' => Auth::getSessionTimeRemaining() - ], 'Prihlaseni uspesne'); -} else { - // Log failed login - AuditLog::logLoginFailed($username, $result['error']); - - // Translate error codes to user-friendly messages - $errorMessages = [ - 'invalid_credentials' => 'Neplatne uzivatelske jmeno nebo heslo', - 'account_locked' => 'Ucet je docasne uzamcen. Zkuste to prosim za ' . ($result['minutes'] ?? 15) . ' minut.', - 'account_deactivated' => 'Tento ucet byl deaktivovan', - 'system_error' => 'Doslo k systemove chybe. Zkuste to prosim pozdeji.' - ]; - - $message = $errorMessages[$result['error']] ?? 'Prihlaseni se nezdarilo'; - - errorResponse($message, $result['error'] === 'account_locked' ? 429 : 401); -} -``` - -### 4.5 api/admin/logout.php - -```php -enforce('logout', 30); - -// Only accept POST -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - errorResponse('Metoda neni povolena', 405); -} - -// Initialize auth -Auth::initialize(); - -// Verify CSRF token (log warning but don't block - logout should always succeed) -$input = getJsonInput(); -$csrfToken = $input['csrf_token'] ?? ''; -if (empty($csrfToken) || !verifyCsrfToken($csrfToken)) { - error_log("Logout CSRF warning: Invalid or missing token for session " . session_id()); - // Continue with logout anyway - trapping users in zombie sessions is worse -} - -// Log logout before destroying session -if (Auth::isLoggedIn()) { - AuditLog::logLogout(); -} - -// Perform logout -Auth::logout(); - -successResponse(null, 'Odhlaseni uspesne'); -``` - -### 4.6 api/admin/session.php - -```php -enforce('session', 60); - -// Accept GET and POST -if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'])) { - errorResponse('Metoda neni povolena', 405); -} - -// Initialize auth -Auth::initialize(); - -// Check session -$authenticated = Auth::check(); - -if ($authenticated) { - // Always fetch fresh user data from database - $pdo = db(); - $stmt = $pdo->prepare(" - SELECT u.id, u.username, u.email, u.first_name, u.last_name, - r.name as role_name, r.display_name as role_display_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - WHERE u.id = ? - "); - $stmt->execute([Auth::getUserId()]); - $dbUser = $stmt->fetch(); - - $userData = [ - 'id' => $dbUser['id'], - 'username' => $dbUser['username'], - 'email' => $dbUser['email'], - 'fullName' => trim($dbUser['first_name'] . ' ' . $dbUser['last_name']), - 'role' => $dbUser['role_name'], - 'roleDisplay' => $dbUser['role_display_name'] ?? $dbUser['role_name'], - 'isAdmin' => $dbUser['role_name'] === 'admin' - ]; - - successResponse([ - 'authenticated' => true, - 'user' => $userData, - 'sessionTimeRemaining' => Auth::getSessionTimeRemaining() - ]); -} else { - successResponse([ - 'authenticated' => false, - 'user' => null, - 'sessionTimeRemaining' => 0 - ]); -} -``` - -### 4.7 api/admin/csrf.php - -```php -enforce('csrf', 30); - -// Only accept GET -if ($_SERVER['REQUEST_METHOD'] !== 'GET') { - errorResponse('Metoda neni povolena', 405); -} - -// Initialize auth (starts session) -Auth::initialize(); - -// Generate CSRF token -$token = generateCsrfToken(); - -successResponse([ - 'csrf_token' => $token -]); -``` - -### 4.8 src/admin/context/AuthContext.jsx - -```jsx -import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react' -import { setLogoutCallback } from '../utils/api' - -const API_BASE = '/api/admin' - -const AuthContext = createContext(null) - -let cachedUser = null -let sessionFetched = false -let lastSessionCheck = 0 -let cachedCsrfToken = null - -// Export for AdminLayout to check timing -export const getLastSessionCheck = () => lastSessionCheck -export const setLastSessionCheck = (time) => { lastSessionCheck = time } - -export function AuthProvider({ children }) { - const [user, setUser] = useState(cachedUser) - const [loading, setLoading] = useState(!sessionFetched) - const [error, setError] = useState(null) - const [csrfToken, setCsrfToken] = useState(cachedCsrfToken) - - useEffect(() => { - cachedUser = user - }, [user]) - - useEffect(() => { - cachedCsrfToken = csrfToken - }, [csrfToken]) - - const fetchCsrfToken = useCallback(async () => { - try { - const response = await fetch(`${API_BASE}/csrf.php`, { - credentials: 'include' - }) - const data = await response.json() - - if (data.success && data.data?.csrf_token) { - setCsrfToken(data.data.csrf_token) - cachedCsrfToken = data.data.csrf_token - return data.data.csrf_token - } - return null - } catch (err) { - console.error('Failed to fetch CSRF token:', err) - return null - } - }, []) - - const checkSession = useCallback(async () => { - try { - lastSessionCheck = Date.now() - const response = await fetch(`${API_BASE}/session.php`, { - credentials: 'include' - }) - const data = await response.json() - - if (data.success && data.data?.authenticated) { - setUser(data.data.user) - cachedUser = data.data.user - return true - } else { - setUser(null) - cachedUser = null - return false - } - } catch (err) { - console.error('Session check failed:', err) - setUser(null) - cachedUser = null - return false - } finally { - setLoading(false) - sessionFetched = true - } - }, []) - - useEffect(() => { - if (!sessionFetched) { - checkSession() - } - }, []) - - // Set up the logout callback for the global apiFetch - useEffect(() => { - setLogoutCallback(() => { - setUser(null) - cachedUser = null - sessionFetched = false - }) - }, []) - - const login = async (username, password, remember = false) => { - setError(null) - - try { - // Always fetch a fresh CSRF token before login to avoid stale token issues - const tokenToUse = await fetchCsrfToken() - - if (!tokenToUse) { - const errorMsg = 'Nepodarilo se ziskat bezpecnostni token. Zkuste to prosim znovu.' - setError(errorMsg) - return { success: false, error: errorMsg } - } - - const response = await fetch(`${API_BASE}/login.php`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include', - body: JSON.stringify({ username, password, remember, csrf_token: tokenToUse }) - }) - - const data = await response.json() - - if (data.success) { - setUser(data.data.user) - lastSessionCheck = Date.now() // Mark session as just verified - // Fetch a new CSRF token after successful login (token rotation) - fetchCsrfToken() - return { success: true } - } else { - setError(data.error) - // Fetch a new CSRF token on failure (token may have been invalidated) - fetchCsrfToken() - return { success: false, error: data.error } - } - } catch (err) { - const errorMsg = 'Chyba pripojeni. Zkuste to prosim znovu.' - setError(errorMsg) - return { success: false, error: errorMsg } - } - } - - const logout = async () => { - try { - // Use current token or fetch a fresh one - const tokenToUse = csrfToken || await fetchCsrfToken() - - await fetch(`${API_BASE}/logout.php`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include', - body: JSON.stringify({ csrf_token: tokenToUse }) - }) - } catch (err) { - console.error('Logout error:', err) - } finally { - setUser(null) - cachedUser = null - sessionFetched = false - setCsrfToken(null) - cachedCsrfToken = null - } - } - - const updateUser = useCallback((updates) => { - setUser(prev => prev ? { ...prev, ...updates } : null) - }, []) - - const value = { - user, - loading, - error, - csrfToken, - isAuthenticated: !!user, - isAdmin: user?.isAdmin || false, - login, - logout, - checkSession, - updateUser, - fetchCsrfToken - } - - return ( - - {children} - - ) -} - -export function useAuth() { - const context = useContext(AuthContext) - if (!context) { - throw new Error('useAuth must be used within an AuthProvider') - } - return context -} - -export default AuthContext -``` - -### 4.9 src/admin/pages/Login.jsx - -```jsx -import { useState, useEffect } from 'react' -import { Navigate } from 'react-router-dom' -import { motion } from 'framer-motion' -import { useAuth } from '../context/AuthContext' -import { useAlert } from '../context/AlertContext' -import { useTheme } from '../../context/ThemeContext' -import { shouldShowSessionExpiredAlert } from '../utils/api' - -export default function Login() { - const { login, isAuthenticated, loading: authLoading } = useAuth() - const alert = useAlert() - const { theme, toggleTheme } = useTheme() - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [remember, setRemember] = useState(false) - const [loading, setLoading] = useState(false) - - - // Show session expired alert if redirected due to 401 - useEffect(() => { - if (shouldShowSessionExpiredAlert()) { - alert.warning('Vase relace vyprsela. Prihlas se prosim znovu.') - } - }, []) - - if (authLoading) { - return ( -
-
-
-
-
- ) - } - - if (isAuthenticated) { - return - } - - const handleSubmit = async (e) => { - e.preventDefault() - setLoading(true) - - const result = await login(username, password, remember) - - if (!result.success) { - alert.error(result.error) - } - - setLoading(false) - } - - return ( -
-
-
- - - - -
- BOHA Automation -

Interni system

-

Prihlas se ke svemu uctu

-
- -
-
- - setUsername(e.target.value)} - required - autoComplete="username" - className="admin-form-input" - placeholder="Zadejte uzivatelske jmeno" - /> -
- -
- - setPassword(e.target.value)} - required - autoComplete="current-password" - className="admin-form-input" - placeholder="Zadejte heslo" - /> -
- - - - -
- - - ← Zpet na web - -
-
- ) -} -``` - -### 4.10 src/admin/utils/api.js - -```javascript -/** - * API utility with automatic 401 handling - * Triggers logout and shows alert when session expires - */ - -let logoutCallback = null -let showSessionExpiredAlert = false - -// Set the logout callback (called from AuthContext) -export const setLogoutCallback = (callback) => { - logoutCallback = callback -} - -// Check and clear the session expired flag (called from Login page) -export const shouldShowSessionExpiredAlert = () => { - if (showSessionExpiredAlert) { - showSessionExpiredAlert = false - return true - } - return false -} - -// Wrapper for fetch that handles 401 responses -export const apiFetch = async (url, options = {}) => { - const response = await fetch(url, { - ...options, - credentials: 'include' - }) - - // If we get a 401, the session is invalid - set flag and trigger logout - if (response.status === 401) { - showSessionExpiredAlert = true - if (logoutCallback) { - logoutCallback() - } - } - - return response -} - -export default apiFetch -``` - -### 4.11 src/admin/components/AdminLayout.jsx - -```jsx -import { useState, useEffect } from 'react' -import { Outlet, Navigate, useLocation } from 'react-router-dom' -import { useAuth, getLastSessionCheck } from '../context/AuthContext' -import { useTheme } from '../../context/ThemeContext' -import Sidebar from './Sidebar' - -const SESSION_CHECK_INTERVAL = 30000 // 30 seconds minimum between checks - -export default function AdminLayout() { - const { isAuthenticated, loading, checkSession } = useAuth() - const { theme, toggleTheme } = useTheme() - const [sidebarOpen, setSidebarOpen] = useState(false) - const location = useLocation() - - // Verify session on route changes, but not too frequently - useEffect(() => { - const now = Date.now() - const lastCheck = getLastSessionCheck() - - // Only check if enough time has passed since last check - if (now - lastCheck > SESSION_CHECK_INTERVAL) { - checkSession() - } - }, [location.pathname]) - - useEffect(() => { - if (sidebarOpen) { - document.documentElement.style.overflow = 'hidden' - document.body.style.overflow = 'hidden' - } else { - document.documentElement.style.overflow = '' - document.body.style.overflow = '' - } - return () => { - document.documentElement.style.overflow = '' - document.body.style.overflow = '' - } - }, [sidebarOpen]) - - if (loading) { - return ( -
-
-
-
-
- ) - } - - if (!isAuthenticated) { - return - } - - return ( -
- setSidebarOpen(false)} /> - -
-
- - -
- - - - - - - - - - Zobrazit web - -
- -
- -
-
-
- ) -} -``` - ---- - -## 5. Security Features - -### 5.1 CSRF Protection - -CSRF tokens protect against Cross-Site Request Forgery attacks. - -**Implementation:** -- Tokens generated using `bin2hex(random_bytes(32))` (64 hex characters) -- Stored in PHP session (`$_SESSION['csrf_token']`) -- Required for state-changing requests (login, logout) -- Verified using timing-safe `hash_equals()` comparison -- Token rotated on successful login (prevents Login CSRF attacks) - -**Token Rotation on Login:** -```php -// In Auth::login() after session_regenerate_id(true) -unset($_SESSION['csrf_token']); // Force new CSRF token generation post-login -``` - -This prevents attackers from preparing CSRF tokens before the victim logs in. - -### 5.2 Rate Limiting - -IP-based rate limiting protects against brute force and abuse. - -| Endpoint | Limit | Purpose | -|----------|-------|---------| -| `/api/admin/login.php` | 10/min | Strict - prevents brute force | -| `/api/admin/logout.php` | 30/min | Moderate - logout operations | -| `/api/admin/session.php` | 60/min | Higher - frequent session checks | -| `/api/admin/csrf.php` | 30/min | Moderate - token generation | - -**Rate Limit Response (429):** -```json -{ - "success": false, - "error": "Prilis mnoho pozadavku. Zkuste to prosim pozdeji.", - "retry_after": 60 -} -``` - -The `Retry-After` header is also set. - -### 5.3 Security Headers - -All API endpoints set these headers: - -| Header | Value | Purpose | -|--------|-------|---------| -| `X-Content-Type-Options` | `nosniff` | Prevents MIME type sniffing | -| `X-Frame-Options` | `DENY` | Prevents clickjacking | -| `X-XSS-Protection` | `1; mode=block` | Enables browser XSS filter | -| `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer info | -| `Cache-Control` | `no-store, no-cache...` | Prevents caching of sensitive data | - -### 5.4 Brute Force Protection - -Failed login attempts tracked per user with automatic lockout. - -**Configuration:** -- `MAX_LOGIN_ATTEMPTS = 5` - Account locks after 5 failures -- `LOCKOUT_MINUTES = 15` - Lock duration - -**Behavior:** -1. Each failed login increments `failed_login_attempts` in users table -2. After 5 failures, `locked_until` is set to current time + 15 minutes -3. Login attempts during lockout return `account_locked` error with remaining time -4. Successful login resets counter and clears lock - -### 5.5 Session Security - -**Cookie Configuration:** - -| Attribute | Value | Purpose | -|-----------|-------|---------| -| `lifetime` | 0 | Session cookie - expires when browser closes | -| `path` | `/` | Available across entire domain | -| `secure` | `true` (HTTPS) | Only transmitted over HTTPS | -| `httponly` | `true` | Not accessible via JavaScript | -| `samesite` | `Lax` | Prevents CSRF in cross-site requests | - -**Additional Security:** -- Session ID regenerated on login (`session_regenerate_id(true)`) -- Sessions validated against database on each request -- **User-Agent binding** - Each session is bound to the browser's User-Agent string. If an attacker steals a session cookie but uses a different browser, the session will be rejected -- Client IP tracked for audit purposes -- Sliding expiration extends session on activity -- Periodic cleanup of expired sessions (1% of requests) - -**IP Address Handling:** -- Uses only `REMOTE_ADDR` which represents the actual TCP connection IP -- Does not trust proxy headers (`X-Forwarded-For`, `X-Real-IP`, etc.) which can be spoofed -- If a reverse proxy is added in the future (Cloudflare, Nginx, etc.), the `getClientIp()` function should be updated to trust specific headers only from known proxy IP ranges - -### 5.6 Remember Me Token Security - -Implements the selector:token pattern with SHA-256 hashing. - -**Token Structure:** -- `selector`: 32 hex characters (public identifier for database lookup) -- `token`: 64 hex characters (secret validator, only hash stored) -- Cookie value: `selector:token` - -**Security Features:** - -1. **Split Token Pattern**: Selector used for lookup, only token hash stored -2. **SHA-256 Hashing**: Token hashed before storage, prevents database leak exposure -3. **Single-Use Rotation**: Token deleted and new one issued after each use -4. **Theft Detection**: If hash doesn't match, all user tokens deleted (possible theft) -5. **Secure Cookie**: Same security attributes as session cookie - -**Token Rotation Flow:** -``` -1. User visits with remember_me cookie -2. Extract selector:token from cookie -3. Lookup token by selector in database -4. Verify SHA-256(token) matches stored hash -5. If match: delete old token, create new one, restore session -6. If mismatch: delete ALL user tokens (possible theft) -``` - ---- - -## 6. Authentication Flows - -### 6.1 Login Flow - -``` -Frontend Backend --------- ------- -1. User enters credentials -2. fetchCsrfToken() -----------------> GET /csrf.php - - Start session - - Generate token -3. <---------------------------------- Return csrf_token -4. login() --------------------------> POST /login.php - {username, password, - Verify CSRF token - remember, csrf_token} - Check rate limit - - Find user by username/email - - Check if locked - - Verify password - - Check if active - - Regenerate session ID - - Clear old CSRF token - - Create session data - - Store in user_sessions - - Create remember_me_token (if remember) - - Reset failed attempts -5. <---------------------------------- Return user data -6. Store user in state -7. fetchCsrfToken() -----------------> GET /csrf.php (token rotation) -8. <---------------------------------- Return new csrf_token -``` - -### 6.2 Logout Flow - -``` -Frontend Backend --------- ------- -1. logout() -------------------------> POST /logout.php - {csrf_token} - Verify CSRF (warn only) - - Log logout event - - Delete from user_sessions - - Delete remember_me_token - - Delete cookies - - Destroy PHP session -2. <---------------------------------- Return success -3. Clear user state -4. Clear CSRF token -5. Redirect to login -``` - -### 6.3 Session Validation Flow - -``` -Frontend Backend --------- ------- -1. checkSession() ------------------> GET /session.php - - Initialize session - - Check $_SESSION['auth'] - - Validate against user_sessions DB - - If valid: update activity, return user - - If invalid: try remember me restore - - If remember me works: return user - - If both fail: clear all, return false -2. <--------------------------------- Return {authenticated, user} -3. Update user state -``` - -### 6.4 Remember Me Restoration Flow - -``` -1. Auth::check() called -2. Session data exists but DB validation fails -3. Check for boha_remember cookie -4. Parse selector:token from cookie -5. Lookup in remember_me_tokens by selector -6. Verify SHA-256(token) matches token_hash -7. If mismatch: DELETE ALL tokens for user (theft detection) -8. If match and user active: - a. Delete old token from DB - b. Create new token (rotation) - c. Update session expiry - d. Create session data - e. Return true (authenticated) -``` - ---- - -## 7. API Endpoints - -### GET /api/admin/csrf.php - -Generates and returns a CSRF token. - -**Rate Limit:** 30 requests/minute - -**Request:** -```http -GET /api/admin/csrf.php -Cookie: BOHA_ADMIN_SESSION=xxx -``` - -**Response:** -```json -{ - "success": true, - "data": { - "csrf_token": "a1b2c3d4e5f6g7h8i9j0..." - } -} -``` - ---- - -### POST /api/admin/login.php - -Authenticates a user and creates a session. - -**Rate Limit:** 10 requests/minute - -**Request:** -```http -POST /api/admin/login.php -Content-Type: application/json -Cookie: BOHA_ADMIN_SESSION=xxx - -{ - "username": "john.doe", - "password": "secret123", - "remember": true, - "csrf_token": "a1b2c3d4e5f6..." -} -``` - -**Success Response (200):** -```json -{ - "success": true, - "message": "Prihlaseni uspesne", - "data": { - "user": { - "id": 1, - "username": "john.doe", - "email": "john@example.com", - "fullName": "John Doe", - "role": "admin", - "roleDisplay": "Administrator", - "isAdmin": true - }, - "sessionTimeRemaining": 3600 - } -} -``` - -**Error Responses:** - -Invalid credentials (401): -```json -{ - "success": false, - "error": "Neplatne uzivatelske jmeno nebo heslo" -} -``` - -Account locked (429): -```json -{ - "success": false, - "error": "Ucet je docasne uzamcen. Zkuste to prosim za 15 minut." -} -``` - -Invalid CSRF token (403): -```json -{ - "success": false, - "error": "Neplatny CSRF token" -} -``` - -Rate limited (429): -```json -{ - "success": false, - "error": "Prilis mnoho pozadavku. Zkuste to prosim pozdeji.", - "retry_after": 60 -} -``` - ---- - -### POST /api/admin/logout.php - -Logs out the current user. - -**Rate Limit:** 30 requests/minute - -**Request:** -```http -POST /api/admin/logout.php -Content-Type: application/json -Cookie: BOHA_ADMIN_SESSION=xxx - -{ - "csrf_token": "a1b2c3d4e5f6..." -} -``` - -**Response (200):** -```json -{ - "success": true, - "message": "Odhlaseni uspesne" -} -``` - -Note: Logout proceeds even with invalid CSRF token (warning logged). This prevents users from being trapped in zombie sessions. - ---- - -### GET /api/admin/session.php - -Validates the current session and returns user data. - -**Rate Limit:** 60 requests/minute - -**Request:** -```http -GET /api/admin/session.php -Cookie: BOHA_ADMIN_SESSION=xxx -``` - -**Authenticated Response (200):** -```json -{ - "success": true, - "data": { - "authenticated": true, - "user": { - "id": 1, - "username": "john.doe", - "email": "john@example.com", - "fullName": "John Doe", - "role": "admin", - "roleDisplay": "Administrator", - "isAdmin": true - }, - "sessionTimeRemaining": 3540 - } -} -``` - -**Unauthenticated Response (200):** -```json -{ - "success": true, - "data": { - "authenticated": false, - "user": null, - "sessionTimeRemaining": 0 - } -} -``` - ---- - -## Summary - -The BOHA authentication system implements comprehensive security measures: - -1. **Defense in Depth** - Multiple layers (CSRF, session validation, brute force protection, rate limiting) -2. **Secure Defaults** - HttpOnly, Secure, SameSite cookies; bcrypt hashing; security headers -3. **Database-Backed Sessions** - Server-side validation prevents session manipulation -4. **Remember Me Security** - Selector/validator pattern with single-use token rotation -5. **CSRF Token Rotation** - Tokens invalidated on login to prevent Login CSRF attacks -6. **Rate Limiting** - IP-based limits per endpoint protect against abuse -7. **Audit Logging** - All authentication events logged for security monitoring -8. **Clean Architecture** - Clear separation between React frontend and PHP backend diff --git a/example_design/boha-spa.html b/example_design/boha-spa.html deleted file mode 100644 index 43c4737..0000000 --- a/example_design/boha-spa.html +++ /dev/null @@ -1,1519 +0,0 @@ - - - - - -BOHA Automation – Platforma - - - - - - - - - -
- - - - diff --git a/index.html b/index.html index e54cae4..8eec5cd 100644 --- a/index.html +++ b/index.html @@ -9,10 +9,10 @@ - BOHA Interni system + Interní systém - + diff --git a/package-lock.json b/package-lock.json index cbe64ca..1b1cc47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "boha-system", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "date-fns": "^4.1.0", "dompurify": "^3.3.1", "framer-motion": "^12.23.25", @@ -20,14 +24,91 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.7.0", "eslint": "^10.0.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", - "vite": "^5.4.11" + "jsdom": "^28.1.0", + "vite": "^5.4.11", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -262,6 +343,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -310,6 +401,218 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -599,6 +902,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -616,6 +936,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -633,6 +970,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -829,6 +1183,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1308,6 +1680,105 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1353,6 +1824,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1402,6 +1891,117 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1425,6 +2025,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1466,6 +2076,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1486,6 +2116,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", @@ -1563,6 +2203,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -1623,6 +2273,67 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -1660,6 +2371,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1667,12 +2385,29 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", @@ -1695,6 +2430,26 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1922,6 +2677,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1938,6 +2703,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1965,6 +2740,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2103,6 +2896,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2123,6 +2957,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2155,6 +2999,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2168,6 +3019,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2296,6 +3188,43 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -2367,6 +3296,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2432,6 +3372,19 @@ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", "license": "BSD-3-Clause" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2451,6 +3404,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2458,6 +3418,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2506,6 +3479,34 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2608,6 +3609,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-quill-new": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz", @@ -2665,6 +3673,20 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2674,6 +3696,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -2722,6 +3754,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2770,6 +3815,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2780,6 +3832,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2806,12 +3872,122 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2831,6 +4007,16 @@ "node": ">= 0.8.0" } }, + "node_modules/undici": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.23.0.tgz", + "integrity": "sha512-HVMxHKZKi+eL2mrUZDzDkKW3XvCjynhbtpSq20xQp4ePDFeSFuAfnvM0GIwZIv8fiKHjXFQ5WjxhCt15KRNj+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", @@ -2932,6 +4118,640 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2954,6 +4774,23 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2978,6 +4815,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index ff26aee..afdd532 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,15 @@ "build": "vite build", "preview": "vite preview", "deploy": "bash deploy.sh", - "lint": "eslint src/" + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "date-fns": "^4.1.0", "dompurify": "^3.3.1", "framer-motion": "^12.23.25", @@ -23,11 +29,16 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.7.0", "eslint": "^10.0.2", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", - "vite": "^5.4.11" + "jsdom": "^28.1.0", + "vite": "^5.4.11", + "vitest": "^4.0.18" } } diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index 8e45d20..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,81 +0,0 @@ - - Order allow,deny - Deny from all - - - - Order allow,deny - Deny from all - - -Options -Indexes - -AddDefaultCharset UTF-8 - - AddCharset UTF-8 .html .css .js .json .xml .txt - - - - Header set X-Content-Type-Options "nosniff" - Header set X-Frame-Options "SAMEORIGIN" - Header set Referrer-Policy "strict-origin-when-cross-origin" - Header set Strict-Transport-Security "max-age=31536000; includeSubDomains" - Header set Permissions-Policy "camera=(), microphone=(), geolocation=(self)" - Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'" - - - - RewriteEngine On - RewriteBase / - - # Force HTTPS - RewriteCond %{HTTPS} off - RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] - - RewriteRule ^api/ - [L] - - RewriteCond %{REQUEST_FILENAME} -f [OR] - RewriteCond %{REQUEST_FILENAME} -d - RewriteRule ^ - [L] - - # All SPA routes go through router.php - RewriteRule ^ /router.php [L] - - - - AddOutputFilterByType DEFLATE text/plain text/html text/xml text/css - AddOutputFilterByType DEFLATE application/xml application/xhtml+xml application/rss+xml - AddOutputFilterByType DEFLATE application/javascript application/x-javascript application/json - AddOutputFilterByType DEFLATE image/svg+xml application/font-woff2 - SetEnvIfNoCase Request_URI "\.(jpg|jpeg|png|gif|webp|zip|gz|br|woff2)$" no-gzip - - - - ExpiresActive On - ExpiresByType image/jpg "access plus 1 year" - ExpiresByType image/jpeg "access plus 1 year" - ExpiresByType image/gif "access plus 1 year" - ExpiresByType image/png "access plus 1 year" - ExpiresByType image/svg+xml "access plus 1 year" - ExpiresByType text/css "access plus 1 year" - ExpiresByType application/javascript "access plus 1 year" - ExpiresByType text/javascript "access plus 1 year" - ExpiresByType application/font-woff2 "access plus 1 year" - ExpiresByType text/html "access plus 0 seconds" - - - - - Header set Cache-Control "no-cache, no-store, must-revalidate" - Header set Pragma "no-cache" - Header set Expires "0" - - - - - - Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0" - Header set Pragma "no-cache" - Header set Expires "0" - - diff --git a/public/site.webmanifest b/public/site.webmanifest index 7a470b3..860663f 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "BOHA", - "short_name": "BOHA", + "name": "System", + "short_name": "System", "icons": [ { "src": "/web-app-manifest-192x192.png", diff --git a/src/admin/admin.css b/src/admin/admin.css index 20d1cf3..1e3fa2d 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -2148,3 +2148,200 @@ img { transform: translateY(-1px); } +/* ============================================================================ + Mobile Responsive Enhancements + ============================================================================ */ + +/* Touch targets - min 44px na mobilech */ +@media (max-width: 768px) { + .admin-btn { + min-height: 44px; + padding: 10px 16px; + } + + .admin-btn-sm { + min-height: 36px; + } + + .admin-btn-icon { + min-width: 44px; + min-height: 44px; + } + + .admin-form-input, + .admin-form-select, + .admin-form-textarea { + min-height: 44px; + font-size: 16px; /* zabrání auto-zoomu na iOS */ + } + + .admin-form-checkbox { + min-height: 44px; + padding: 8px 0; + } + + .admin-form-checkbox input + span::before { + width: 20px; + height: 20px; + } + + .admin-form-label { + font-size: 13px; + } +} + +/* Tabulky - kompaktnejsi na mobilech, lepsi scroll indikace */ +@media (max-width: 640px) { + .admin-table-wrapper, + .admin-table-responsive { + margin: 0 -1rem; + padding: 0 1rem; + position: relative; + } + + .admin-table-wrapper::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 24px; + background: linear-gradient(to right, transparent, var(--bg-primary)); + pointer-events: none; + opacity: 0.8; + } + + .admin-table { + min-width: 500px; + } + + .admin-table th, + .admin-table td { + padding: 8px; + font-size: 11px; + } + + .admin-table th { + font-size: 9px; + } + + .admin-table-actions { + gap: 0.25rem; + } +} + +/* Page header na mobilech */ +@media (max-width: 480px) { + .admin-page-title { + font-size: 18px; + } + + .admin-page-subtitle { + font-size: 12px; + } + + .admin-content { + padding: 12px !important; + } + + .admin-card-body { + padding: 12px; + } + + .admin-card-header { + padding: 12px; + } +} + +/* Grid - single column na malych mobilech */ +@media (max-width: 480px) { + .admin-grid-4 { + grid-template-columns: 1fr; + } +} + +/* Confirm modal - ne fullscreen na mobilech */ +@media (max-width: 480px) { + .admin-confirm-content { + padding: 1.5rem 1rem; + } + + .admin-confirm-title { + font-size: 1.1rem; + } + + .admin-confirm-message { + font-size: 0.875rem; + } +} + +/* Skeleton loading na mobilech */ +@media (max-width: 640px) { + .admin-skeleton { + border-radius: 4px; + } +} + +/* Badge na mobilech - vetsi pro touch */ +@media (max-width: 768px) { + .admin-badge { + padding: 4px 10px; + font-size: 12px; + } + + button.admin-badge { + min-height: 32px; + } +} + +/* Prefers reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Drag handle */ +.admin-drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--text-muted); + cursor: grab; + border-radius: 4px; + padding: 0; + transition: color 0.15s, background 0.15s; + touch-action: none; +} + +.admin-drag-handle:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + +.admin-drag-handle:active { + cursor: grabbing; +} + +/* Keyboard shortcut badge */ +.admin-kbd { + display: inline-block; + padding: 2px 7px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; + border-radius: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + white-space: nowrap; +} + diff --git a/src/admin/components/AdminLayout.jsx b/src/admin/components/AdminLayout.jsx index 87acefe..63beed7 100644 --- a/src/admin/components/AdminLayout.jsx +++ b/src/admin/components/AdminLayout.jsx @@ -6,6 +6,7 @@ import { useTheme } from '../../context/ThemeContext' import { setLogoutAlert } from '../utils/api' import useModalLock from '../hooks/useModalLock' import Sidebar from './Sidebar' +import ShortcutsHelp from './ShortcutsHelp' export default function AdminLayout() { const { isAuthenticated, loading, checkSession, user, logout } = useAuth() @@ -101,6 +102,7 @@ export default function AdminLayout() {
+ ) } diff --git a/src/admin/components/OfferItemsSection.jsx b/src/admin/components/OfferItemsSection.jsx index ed600c7..879d561 100644 --- a/src/admin/components/OfferItemsSection.jsx +++ b/src/admin/components/OfferItemsSection.jsx @@ -1,12 +1,23 @@ import { motion } from 'framer-motion' +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core' +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' import { formatCurrency } from '../utils/formatters' +import SortableRow, { DragHandle } from './SortableRow' +import useSortableList from '../hooks/useSortableList' export default function OfferItemsSection({ - items, updateItem, addItem, removeItem, moveItem, + items, setItems, updateItem, addItem, removeItem, itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu, addItemFromTemplate, totals, currency, applyVat, vatRate, itemsError, readOnly }) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), + useSensor(KeyboardSensor) + ) + const { handleDragEnd } = useSortableList(setItems, '_key') return ( + {!readOnly && } # Popis položky Množství @@ -68,107 +80,112 @@ export default function OfferItemsSection({ Jedn. cena V ceně Celkem - {!readOnly && } + {!readOnly && } - - {items.map((item, index) => { - const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) - return ( - - {index + 1} - - updateItem(index, 'description', e.target.value)} - className="admin-form-input" - placeholder="Název položky" - style={{ marginBottom: '0.5rem', fontWeight: 500 }} - readOnly={readOnly} - /> - updateItem(index, 'item_description', e.target.value)} - className="admin-form-input" - placeholder="Podrobný popis (volitelný)" - style={{ fontSize: '0.8rem', opacity: 0.8 }} - readOnly={readOnly} - /> - - - updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} - className="admin-form-input" - min="0" - step="1" - style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} - readOnly={readOnly} - /> - - - updateItem(index, 'unit', e.target.value)} - className="admin-form-input" - placeholder="hod" - style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} - readOnly={readOnly} - /> - - - updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)} - className="admin-form-input" - min="0" - step="0.01" - style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} - readOnly={readOnly} - /> - - - - - - {formatCurrency(lineTotal, currency)} - - {!readOnly && ( - -
- - - {items.length > 1 && ( - - )} -
- - )} - - ) - })} - + + String(i._key))} strategy={verticalListSortingStrategy}> + + {items.map((item, index) => { + const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) + return ( + + {({ attributes, listeners }) => ( + <> + {!readOnly && ( + + + + )} + {index + 1} + + updateItem(index, 'description', e.target.value)} + className="admin-form-input" + placeholder="Název položky" + style={{ marginBottom: '0.5rem', fontWeight: 500 }} + readOnly={readOnly} + /> + updateItem(index, 'item_description', e.target.value)} + className="admin-form-input" + placeholder="Podrobný popis (volitelný)" + style={{ fontSize: '0.8rem', opacity: 0.8 }} + readOnly={readOnly} + /> + + + updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + className="admin-form-input" + min="0" + step="1" + style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} + readOnly={readOnly} + /> + + + updateItem(index, 'unit', e.target.value)} + className="admin-form-input" + placeholder="hod" + style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }} + readOnly={readOnly} + /> + + + updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)} + className="admin-form-input" + min="0" + step="0.01" + style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }} + readOnly={readOnly} + /> + + + + + + {formatCurrency(lineTotal, currency)} + + {!readOnly && ( + + {items.length > 1 && ( + + )} + + )} + + )} + + ) + })} + + +
diff --git a/src/admin/components/ShortcutsHelp.jsx b/src/admin/components/ShortcutsHelp.jsx new file mode 100644 index 0000000..fa3bd87 --- /dev/null +++ b/src/admin/components/ShortcutsHelp.jsx @@ -0,0 +1,50 @@ +import { useState } from 'react' +import useKeyboardShortcuts from '../hooks/useKeyboardShortcuts' + +const GLOBAL_SHORTCUTS = [ + { keys: '?', description: 'Zobrazit klávesové zkratky' }, + { keys: 'Ctrl + N', description: 'Nový záznam' }, + { keys: 'Ctrl + S', description: 'Uložit' }, + { keys: 'Escape', description: 'Zavřít modal / zrušit' }, + { keys: '/', description: 'Hledat' }, +] + +export default function ShortcutsHelp() { + const [open, setOpen] = useState(false) + + useKeyboardShortcuts([ + { key: '?', shift: true, handler: () => setOpen(prev => !prev) }, + { key: 'Escape', handler: () => setOpen(false), when: open }, + ]) + + if (!open) return null + + return ( +
setOpen(false)}> +
e.stopPropagation()}> +
+

Klávesové zkratky

+ +
+
+ + + {GLOBAL_SHORTCUTS.map(s => ( + + + + + ))} + +
+ {s.keys} + {s.description}
+
+
+
+ ) +} diff --git a/src/admin/components/SortableRow.jsx b/src/admin/components/SortableRow.jsx new file mode 100644 index 0000000..ba008a9 --- /dev/null +++ b/src/admin/components/SortableRow.jsx @@ -0,0 +1,53 @@ +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +export function DragHandle({ listeners, attributes }) { + return ( + + ) +} + +export default function SortableRow({ id, children, disabled }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + position: 'relative', + zIndex: isDragging ? 10 : undefined, + background: isDragging ? 'var(--bg-secondary)' : undefined, + } + + return ( + + {typeof children === 'function' + ? children({ attributes, listeners }) + : children + } + + ) +} diff --git a/src/admin/hooks/__tests__/useTableSort.test.js b/src/admin/hooks/__tests__/useTableSort.test.js new file mode 100644 index 0000000..d2e651b --- /dev/null +++ b/src/admin/hooks/__tests__/useTableSort.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import useTableSort from '../useTableSort' + +describe('useTableSort', () => { + it('vraci pocatecni stav s default hodnotami', () => { + const { result } = renderHook(() => useTableSort('name')) + expect(result.current.sort).toBe('name') + expect(result.current.order).toBe('DESC') + expect(result.current.activeSort).toBeNull() + }) + + it('respektuje custom pocatecni order', () => { + const { result } = renderHook(() => useTableSort('date', 'ASC')) + expect(result.current.sort).toBe('date') + expect(result.current.order).toBe('ASC') + }) + + it('activeSort je null dokud uzivatel neklikne', () => { + const { result } = renderHook(() => useTableSort('name')) + expect(result.current.activeSort).toBeNull() + }) + + it('po kliknuti na sloupec nastavi activeSort', () => { + const { result } = renderHook(() => useTableSort('name')) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.activeSort).toBe('name') + }) + + it('toggleuje order pri kliknuti na stejny sloupec', () => { + const { result } = renderHook(() => useTableSort('name')) + + act(() => { + result.current.handleSort('name') + }) + // Default DESC -> toggle to ASC + expect(result.current.order).toBe('ASC') + + act(() => { + result.current.handleSort('name') + }) + expect(result.current.order).toBe('DESC') + }) + + it('pri kliknuti na jiny sloupec resetuje order na DESC', () => { + const { result } = renderHook(() => useTableSort('name')) + + // Klikneme na name - toggle z DESC na ASC + act(() => { + result.current.handleSort('name') + }) + expect(result.current.order).toBe('ASC') + + // Klikneme na jiny sloupec - reset na DESC + act(() => { + result.current.handleSort('date') + }) + expect(result.current.sort).toBe('date') + expect(result.current.order).toBe('DESC') + }) +}) diff --git a/src/admin/hooks/useKeyboardShortcuts.js b/src/admin/hooks/useKeyboardShortcuts.js new file mode 100644 index 0000000..1752fa2 --- /dev/null +++ b/src/admin/hooks/useKeyboardShortcuts.js @@ -0,0 +1,36 @@ +import { useEffect, useCallback } from 'react' + +/** + * Hook pro globalni keyboard shortcuts + * @param {Array<{key: string, ctrl?: boolean, shift?: boolean, alt?: boolean, handler: Function, when?: boolean}>} shortcuts + */ +export default function useKeyboardShortcuts(shortcuts) { + const handleKeyDown = useCallback((e) => { + // Ignorovat pokud je focus v inputu/textarea/contenteditable (krome Escape) + const tag = e.target.tagName + const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable + + for (const shortcut of shortcuts) { + if (shortcut.when === false) continue + + const ctrlMatch = shortcut.ctrl ? (e.ctrlKey || e.metaKey) : !(e.ctrlKey || e.metaKey) + const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey + const altMatch = shortcut.alt ? e.altKey : !e.altKey + const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase() + + if (keyMatch && ctrlMatch && shiftMatch && altMatch) { + // Escape funguje i v inputech + if (isInput && e.key !== 'Escape') continue + + e.preventDefault() + shortcut.handler(e) + return + } + } + }, [shortcuts]) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) +} diff --git a/src/admin/hooks/useSortableList.js b/src/admin/hooks/useSortableList.js new file mode 100644 index 0000000..4476f1e --- /dev/null +++ b/src/admin/hooks/useSortableList.js @@ -0,0 +1,29 @@ +import { useCallback } from 'react' +import { arrayMove } from '@dnd-kit/sortable' + +/** + * Hook pro drag-and-drop razeni seznamu + * Vraci handleDragEnd pro DndContext + * + * @param {Function} setItems - setter pro pole polozek + * @param {string} keyField - nazev property pro unikatni identifikaci (_key, id) + */ +export default function useSortableList(setItems, keyField = '_key') { + const handleDragEnd = useCallback((event) => { + const { active, over } = event + if (!over || active.id === over.id) { + return + } + + setItems(prev => { + const oldIndex = prev.findIndex(item => String(item[keyField]) === String(active.id)) + const newIndex = prev.findIndex(item => String(item[keyField]) === String(over.id)) + if (oldIndex === -1 || newIndex === -1) { + return prev + } + return arrayMove(prev, oldIndex, newIndex) + }) + }, [setItems, keyField]) + + return { handleDragEnd } +} diff --git a/src/admin/invoices.css b/src/admin/invoices.css index 1bec7cd..59f916e 100644 --- a/src/admin/invoices.css +++ b/src/admin/invoices.css @@ -124,3 +124,18 @@ align-items: flex-start; } +@media (max-width: 640px) { + .invoice-month-btn { + width: 44px; + height: 44px; + } + + .received-upload-row { + flex-direction: column; + } + + .received-upload-file-name { + max-width: 200px; + } +} + diff --git a/src/admin/pages/Login.jsx b/src/admin/pages/Login.jsx index 501a647..cb1b32c 100644 --- a/src/admin/pages/Login.jsx +++ b/src/admin/pages/Login.jsx @@ -158,7 +158,7 @@ export default function Login() {
BOHA Automation

Interní systém

diff --git a/src/admin/pages/OfferDetail.jsx b/src/admin/pages/OfferDetail.jsx index b5b0bb3..f7a2f80 100644 --- a/src/admin/pages/OfferDetail.jsx +++ b/src/admin/pages/OfferDetail.jsx @@ -330,16 +330,6 @@ export default function OfferDetail() { setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev) } - const moveItem = (index, direction) => { - setItems(prev => { - const newItems = [...prev] - const target = index + direction - if (target < 0 || target >= newItems.length) return prev - ;[newItems[index], newItems[target]] = [newItems[target], newItems[index]] - return newItems - }) - } - const addItemFromTemplate = (template) => { setItems(prev => [...prev, { _key: `item-${++_keyCounter}`, @@ -921,10 +911,10 @@ export default function OfferDetail() { { + it('formatuje datum do cs-CZ formatu', () => { + const result = formatDate('2026-03-12') + expect(result).toMatch(/12/) + expect(result).toMatch(/2026/) + }) + + it('vraci pomlcku pro falsy hodnoty', () => { + expect(formatDate('')).toBe('—') + expect(formatDate(null)).toBe('—') + expect(formatDate(undefined)).toBe('—') + }) +}) + +describe('formatTime', () => { + it('formatuje cas ve formatu HH:MM', () => { + const result = formatTime('2026-03-12T14:30:00') + expect(result).toBe('14:30') + }) + + it('vraci pomlcku pro prazdny vstup', () => { + expect(formatTime('')).toBe('—') + expect(formatTime(null)).toBe('—') + }) +}) + +describe('formatDatetime', () => { + it('formatuje datum a cas dohromady', () => { + const result = formatDatetime('2026-03-12T14:30:00') + expect(result).toContain('12') + expect(result).toContain('14:30') + }) + + it('vraci pomlcku pro prazdny vstup', () => { + expect(formatDatetime('')).toBe('—') + expect(formatDatetime(null)).toBe('—') + }) +}) + +describe('formatMinutes', () => { + it('formatuje minuty na H:MM', () => { + expect(formatMinutes(90)).toBe('1:30') + expect(formatMinutes(60)).toBe('1:00') + expect(formatMinutes(0)).toBe('0:00') + expect(formatMinutes(125)).toBe('2:05') + }) + + it('formatuje s jednotkou kdyz withUnit=true', () => { + expect(formatMinutes(90, true)).toBe('1:30 h') + expect(formatMinutes(0, true)).toBe('0:00 h') + }) + + it('formatuje male hodnoty s paddingem', () => { + expect(formatMinutes(5)).toBe('0:05') + expect(formatMinutes(1)).toBe('0:01') + }) +}) + +describe('calculateWorkMinutes', () => { + it('spocita minuty bez prestavky', () => { + const record = { + arrival_time: '2026-03-12T08:00:00', + departure_time: '2026-03-12T16:00:00' + } + expect(calculateWorkMinutes(record)).toBe(480) + }) + + it('odecte prestavku', () => { + const record = { + arrival_time: '2026-03-12T08:00:00', + departure_time: '2026-03-12T16:00:00', + break_start: '2026-03-12T12:00:00', + break_end: '2026-03-12T12:30:00' + } + expect(calculateWorkMinutes(record)).toBe(450) + }) + + it('vraci 0 kdyz chybi prichod nebo odchod', () => { + expect(calculateWorkMinutes({ arrival_time: '2026-03-12T08:00:00' })).toBe(0) + expect(calculateWorkMinutes({ departure_time: '2026-03-12T16:00:00' })).toBe(0) + expect(calculateWorkMinutes({})).toBe(0) + }) + + it('vraci 0 pro zaporne minuty', () => { + const record = { + arrival_time: '2026-03-12T16:00:00', + departure_time: '2026-03-12T08:00:00' + } + expect(calculateWorkMinutes(record)).toBe(0) + }) +}) + +describe('getLeaveTypeName', () => { + it('vraci spravne nazvy pro zname typy', () => { + expect(getLeaveTypeName('work')).toBe('Práce') + expect(getLeaveTypeName('vacation')).toBe('Dovolená') + expect(getLeaveTypeName('sick')).toBe('Nemoc') + expect(getLeaveTypeName('holiday')).toBe('Svátek') + expect(getLeaveTypeName('unpaid')).toBe('Neplacené volno') + }) + + it('vraci Prace jako fallback', () => { + expect(getLeaveTypeName('unknown')).toBe('Práce') + expect(getLeaveTypeName(undefined)).toBe('Práce') + }) +}) + +describe('getLeaveTypeBadgeClass', () => { + it('vraci spravne CSS tridy', () => { + expect(getLeaveTypeBadgeClass('vacation')).toBe('badge-vacation') + expect(getLeaveTypeBadgeClass('sick')).toBe('badge-sick') + expect(getLeaveTypeBadgeClass('holiday')).toBe('badge-holiday') + expect(getLeaveTypeBadgeClass('unpaid')).toBe('badge-unpaid') + }) + + it('vraci prazdny retezec pro work a nezname typy', () => { + expect(getLeaveTypeBadgeClass('work')).toBe('') + expect(getLeaveTypeBadgeClass('unknown')).toBe('') + expect(getLeaveTypeBadgeClass(undefined)).toBe('') + }) +}) diff --git a/src/admin/utils/__tests__/formatters.test.js b/src/admin/utils/__tests__/formatters.test.js new file mode 100644 index 0000000..38a959f --- /dev/null +++ b/src/admin/utils/__tests__/formatters.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import { formatCurrency, formatDate, formatKm, czechPlural } from '../formatters' + +describe('formatCurrency', () => { + it('formatuje CZK s dvema desetinnymi misty', () => { + const result = formatCurrency(1234.5, 'CZK') + expect(result).toContain('Kč') + expect(result).toMatch(/1[\s\u00a0]?234,50/) + }) + + it('formatuje EUR s eurem za castkou', () => { + const result = formatCurrency(99.9, 'EUR') + expect(result).toContain('€') + expect(result).toContain('99,90') + }) + + it('formatuje USD s dolarem pred castkou', () => { + const result = formatCurrency(1500, 'USD') + expect(result).toMatch(/^\$/) + expect(result).toContain('1,500.00') + }) + + it('formatuje GBP s librou pred castkou', () => { + const result = formatCurrency(250, 'GBP') + expect(result).toMatch(/^£/) + expect(result).toContain('250.00') + }) + + it('pouzije fallback pro neznámou menu', () => { + const result = formatCurrency(100, 'CHF') + expect(result).toBe('100.00 CHF') + }) + + it('vraci 0 pro nevalidni vstup', () => { + const result = formatCurrency('abc', 'CZK') + expect(result).toContain('0,00') + expect(result).toContain('Kč') + }) + + it('vraci 0 pro null', () => { + const result = formatCurrency(null, 'CZK') + expect(result).toContain('0,00') + }) + + it('formatuje zaporne castky', () => { + const result = formatCurrency(-500, 'CZK') + expect(result).toContain('Kč') + expect(result).toContain('500') + }) +}) + +describe('formatDate', () => { + it('formatuje datum do cs-CZ formatu', () => { + const result = formatDate('2026-03-12') + // cs-CZ format: 12. 3. 2026 nebo 12.3.2026 + expect(result).toMatch(/12/) + expect(result).toMatch(/3/) + expect(result).toMatch(/2026/) + }) + + it('vraci pomlcku pro prazdny vstup', () => { + expect(formatDate('')).toBe('—') + expect(formatDate(null)).toBe('—') + expect(formatDate(undefined)).toBe('—') + }) +}) + +describe('formatKm', () => { + it('formatuje kilometry s oddelovacem tisicu', () => { + const result = formatKm(12345) + // cs-CZ pouziva mezeru nebo narrow no-break space jako oddelovac + expect(result).toMatch(/12[\s\u00a0]?345/) + }) + + it('vraci 0 pro nevalidni vstup', () => { + expect(formatKm('abc')).toBe('0') + expect(formatKm(null)).toBe('0') + }) + + it('formatuje mala cisla bez oddelovace', () => { + expect(formatKm(42)).toBe('42') + }) +}) + +describe('czechPlural', () => { + it('vraci tvar pro 1', () => { + expect(czechPlural(1, 'den', 'dny', 'dní')).toBe('den') + }) + + it('vraci tvar pro 2-4', () => { + expect(czechPlural(2, 'den', 'dny', 'dní')).toBe('dny') + expect(czechPlural(3, 'den', 'dny', 'dní')).toBe('dny') + expect(czechPlural(4, 'den', 'dny', 'dní')).toBe('dny') + }) + + it('vraci tvar pro 0 a 5+', () => { + expect(czechPlural(0, 'den', 'dny', 'dní')).toBe('dní') + expect(czechPlural(5, 'den', 'dny', 'dní')).toBe('dní') + expect(czechPlural(10, 'den', 'dny', 'dní')).toBe('dní') + expect(czechPlural(100, 'den', 'dny', 'dní')).toBe('dní') + }) +}) diff --git a/src/test/setup.js b/src/test/setup.js new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/test/setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/vite.config.js b/vite.config.js index 1f6f7cb..8b44012 100644 --- a/vite.config.js +++ b/vite.config.js @@ -58,6 +58,11 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [react(), copyFoldersPlugin()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.js', + }, build: { outDir: 'dist', emptyOutDir: true,