Update: 2026-05-03 22:51:59

This commit is contained in:
Hamza-Ayed
2026-05-03 22:51:59 +03:00
parent 6d2c61497c
commit 87809ac893
9 changed files with 201 additions and 45 deletions

View File

@@ -60,13 +60,22 @@ $refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
$stmt->execute([$refreshTokenHash, $user['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([ json_success([
'access_token' => $token, 'access_token' => $token,
'refresh_token' => $refreshToken,
'user' => [ 'user' => [
'id' => $user['id'], 'id' => $user['id'],
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']), 'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email']), 'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
'role' => $user['role'] 'role' => $user['role'],
'tenant_id' => $user['tenant_id']
] ]
], 'تم تسجيل الدخول بنجاح'); ], 'تم تسجيل الدخول بنجاح');

View File

@@ -1,21 +1,23 @@
<?php <?php
/** /**
* Auth Refresh Endpoint * Refresh Token Endpoint (Secure Cookie Based)
*/ */
use App\Core\Database; use App\Core\Database;
use App\Core\JWT; use Firebase\JWT\JWT;
$data = input(); // 1. Get Refresh Token from HttpOnly Cookie
$refreshToken = $data['refresh_token'] ?? null; $refreshToken = $_COOKIE['refresh_token'] ?? null;
if (!$refreshToken) { if (!$refreshToken) {
json_error('Refresh token is required', 400); json_error('Refresh token is required', 401);
} }
$db = Database::getInstance(); $db = Database::getInstance();
$refreshTokenHash = hash('sha256', $refreshToken); $refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->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]); $stmt->execute([$refreshTokenHash]);
$user = $stmt->fetch(); $user = $stmt->fetch();
@@ -23,25 +25,21 @@ if (!$user) {
json_error('Invalid refresh token', 401); json_error('Invalid refresh token', 401);
} }
$secret = env('JWT_SECRET'); // 3. Generate New Access Token
if (!$secret || strlen($secret) < 32) { $secret = $_ENV['JWT_SECRET'] ?? null;
error_log('FATAL: JWT_SECRET is missing or too short in .env'); if (!$secret) {
json_error('Server configuration error', 500); json_error('Server configuration error', 500);
} }
$payload = [ $payload = [
'user_id' => $user['id'], 'user_id' => $user['id'],
'role' => $user['role'], 'tenant_id' => $user['tenant_id'], // Now including tenant_id
'exp' => time() + (15 * 60) 'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes
]; ];
$newToken = JWT::encode($payload, $secret); $token = JWT::encode($payload, $secret, 'HS256');
$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']]);
json_success([ json_success([
'access_token' => $newToken, 'access_token' => $token
'refresh_token' => $newRefreshToken ]);
], 'تم تجديد الجلسة بنجاح');

View File

