diff --git a/app/modules_app/auth/login.php b/app/modules_app/auth/login.php index a852226..24db0d9 100644 --- a/app/modules_app/auth/login.php +++ b/app/modules_app/auth/login.php @@ -60,13 +60,22 @@ $refreshTokenHash = hash('sha256', $refreshToken); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); $stmt->execute([$refreshTokenHash, $user['id']]); +// 7. Secure Refresh Token delivery via HttpOnly Cookie +setcookie('refresh_token', $refreshToken, [ + 'expires' => time() + (7 * 24 * 60 * 60), // 7 days + 'path' => '/api/v1/auth/refresh', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', +]); + json_success([ 'access_token' => $token, - 'refresh_token' => $refreshToken, 'user' => [ 'id' => $user['id'], 'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']), 'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email']), - 'role' => $user['role'] + 'role' => $user['role'], + 'tenant_id' => $user['tenant_id'] ] ], 'تم تسجيل الدخول بنجاح'); diff --git a/app/modules_app/auth/refresh.php b/app/modules_app/auth/refresh.php index 73844b8..bcb4f69 100644 --- a/app/modules_app/auth/refresh.php +++ b/app/modules_app/auth/refresh.php @@ -1,21 +1,23 @@ prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1"); + +// 2. Verify in DB +$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1"); $stmt->execute([$refreshTokenHash]); $user = $stmt->fetch(); @@ -23,25 +25,21 @@ if (!$user) { json_error('Invalid refresh token', 401); } -$secret = env('JWT_SECRET'); -if (!$secret || strlen($secret) < 32) { - error_log('FATAL: JWT_SECRET is missing or too short in .env'); +// 3. Generate New Access Token +$secret = $_ENV['JWT_SECRET'] ?? null; +if (!$secret) { json_error('Server configuration error', 500); } + $payload = [ - 'user_id' => $user['id'], - 'role' => $user['role'], - 'exp' => time() + (15 * 60) + 'user_id' => $user['id'], + 'tenant_id' => $user['tenant_id'], // Now including tenant_id + 'role' => $user['role'], + 'exp' => time() + (15 * 60) // 15 minutes ]; -$newToken = JWT::encode($payload, $secret); -$newRefreshToken = bin2hex(random_bytes(32)); -$newRefreshTokenHash = hash('sha256', $newRefreshToken); - -$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); -$stmt->execute([$newRefreshTokenHash, $user['id']]); +$token = JWT::encode($payload, $secret, 'HS256'); json_success([ - 'access_token' => $newToken, - 'refresh_token' => $newRefreshToken -], 'تم تجديد الجلسة بنجاح'); + 'access_token' => $token +]); diff --git a/app/modules_app/dashboard/stats.php b/app/modules_app/dashboard/stats.php index a80f30f..fad1c3b 100644 --- a/app/modules_app/dashboard/stats.php +++ b/app/modules_app/dashboard/stats.php @@ -10,32 +10,41 @@ use App\Middleware\AuthMiddleware; $decoded = AuthMiddleware::check(); $db = Database::getInstance(); -$tenantId = $decoded['tenant_id']; +$tenantId = $decoded['tenant_id'] ?? null; $companyId = $decoded['company_id'] ?? null; $role = $decoded['role']; try { - // 2. Build Query based on Role - $where = "WHERE tenant_id = :tenant_id"; - $params = [':tenant_id' => $tenantId]; + $where = "WHERE 1=1"; + $params = []; - // If accountant or employee restricted to a company - if (($role === 'accountant' || $role === 'viewer') && $companyId) { - $where .= " AND company_id = :company_id"; - $params[':company_id'] = $companyId; + // 2. Apply Filters based on Role + if ($role === 'super_admin') { + // No filters - see everything + } elseif ($role === 'admin') { + // Filter by Tenant (Accounting Office) + $where .= " AND tenant_id = :tenant_id"; + $params[':tenant_id'] = $tenantId; + } else { + // Accountant/Viewer: Filter by specific company + $where .= " AND tenant_id = :tenant_id"; + $params[':tenant_id'] = $tenantId; + + if ($companyId) { + $where .= " AND company_id = :company_id"; + $params[':company_id'] = $companyId; + } } - // Total Invoices + // 3. Fetch Stats $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where"); $stmt->execute($params); $total = $stmt->fetchColumn(); - // Pending Invoices $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'pending'"); $stmt->execute($params); $pending = $stmt->fetchColumn(); - // Approved Invoices $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'"); $stmt->execute($params); $approved = $stmt->fetchColumn(); diff --git a/app/modules_app/invoices/upload.php b/app/modules_app/invoices/upload.php new file mode 100644 index 0000000..4cf6e23 --- /dev/null +++ b/app/modules_app/invoices/upload.php @@ -0,0 +1,72 @@ +prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL"); + $stmt->execute([$companyId, $tenantId]); +} elseif ($decoded['role'] === 'accountant') { + $stmt = $db->prepare(" + SELECT c.id FROM companies c + JOIN user_company_assignments uca ON c.id = uca.company_id + WHERE c.id = ? AND uca.user_id = ? AND uca.is_active = 1 + "); + $stmt->execute([$companyId, $userId]); +} else { // employee + // In our schema, employee is linked via users.company_id + $stmt = $db->prepare("SELECT id FROM users WHERE id = ? AND company_id = ?"); + $stmt->execute([$userId, $companyId]); +} + +if (!$stmt->fetch()) { + json_error('Access denied to this company', 403); +} + +// 4. Handle File Upload (Mock logic for now, using storage/invoices) +$uploadDir = __DIR__ . '/../../../storage/invoices/' . $tenantId . '/' . $companyId . '/'; +if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + +$fileName = time() . '_' . basename($_FILES['invoice']['name']); +$targetFile = $uploadDir . $fileName; + +if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { + // 5. Save to DB + $stmt = $db->prepare(" + INSERT INTO invoices ( + tenant_id, company_id, status, uploaded_by, original_file_path, created_at + ) VALUES (?, ?, 'uploaded', ?, ?, NOW()) + "); + $stmt->execute([ + $tenantId, + $companyId, + $userId, + $targetFile + ]); + + json_success(['id' => $db->lastInsertId()], 'تم رفع الفاتورة بنجاح وبدأت عملية المعالجة'); +} else { + json_error('Failed to save uploaded file', 500); +} diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index cc340da..53e13e6 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -16,6 +16,17 @@ if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') { $data = input(); +// 1. Role Authorization check (Prevent Role Escalation) +$allowedRoles = match($decoded['role']) { + 'super_admin' => ['super_admin', 'admin', 'accountant', 'employee', 'viewer'], + 'admin' => ['accountant', 'employee', 'viewer'], + default => [] +}; + +if (!in_array($data['role'] ?? '', $allowedRoles, true)) { + json_error('غير مصرح لك بإنشاء مستخدم بهذا الدور', 403); +} + // 2. Validation $errors = Validator::validate($data, [ 'name' => 'required', diff --git a/app/modules_app/users/index.php b/app/modules_app/users/index.php index 3193e34..5d0db27 100644 --- a/app/modules_app/users/index.php +++ b/app/modules_app/users/index.php @@ -1,6 +1,6 @@ query(" + SELECT u.id, u.name, u.email, u.role, u.is_active, u.created_at, t.name as tenant_name, c.name as company_name + FROM users u + LEFT JOIN tenants t ON u.tenant_id = t.id + LEFT JOIN companies c ON u.company_id = c.id + "); +} elseif ($role === 'admin') { + // Admin sees only users in THEIR tenant (Accounting Office) + $stmt = $db->prepare(" + SELECT u.id, u.name, u.email, u.role, u.is_active, u.created_at, t.name as tenant_name, c.name as company_name + FROM users u + LEFT JOIN tenants t ON u.tenant_id = t.id + LEFT JOIN companies c ON u.company_id = c.id + WHERE u.tenant_id = ? + "); + $stmt->execute([$tenantId]); +} else { + // Other roles shouldn't see user list + json_error('Unauthorized', 403); } -// 3. Fetch Data -$db = Database::getInstance(); -$stmt = $db->prepare("SELECT id, name, email, role, is_active, created_at FROM users"); -$stmt->execute(); $users = $stmt->fetchAll(); -// 4. Decrypt sensitive data for the UI +// 3. Decrypt data and format foreach ($users as &$user) { - // Try to decrypt. If it fails (e.g. data was plain text), keep original. + // Decrypt User Name/Email $decryptedName = Encryption::decrypt($user['name']); $user['name'] = $decryptedName !== false ? $decryptedName : $user['name']; $decryptedEmail = Encryption::decrypt($user['email']); $user['email'] = $decryptedEmail !== false ? $decryptedEmail : $user['email']; + + // Decrypt Company Name (if exists) + if ($user['company_name']) { + $decryptedCompanyName = Encryption::decrypt($user['company_name']); + $user['company_name'] = $decryptedCompanyName !== false ? $decryptedCompanyName : $user['company_name']; + } } json_success($users); diff --git a/public/index.php b/public/index.php index 51363a3..9122216 100644 --- a/public/index.php +++ b/public/index.php @@ -23,6 +23,7 @@ $routes = [ 'v1/users/create' => ['POST', 'users/create.php'], 'v1/companies' => ['GET', 'companies/index.php'], 'v1/companies/create' => ['POST', 'companies/create.php'], + 'v1/invoices/upload' => ['POST', 'invoices/upload.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], ]; diff --git a/public/shell.php b/public/shell.php index 2d45f6f..5dea78f 100644 --- a/public/shell.php +++ b/public/shell.php @@ -102,6 +102,8 @@