Update: 2026-05-03 22:51:59
This commit is contained in:
@@ -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']
|
||||
]
|
||||
], 'تم تسجيل الدخول بنجاح');
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* Auth Refresh Endpoint
|
||||
* Refresh Token Endpoint (Secure Cookie Based)
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\JWT;
|
||||
use Firebase\JWT\JWT;
|
||||
|
||||
$data = input();
|
||||
$refreshToken = $data['refresh_token'] ?? null;
|
||||
// 1. Get Refresh Token from HttpOnly Cookie
|
||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||
|
||||
if (!$refreshToken) {
|
||||
json_error('Refresh token is required', 400);
|
||||
json_error('Refresh token is required', 401);
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$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]);
|
||||
$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
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
72
app/modules_app/invoices/upload.php
Normal file
72
app/modules_app/invoices/upload.php
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Users List Endpoint (with Decryption)
|
||||
* Users List Endpoint (Role-Based & Tenant-Aware)
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
@@ -9,26 +9,51 @@ use App\Middleware\AuthMiddleware;
|
||||
|
||||
// 1. Auth Check
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
// 2. Simple Role-Based Access Control (RBAC)
|
||||
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
||||
json_error('غير مصرح لك بالوصول لهذه البيانات', 403);
|
||||
$role = $decoded['role'];
|
||||
$tenantId = $decoded['tenant_id'] ?? null;
|
||||
|
||||
// 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();
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -110,6 +112,8 @@
|
||||
<tr class="border-t border-gray-800">
|
||||
<td class="p-4" x-text="u.name"></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">
|
||||
<span class="px-2 py-1 bg-gray-800 rounded" x-text="u.role"></span>
|
||||
</td>
|
||||
|
||||
@@ -56,6 +56,33 @@ foreach ($users as $user) {
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user