@@ -10,32 +10,41 @@ use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check(); $decoded = AuthMiddleware::check();
$db = Database::getInstance(); $db = Database::getInstance();
$tenantId = $decoded['tenant_id']; $tenantId = $decoded['tenant_id'] ?? null;
$companyId = $decoded['company_id'] ?? null; $companyId = $decoded['company_id'] ?? null;
$role = $decoded['role']; $role = $decoded['role'];
try { try {
// 2. Build Query based on Role $where = "WHERE 1=1";
$where = "WHERE tenant_id = :tenant_id"; $params = [];
$params = [':tenant_id' => $tenantId];
// If accountant or employee restricted to a company // 2. Apply Filters based on Role
if (($role === 'accountant' || $role === 'viewer') && $companyId) { if ($role === 'super_admin') {
$where .= " AND company_id = :company_id"; // No filters - see everything
$params[':company_id'] = $companyId; } 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 = $db->prepare("SELECT COUNT(*) FROM invoices $where");
$stmt->execute($params); $stmt->execute($params);
$total = $stmt->fetchColumn(); $total = $stmt->fetchColumn();
// Pending Invoices
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'pending'"); $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'pending'");
$stmt->execute($params); $stmt->execute($params);
$pending = $stmt->fetchColumn(); $pending = $stmt->fetchColumn();
// Approved Invoices
$stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'"); $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'");
$stmt->execute($params); $stmt->execute($params);
$approved = $stmt->fetchColumn(); $approved = $stmt->fetchColumn();

View File

@@ -0,0 +1,72 @@
<?php
/**
* Invoice Upload Endpoint (Multi-Tenant & Role-Aware)
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$allowedRoles = ['admin', 'accountant', 'employee'];
if (!in_array($decoded['role'], $allowedRoles)) {
json_error('Unauthorized to upload invoices', 403);
}
// 2. Validate Request
$companyId = $_POST['company_id'] ?? null;
if (!$companyId || !isset($_FILES['invoice'])) {
json_error('Company ID and invoice file are required', 422);
}
// 3. Permission Check (Can this user upload to this company?)
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
if ($decoded['role'] === 'admin') {
$stmt = $db->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);
}

View File

@@ -16,6 +16,17 @@ if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
$data = input(); $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 // 2. Validation
$errors = Validator::validate($data, [ $errors = Validator::validate($data, [
'name' => 'required', 'name' => 'required',

View File

@@ -1,6 +1,6 @@
<?php <?php
/** /**
* Users List Endpoint (with Decryption) * Users List Endpoint (Role-Based & Tenant-Aware)
*/ */
use App\Core\Database; use App\Core\Database;
@@ -9,26 +9,51 @@ use App\Middleware\AuthMiddleware;
// 1. Auth Check // 1. Auth Check
$decoded = AuthMiddleware::check(); $decoded = AuthMiddleware::check();
$db = Database::getInstance();
// 2. Simple Role-Based Access Control (RBAC) $role = $decoded['role'];
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') { $tenantId = $decoded['tenant_id'] ?? null;
json_error('غير مصرح لك بالوصول لهذه البيانات', 403);
// 2. Build Query based on Role
if ($role === 'super_admin') {
// Super Admin sees ALL users from ALL tenants
$stmt = $db->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(); $users = $stmt->fetchAll();
// 4. Decrypt sensitive data for the UI // 3. Decrypt data and format
foreach ($users as &$user) { 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']); $decryptedName = Encryption::decrypt($user['name']);
$user['name'] = $decryptedName !== false ? $decryptedName : $user['name']; $user['name'] = $decryptedName !== false ? $decryptedName : $user['name'];
$decryptedEmail = Encryption::decrypt($user['email']); $decryptedEmail = Encryption::decrypt($user['email']);
$user['email'] = $decryptedEmail !== false ? $decryptedEmail : $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); json_success($users);

View File

@@ -23,6 +23,7 @@ $routes = [
'v1/users/create' => ['POST', 'users/create.php'], 'v1/users/create' => ['POST', 'users/create.php'],
'v1/companies' => ['GET', 'companies/index.php'], 'v1/companies' => ['GET', 'companies/index.php'],
'v1/companies/create' => ['POST', 'companies/create.php'], 'v1/companies/create' => ['POST', 'companies/create.php'],
'v1/invoices/upload' => ['POST', 'invoices/upload.php'],
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],
]; ];

View File

@@ -102,6 +102,8 @@
<tr> <tr>
<th class="p-4">الاسم</th> <th class="p-4">الاسم</th>
<th class="p-4">البريد الإلكتروني</th> <th class="p-4">البريد الإلكتروني</th>
<th class="p-4">المكتب</th>
<th class="p-4">الشركة المعينة</th>
<th class="p-4">الدور</th> <th class="p-4">الدور</th>
</tr> </tr>
</thead> </thead>
@@ -110,6 +112,8 @@
<tr class="border-t border-gray-800"> <tr class="border-t border-gray-800">
<td class="p-4" x-text="u.name"></td> <td class="p-4" x-text="u.name"></td>
<td class="p-4" x-text="u.email"></td> <td class="p-4" x-text="u.email"></td>
<td class="p-4 text-xs text-gray-400" x-text="u.tenant_name || '-'"></td>
<td class="p-4 text-xs text-gray-400" x-text="u.company_name || 'كامل المكتب'"></td>
<td class="p-4 text-xs uppercase text-gray-500"> <td class="p-4 text-xs uppercase text-gray-500">
<span class="px-2 py-1 bg-gray-800 rounded" x-text="u.role"></span> <span class="px-2 py-1 bg-gray-800 rounded" x-text="u.role"></span>
</td> </td>

View File

@@ -56,6 +56,33 @@ foreach ($users as $user) {
echo "User ID {$user['id']} migrated successfully.\n"; echo "User ID {$user['id']} migrated successfully.\n";
} }
// (Table creation logic removed because it is properly handled by schema.sql) // 4. Create user_company_assignments table
try {
$db->exec("CREATE TABLE IF NOT EXISTS user_company_assignments (
id CHAR(36) NOT NULL DEFAULT (UUID()),
user_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
assigned_by CHAR(36) NOT NULL,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id),
UNIQUE KEY uq_user_company (user_id, company_id),
CONSTRAINT fk_uca_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_uca_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
CONSTRAINT fk_uca_admin FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo "[OK] User_company_assignments table created.\n";
} catch (\Exception $e) {
echo "[SKIP] user_company_assignments table: " . $e->getMessage() . "\n";
}
// 5. Update invoices table to include uploaded_by
try {
$db->exec("ALTER TABLE invoices ADD COLUMN uploaded_by CHAR(36) NULL AFTER status");
$db->exec("ALTER TABLE invoices ADD CONSTRAINT fk_inv_uploader FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL");
echo "[OK] Updated invoices table with uploaded_by tracker.\n";
} catch (\Exception $e) {
echo "[SKIP] invoices table update: " . $e->getMessage() . "\n";
}
echo "--- Migration Complete ---\n"; echo "--- Migration Complete ---\n";