Commit 94b63987 authored by Administrator's avatar Administrator

Update 41 files via Son of Anton

parent 65a8298d
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Middleware\MiddlewareInterface;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Users\Models\Employee;
final class AuthMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
$app = App::getInstance();
$session = $app->session();
$employeeId = $session->get('employee_id');
if (!$employeeId) {
return (new Response())->redirect('/login');
}
$employee = Employee::find((int) $employeeId);
if (!$employee || !$employee->is_active || $employee->is_archived) {
$session->destroy();
return (new Response())->redirect('/login');
}
$timeout = (int) config('auth.session_lifetime', 30) * 60;
$lastActivity = $session->get('last_activity_at', 0);
if ($lastActivity && (time() - $lastActivity) > $timeout) {
$session->destroy();
return (new Response())->redirect('/login')->withError('انتهت الجلسة. يرجى تسجيل الدخول مجدداً.');
}
$session->set('last_activity_at', time());
$app->setCurrentEmployee($employee);
if ($employee->branch_id) {
$branch = $app->db()->selectOne("SELECT * FROM branches WHERE id = ?", [$employee->branch_id]);
if ($branch) {
$app->setCurrentBranch($branch);
}
}
$this->updateActiveSession($app, $employee, $request);
$currentPath = $request->path();
if ($employee->force_password_change && $currentPath !== '/change-password' && $currentPath !== '/logout') {
return (new Response())->redirect('/change-password');
}
$requiredPermission = $request->getAttribute('_permission');
if ($requiredPermission && !$employee->hasPermission($requiredPermission)) {
return (new Response())->html(
$this->render403(),
403
);
}
return $next($request);
}
private function updateActiveSession(App $app, Employee $employee, Request $request): void
{
try {
$sessionId = $app->session()->id();
if (!$sessionId) {
return;
}
$db = $app->db();
$existing = $db->selectOne(
"SELECT id FROM active_sessions WHERE employee_id = ? AND session_id = ? AND is_active = 1",
[$employee->id, $sessionId]
);
if ($existing) {
$db->update('active_sessions', [
'last_activity_at' => date('Y-m-d H:i:s'),
'ip_address' => $request->ip(),
], '`id` = ?', [$existing['id']]);
}
} catch (\Throwable $e) {
// Non-critical, don't break the request
}
}
private function render403(): string
{
return '<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>غير مصرح</title>'
. '<style>body{font-family:Cairo,Tahoma,sans-serif;text-align:center;padding:80px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:64px;margin-bottom:10px;}p{color:#6B7280;font-size:18px;}'
. 'a{color:#0D7377;text-decoration:none;font-size:16px;}</style></head>'
. '<body><h1>403</h1><p>غير مصرح لك بالوصول لهذه الصفحة</p>'
. '<a href="/">← العودة للرئيسية</a></body></html>';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Middleware\MiddlewareInterface;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
final class PermissionMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
$permission = $request->getAttribute('_permission');
if (!$permission) {
return $next($request);
}
$employee = App::getInstance()->currentEmployee();
if (!$employee) {
return (new Response())->redirect('/login');
}
if (method_exists($employee, 'hasPermission') && !$employee->hasPermission($permission)) {
return (new Response())->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>غير مصرح</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:80px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:64px;}p{color:#6B7280;}</style></head>'
. '<body><h1>403</h1><p>ليس لديك صلاحية لهذا الإجراء</p><a href="/">← الرئيسية</a></body></html>',
403
);
}
return $next($request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Middleware\MiddlewareInterface;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
final class RateLimitMiddleware implements MiddlewareInterface
{
private int $maxRequests = 60;
private int $windowSeconds = 60;
public function handle(Request $request, callable $next): Response
{
$ip = $request->ip();
$key = 'rate_limit_' . md5($ip . $request->path());
$session = App::getInstance()->session();
$data = $session->get($key, ['count' => 0, 'reset_at' => time() + $this->windowSeconds]);
if (time() > $data['reset_at']) {
$data = ['count' => 0, 'reset_at' => time() + $this->windowSeconds];
}
$data['count']++;
$session->set($key, $data);
if ($data['count'] > $this->maxRequests) {
if ($request->isAjax() || $request->isJson()) {
return (new Response())->json(['error' => 'تم تجاوز الحد الأقصى للطلبات. حاول لاحقاً.'], 429);
}
return (new Response())->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>كثرة الطلبات</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:80px;}</style></head>'
. '<body><h1>429</h1><p>تم تجاوز الحد الأقصى للطلبات. يرجى المحاولة لاحقاً.</p></body></html>',
429
);
}
return $next($request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Auth\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Users\Models\Employee;
use App\Modules\Auth\Models\LoginAttempt;
use App\Modules\Auth\Models\ActiveSession;
class AuthController extends Controller
{
public function home(Request $request): Response
{
$redirect = config('auth.default_redirect', '/users');
return $this->redirect($redirect);
}
public function login(Request $request): Response
{
$session = App::getInstance()->session();
if ($session->get('employee_id')) {
return $this->redirect(config('auth.default_redirect', '/users'));
}
return $this->view('Auth.Views.login', []);
}
public function authenticate(Request $request): Response
{
$username = trim((string) $request->post('username', ''));
$password = (string) $request->post('password', '');
$ip = $request->ip();
$userAgent = $request->userAgent();
if ($username === '' || $password === '') {
return $this->redirect('/login')->withError('يرجى إدخال اسم المستخدم وكلمة المرور');
}
$employee = Employee::findByUsername($username);
if (!$employee) {
LoginAttempt::record($username, $ip, $userAgent, false, 'user_not_found');
return $this->redirect('/login')->withError('بيانات الدخول غير صحيحة');
}
if ($employee->is_archived) {
LoginAttempt::record($username, $ip, $userAgent, false, 'archived');
return $this->redirect('/login')->withError('هذا الحساب محذوف');
}
if (!$employee->is_active) {
LoginAttempt::record($username, $ip, $userAgent, false, 'inactive');
return $this->redirect('/login')->withError('هذا الحساب معطل');
}
if ($employee->isLocked()) {
LoginAttempt::record($username, $ip, $userAgent, false, 'locked');
return $this->redirect('/login')->withError('الحساب مقفل مؤقتاً بسبب محاولات دخول فاشلة. حاول لاحقاً.');
}
if (!password_verify($password, $employee->password_hash)) {
$employee->incrementFailedLogins();
LoginAttempt::record($username, $ip, $userAgent, false, 'wrong_password');
return $this->redirect('/login')->withError('بيانات الدخول غير صحيحة');
}
$employee->resetFailedLogins();
$employee->recordLogin($ip);
$session = App::getInstance()->session();
$session->regenerate();
$session->set('employee_id', (int) $employee->id);
$session->set('employee_name', $employee->full_name_ar);
$session->set('logged_in_at', time());
$session->set('last_activity_at', time());
LoginAttempt::record($username, $ip, $userAgent, true, null);
ActiveSession::createSession((int) $employee->id, $session->id(), $ip, $userAgent);
$this->enforceMaxSessions((int) $employee->id, $session->id());
$eventData = ['employee_id' => (int) $employee->id, 'ip' => $ip];
EventBus::dispatch('auth.login', $eventData);
Logger::info("Employee logged in: {$username}", ['ip' => $ip]);
if ($employee->force_password_change) {
return $this->redirect('/change-password');
}
return $this->redirect(config('auth.default_redirect', '/users'));
}
public function logout(Request $request): Response
{
$app = App::getInstance();
$session = $app->session();
$employeeId = $session->get('employee_id');
if ($employeeId) {
ActiveSession::deactivateSession($session->id());
$eventData = ['employee_id' => $employeeId];
EventBus::dispatch('auth.logout', $eventData);
Logger::info("Employee logged out", ['employee_id' => $employeeId]);
}
$session->destroy();
return $this->redirect('/login');
}
public function changePassword(Request $request): Response
{
return $this->view('Auth.Views.change-password', []);
}
public function updatePassword(Request $request): Response
{
$employee = $this->currentEmployee();
if (!$employee) {
return $this->redirect('/login');
}
$currentPassword = (string) $request->post('current_password', '');
$newPassword = (string) $request->post('new_password', '');
$confirmPassword = (string) $request->post('new_password_confirmation', '');
$errors = [];
if (!$employee->force_password_change && $currentPassword === '') {
$errors[] = 'يرجى إدخال كلمة المرور الحالية';
}
if (!$employee->force_password_change && !password_verify($currentPassword, $employee->password_hash)) {
$errors[] = 'كلمة المرور الحالية غير صحيحة';
}
$minLength = (int) config('auth.password_min_length', 8);
if (mb_strlen($newPassword) < $minLength) {
$errors[] = "كلمة المرور الجديدة يجب أن تكون {$minLength} أحرف على الأقل";
}
if ($newPassword !== $confirmPassword) {
$errors[] = 'كلمة المرور الجديدة غير متطابقة مع التأكيد';
}
if (!preg_match('/[A-Z]/', $newPassword) || !preg_match('/[a-z]/', $newPassword) || !preg_match('/[0-9]/', $newPassword)) {
$errors[] = 'كلمة المرور يجب أن تحتوي على حرف كبير وحرف صغير ورقم';
}
if (empty($errors)) {
$historyCount = (int) config('auth.password_history_count', 5);
$db = App::getInstance()->db();
$history = $db->select(
"SELECT password_hash FROM password_history WHERE employee_id = ? ORDER BY created_at DESC LIMIT ?",
[$employee->id, $historyCount]
);
foreach ($history as $h) {
if (password_verify($newPassword, $h['password_hash'])) {
$errors[] = 'لا يمكن استخدام كلمة مرور تم استخدامها سابقاً';
break;
}
}
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
return $this->redirect('/change-password');
}
$cost = (int) config('auth.password_bcrypt_cost', 12);
$newHash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => $cost]);
$db = App::getInstance()->db();
$db->update('employees', [
'password_hash' => $newHash,
'force_password_change' => 0,
'password_changed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$employee->id]);
$db->insert('password_history', [
'employee_id' => (int) $employee->id,
'password_hash' => $newHash,
'created_at' => date('Y-m-d H:i:s'),
]);
$eventData = ['employee_id' => (int) $employee->id];
EventBus::dispatch('auth.password_changed', $eventData);
Logger::info("Password changed", ['employee_id' => $employee->id]);
return $this->redirect(config('auth.default_redirect', '/users'))
->withSuccess('تم تغيير كلمة المرور بنجاح');
}
private function enforceMaxSessions(int $employeeId, string $currentSessionId): void
{
$max = (int) config('auth.max_concurrent_sessions', 3);
$db = App::getInstance()->db();
$activeSessions = $db->select(
"SELECT id, session_id FROM active_sessions WHERE employee_id = ? AND is_active = 1 ORDER BY logged_in_at ASC",
[$employeeId]
);
if (count($activeSessions) > $max) {
$toDeactivate = array_slice($activeSessions, 0, count($activeSessions) - $max);
foreach ($toDeactivate as $s) {
if ($s['session_id'] !== $currentSessionId) {
$db->update('active_sessions', ['is_active' => 0], '`id` = ?', [$s['id']]);
}
}
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Auth\Models;
use App\Core\App;
class ActiveSession
{
public static function createSession(int $employeeId, string $sessionId, string $ip, string $userAgent): void
{
$db = App::getInstance()->db();
$db->insert('active_sessions', [
'employee_id' => $employeeId,
'session_id' => $sessionId,
'ip_address' => $ip,
'user_agent' => mb_substr($userAgent, 0, 500),
'logged_in_at' => date('Y-m-d H:i:s'),
'last_activity_at' => date('Y-m-d H:i:s'),
'is_active' => 1,
]);
}
public static function deactivateSession(string $sessionId): void
{
$db = App::getInstance()->db();
$db->update('active_sessions', ['is_active' => 0], '`session_id` = ?', [$sessionId]);
}
public static function getActiveForEmployee(int $employeeId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM active_sessions WHERE employee_id = ? AND is_active = 1 ORDER BY last_activity_at DESC",
[$employeeId]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Auth\Models;
use App\Core\App;
class LoginAttempt
{
public static function record(string $username, string $ip, string $userAgent, bool $success, ?string $failureReason): void
{
$db = App::getInstance()->db();
$db->insert('login_attempts', [
'username' => $username,
'ip_address' => $ip,
'user_agent' => mb_substr($userAgent, 0, 500),
'success' => $success ? 1 : 0,
'failure_reason' => $failureReason,
'attempted_at' => date('Y-m-d H:i:s'),
]);
}
public static function recentFailedCount(string $username, int $minutesBack = 30): int
{
$db = App::getInstance()->db();
$since = date('Y-m-d H:i:s', time() - ($minutesBack * 60));
$result = $db->selectOne(
"SELECT COUNT(*) as cnt FROM login_attempts WHERE username = ? AND success = 0 AND attempted_at >= ?",
[$username, $since]
);
return (int) ($result['cnt'] ?? 0);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/', 'Auth\Controllers\AuthController@home', ['auth'], null],
['GET', '/login', 'Auth\Controllers\AuthController@login', [], null],
['POST', '/login', 'Auth\Controllers\AuthController@authenticate', ['csrf'], null],
['GET', '/logout', 'Auth\Controllers\AuthController@logout', [], null],
['GET', '/change-password', 'Auth\Controllers\AuthController@changePassword', ['auth', 'csrf'], null],
['POST', '/change-password', 'Auth\Controllers\AuthController@updatePassword', ['auth', 'csrf'], null],
];
\ No newline at end of file
<?php $__template->layout('Layout.auth'); ?>
<?php $__template->section('title'); ?>تغيير كلمة المرور<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<h2 style="text-align:center;color:#1A1A2E;margin-bottom:20px;font-size:18px;">تغيير كلمة المرور</h2>
<?php
$employee = \App\Core\App::getInstance()->currentEmployee();
$forceChange = $employee && $employee->force_password_change;
?>
<?php if ($forceChange): ?>
<p style="color:#D97706;text-align:center;margin-bottom:20px;font-size:14px;">يجب تغيير كلمة المرور قبل المتابعة</p>
<?php endif; ?>
<form method="POST" action="/change-password">
<?= csrf_field() ?>
<?php if (!$forceChange): ?>
<div class="form-group" style="margin-bottom:20px;">
<label for="current_password" class="form-label" style="display:block;margin-bottom:6px;font-weight:600;">كلمة المرور الحالية</label>
<input type="password" id="current_password" name="current_password" class="form-input" style="width:100%;padding:10px 14px;border:1px solid #E5E7EB;border-radius:6px;" required>
</div>
<?php endif; ?>
<div class="form-group" style="margin-bottom:20px;">
<label for="new_password" class="form-label" style="display:block;margin-bottom:6px;font-weight:600;">كلمة المرور الجديدة</label>
<input type="password" id="new_password" name="new_password" class="form-input" style="width:100%;padding:10px 14px;border:1px solid #E5E7EB;border-radius:6px;" required minlength="8">
</div>
<div class="form-group" style="margin-bottom:20px;">
<label for="new_password_confirmation" class="form-label" style="display:block;margin-bottom:6px;font-weight:600;">تأكيد كلمة المرور</label>
<input type="password" id="new_password_confirmation" name="new_password_confirmation" class="form-input" style="width:100%;padding:10px 14px;border:1px solid #E5E7EB;border-radius:6px;" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;padding:12px;background:#0D7377;color:#fff;border:none;border-radius:6px;font-size:16px;font-weight:600;cursor:pointer;">
تغيير كلمة المرور
</button>
<?php if (!$forceChange): ?>
<a href="/" style="display:block;text-align:center;margin-top:15px;color:#6B7280;font-size:14px;">← العودة</a>
<?php endif; ?>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.auth'); ?>
<?php $__template->section('title'); ?>تسجيل الدخول<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/login" autocomplete="off">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:20px;">
<label for="username" class="form-label" style="display:block;margin-bottom:6px;font-weight:600;color:#1A1A2E;">اسم المستخدم</label>
<input type="text" id="username" name="username" value="<?= e(old('username')) ?>" class="form-input" style="width:100%;padding:10px 14px;border:1px solid #E5E7EB;border-radius:6px;font-size:14px;" required autofocus>
</div>
<div class="form-group" style="margin-bottom:20px;">
<label for="password" class="form-label" style="display:block;margin-bottom:6px;font-weight:600;color:#1A1A2E;">كلمة المرور</label>
<input type="password" id="password" name="password" class="form-input" style="width:100%;padding:10px 14px;border:1px solid #E5E7EB;border-radius:6px;font-size:14px;" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;padding:12px;background:#0D7377;color:#fff;border:none;border-radius:6px;font-size:16px;font-weight:600;cursor:pointer;">
تسجيل الدخول
</button>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('auth', [
'auth.force_logout' => ['ar' => 'فرض تسجيل الخروج', 'en' => 'Force Logout'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Roles\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Registries\PermissionRegistry;
use App\Modules\Roles\Models\Role;
class RoleController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$roles = $db->select("
SELECT r.*, (SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) as perm_count,
(SELECT COUNT(*) FROM employee_roles er WHERE er.role_id = r.id AND er.is_active = 1) as emp_count
FROM roles r WHERE r.is_active = 1 ORDER BY r.name_ar
");
return $this->view('Roles.Views.index', ['roles' => $roles]);
}
public function create(Request $request): Response
{
$allPermissions = PermissionRegistry::getAllGrouped();
return $this->view('Roles.Views.create', [
'allPermissions' => $allPermissions,
'role' => null,
'rolePerms' => [],
]);
}
public function store(Request $request): Response
{
$data = $this->validate($request->all(), [
'role_code' => 'required|string|min:2|max:50',
'name_ar' => 'required|string|min:2|max:200',
'name_en' => 'nullable|string|max:200',
'description_ar' => 'nullable|string|max:500',
]);
$existing = App::getInstance()->db()->selectOne("SELECT id FROM roles WHERE role_code = ?", [$data['role_code']]);
if ($existing) {
return $this->redirect('/roles/create')->withError('كود الدور مستخدم بالفعل');
}
$role = Role::create([
'role_code' => $data['role_code'],
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'] ?? null,
'description_ar' => $data['description_ar'] ?? null,
'is_system' => 0,
'is_active' => 1,
]);
$permissions = $request->post('permissions', []);
if (is_array($permissions)) {
$role->syncPermissions($permissions);
}
return $this->redirect('/roles')->withSuccess('تم إنشاء الدور بنجاح');
}
public function edit(Request $request, string $id): Response
{
$role = Role::find((int) $id);
if (!$role) {
return $this->redirect('/roles')->withError('الدور غير موجود');
}
$allPermissions = PermissionRegistry::getAllGrouped();
$rolePerms = $role->getPermissionKeys();
return $this->view('Roles.Views.edit', [
'role' => $role,
'allPermissions' => $allPermissions,
'rolePerms' => $rolePerms,
]);
}
public function update(Request $request, string $id): Response
{
$role = Role::find((int) $id);
if (!$role) {
return $this->redirect('/roles')->withError('الدور غير موجود');
}
$data = $this->validate($request->all(), [
'name_ar' => 'required|string|min:2|max:200',
'name_en' => 'nullable|string|max:200',
'description_ar' => 'nullable|string|max:500',
]);
$role->update([
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'] ?? null,
'description_ar' => $data['description_ar'] ?? null,
]);
$permissions = $request->post('permissions', []);
if (is_array($permissions)) {
$role->syncPermissions($permissions);
}
return $this->redirect('/roles')->withSuccess('تم تحديث الدور بنجاح');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Roles\Models;
use App\Core\Model;
use App\Core\App;
class Role extends Model
{
protected static string $table = 'roles';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'role_code', 'name_ar', 'name_en', 'description_ar', 'description_en',
'is_system', 'is_active', 'parent_role_id',
];
public function getPermissionKeys(): array
{
$db = App::getInstance()->db();
$rows = $db->select("SELECT permission_key FROM role_permissions WHERE role_id = ?", [$this->id]);
return array_column($rows, 'permission_key');
}
public function syncPermissions(array $permissionKeys): void
{
$db = App::getInstance()->db();
$db->delete('role_permissions', '`role_id` = ?', [$this->id]);
$employee = App::getInstance()->currentEmployee();
$grantedBy = $employee ? ($employee->id ?? null) : null;
foreach ($permissionKeys as $key) {
$key = trim($key);
if ($key === '') continue;
$db->insert('role_permissions', [
'role_id' => (int) $this->id,
'permission_key' => $key,
'granted_at' => date('Y-m-d H:i:s'),
'granted_by' => $grantedBy,
]);
}
}
public static function allActive(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT * FROM roles WHERE is_active = 1 ORDER BY name_ar");
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Roles\Models;
use App\Core\Model;
class RolePermission extends Model
{
protected static string $table = 'role_permissions';
protected static bool $softDelete = false;
protected static bool $timestamps = false;
protected static array $fillable = ['role_id', 'permission_key', 'granted_by'];
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/roles', 'Roles\Controllers\RoleController@index', ['auth'], 'user.assign_role'],
['GET', '/roles/create', 'Roles\Controllers\RoleController@create', ['auth'], 'user.assign_role'],
['POST', '/roles', 'Roles\Controllers\RoleController@store', ['auth', 'csrf'], 'user.assign_role'],
['GET', '/roles/{id}/edit', 'Roles\Controllers\RoleController@edit', ['auth'], 'user.assign_role'],
['POST', '/roles/{id}', 'Roles\Controllers\RoleController@update', ['auth', 'csrf'], 'user.assign_role'],
];
\ No newline at end of file
<?php
$r = $role ?? null;
$rp = $rolePerms ?? [];
$ap = $allPermissions ?? [];
?>
<div class="card" style="margin-bottom:20px;">
<div class="card-body" style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<?php if (!$r || !$r->is_system): ?>
<div class="form-group">
<label class="form-label">كود الدور <span style="color:#DC2626;">*</span></label>
<input type="text" name="role_code" value="<?= e($r ? $r->role_code : old('role_code')) ?>" class="form-input" required <?= $r ? 'readonly' : '' ?>>
</div>
<?php endif; ?>
<div class="form-group">
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" value="<?= e($r ? $r->name_ar : old('name_ar')) ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="name_en" value="<?= e($r ? $r->name_en : old('name_en')) ?>" class="form-input">
</div>
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">الوصف</label>
<textarea name="description_ar" class="form-textarea" rows="2"><?= e($r ? $r->description_ar : old('description_ar')) ?></textarea>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:20px;">
<h3 style="margin-bottom:15px;color:#1A1A2E;">الصلاحيات</h3>
<?php if (empty($ap)): ?>
<p style="color:#6B7280;">لا توجد صلاحيات مسجلة بعد. ستظهر تلقائياً عند إضافة الوحدات.</p>
<?php else: ?>
<?php foreach ($ap as $group => $perms): ?>
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:8px;padding:15px;">
<h4 style="margin-bottom:10px;color:#0D7377;font-size:16px;"><?= e($group) ?></h4>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:8px;">
<?php foreach ($perms as $key => $labels): ?>
<label style="display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;">
<input type="checkbox" name="permissions[]" value="<?= e($key) ?>"
<?= in_array($key, $rp) ? 'checked' : '' ?>>
<span><?= e($labels['ar'] ?? $key) ?></span>
<small style="color:#9CA3AF;">(<?= e($key) ?>)</small>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إنشاء دور جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/roles">
<?= csrf_field() ?>
<?php require __DIR__ . '/_form.php'; ?>
<button type="submit" class="btn btn-primary" style="margin-top:20px;">حفظ الدور</button>
<a href="/roles" class="btn btn-outline" style="margin-top:20px;">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل الدور: <?= e($role->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/roles/<?= (int) $role->id ?>">
<?= csrf_field() ?>
<?php require __DIR__ . '/_form.php'; ?>
<button type="submit" class="btn btn-primary" style="margin-top:20px;">تحديث الدور</button>
<a href="/roles" class="btn btn-outline" style="margin-top:20px;">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة الأدوار<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/roles/create" class="btn btn-primary">+ دور جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>كود الدور</th>
<th>الاسم (عربي)</th>
<th>الاسم (إنجليزي)</th>
<th>نظامي</th>
<th>الصلاحيات</th>
<th>الموظفون</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($roles as $role): ?>
<tr>
<td><code><?= e($role['role_code']) ?></code></td>
<td><?= e($role['name_ar']) ?></td>
<td><?= e($role['name_en'] ?? '—') ?></td>
<td><?= $role['is_system'] ? '<span style="color:#059669;">نعم</span>' : 'لا' ?></td>
<td><?= (int) $role['perm_count'] ?></td>
<td><?= (int) $role['emp_count'] ?></td>
<td>
<a href="/roles/<?= (int) $role['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($roles)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد أدوار</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('users_roles', [
'label_ar' => 'المستخدمون والصلاحيات',
'label_en' => 'Users & Roles',
'icon' => '👥',
'route' => '/users',
'permission' => 'user.view',
'order' => 100,
'children' => [
['label_ar' => 'الموظفون', 'label_en' => 'Employees', 'route' => '/users', 'permission' => 'user.view', 'order' => 1],
['label_ar' => 'إضافة موظف', 'label_en' => 'Add Employee', 'route' => '/users/create', 'permission' => 'user.create', 'order' => 2],
['label_ar' => 'الأدوار', 'label_en' => 'Roles', 'route' => '/roles', 'permission' => 'user.assign_role', 'order' => 3],
],
]);
PermissionRegistry::register('users', [
'user.view' => ['ar' => 'عرض الموظفين', 'en' => 'View Employees'],
'user.create' => ['ar' => 'إنشاء موظف', 'en' => 'Create Employee'],
'user.edit' => ['ar' => 'تعديل موظف', 'en' => 'Edit Employee'],
'user.deactivate' => ['ar' => 'تعطيل موظف', 'en' => 'Deactivate Employee'],
'user.assign_role' => ['ar' => 'تعيين الأدوار', 'en' => 'Assign Roles'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Users\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Pagination;
use App\Modules\Users\Models\Employee;
use App\Modules\Roles\Models\Role;
class UserController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$perPage = (int) config('app.per_page', 25);
$page = max(1, (int) $request->get('page', 1));
$search = trim((string) $request->get('q', ''));
$statusFilter = $request->get('status', '');
$where = "e.is_archived = 0";
$params = [];
if ($search !== '') {
$where .= " AND (e.full_name_ar LIKE ? OR e.username LIKE ? OR e.email LIKE ?)";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
}
if ($statusFilter === 'active') {
$where .= " AND e.is_active = 1";
} elseif ($statusFilter === 'inactive') {
$where .= " AND e.is_active = 0";
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM employees e WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$pagination = Pagination::paginate($total, $perPage, $page);
$offset = ($page - 1) * $perPage;
$employees = $db->select("
SELECT e.*, b.name_ar as branch_name
FROM employees e
LEFT JOIN branches b ON b.id = e.branch_id
WHERE {$where}
ORDER BY e.created_at DESC
LIMIT {$perPage} OFFSET {$offset}
", $params);
return $this->view('Users.Views.index', [
'employees' => $employees,
'pagination' => $pagination,
'search' => $search,
'status' => $statusFilter,
]);
}
public function create(Request $request): Response
{
$roles = Role::allActive();
$branches = App::getInstance()->db()->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Users.Views.create', [
'roles' => $roles,
'branches' => $branches,
]);
}
public function store(Request $request): Response
{
$data = $this->validate($request->all(), [
'username' => 'required|string|min:3|max:50',
'password' => 'required|string|min:8',
'full_name_ar' => 'required|string|min:3|max:200',
'full_name_en' => 'nullable|string|max:200',
'email' => 'nullable|email',
'phone' => 'nullable|string|max:20',
'branch_id' => 'nullable|integer',
]);
$existing = App::getInstance()->db()->selectOne("SELECT id FROM employees WHERE username = ?", [$data['username']]);
if ($existing) {
return $this->redirect('/users/create')
->withError('اسم المستخدم مستخدم بالفعل')
->withInput($request->all());
}
$cost = (int) config('auth.password_bcrypt_cost', 12);
$hash = password_hash($data['password'], PASSWORD_BCRYPT, ['cost' => $cost]);
$employee = Employee::create([
'username' => $data['username'],
'password_hash' => $hash,
'full_name_ar' => $data['full_name_ar'],
'full_name_en' => $data['full_name_en'] ?? null,
'email' => $data['email'] ?? null,
'phone' => $data['phone'] ?? null,
'branch_id' => $data['branch_id'] ? (int) $data['branch_id'] : null,
'is_active' => 1,
'force_password_change' => 1,
]);
$roleIds = $request->post('roles', []);
if (is_array($roleIds)) {
$db = App::getInstance()->db();
$currentEmp = App::getInstance()->currentEmployee();
foreach ($roleIds as $roleId) {
$db->insert('employee_roles', [
'employee_id' => (int) $employee->id,
'role_id' => (int) $roleId,
'assigned_at' => date('Y-m-d H:i:s'),
'assigned_by' => $currentEmp ? (int) $currentEmp->id : null,
'is_active' => 1,
]);
}
}
$db = App::getInstance()->db();
$db->insert('password_history', [
'employee_id' => (int) $employee->id,
'password_hash' => $hash,
'created_at' => date('Y-m-d H:i:s'),
]);
return $this->redirect('/users')->withSuccess('تم إنشاء الموظف بنجاح');
}
public function show(Request $request, string $id): Response
{
$employee = Employee::find((int) $id);
if (!$employee) {
return $this->redirect('/users')->withError('الموظف غير موجود');
}
$roles = $employee->getRolesWithNames();
$sessions = \App\Modules\Auth\Models\ActiveSession::getActiveForEmployee((int) $employee->id);
return $this->view('Users.Views.show', [
'employee' => $employee,
'roles' => $roles,
'sessions' => $sessions,
]);
}
public function edit(Request $request, string $id): Response
{
$employee = Employee::find((int) $id);
if (!$employee) {
return $this->redirect('/users')->withError('الموظف غير موجود');
}
$allRoles = Role::allActive();
$currentRoleIds = array_column($employee->getRolesWithNames(), 'id');
$branches = App::getInstance()->db()->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Users.Views.edit', [
'employee' => $employee,
'allRoles' => $allRoles,
'currentRoleIds' => $currentRoleIds,
'branches' => $branches,
]);
}
public function update(Request $request, string $id): Response
{
$employee = Employee::find((int) $id);
if (!$employee) {
return $this->redirect('/users')->withError('الموظف غير موجود');
}
$data = $this->validate($request->all(), [
'full_name_ar' => 'required|string|min:3|max:200',
'full_name_en' => 'nullable|string|max:200',
'email' => 'nullable|email',
'phone' => 'nullable|string|max:20',
'branch_id' => 'nullable|integer',
'is_active' => 'nullable|in:0,1',
]);
$updateData = [
'full_name_ar' => $data['full_name_ar'],
'full_name_en' => $data['full_name_en'] ?? null,
'email' => $data['email'] ?? null,
'phone' => $data['phone'] ?? null,
'branch_id' => isset($data['branch_id']) && $data['branch_id'] !== '' ? (int) $data['branch_id'] : null,
'is_active' => isset($data['is_active']) ? (int) $data['is_active'] : (int) $employee->is_active,
];
$newPassword = $request->post('new_password', '');
if ($newPassword !== '') {
if (mb_strlen($newPassword) < 8) {
return $this->redirect("/users/{$id}/edit")->withError('كلمة المرور يجب أن تكون 8 أحرف على الأقل');
}
$cost = (int) config('auth.password_bcrypt_cost', 12);
$updateData['password_hash'] = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => $cost]);
$updateData['force_password_change'] = 1;
}
$employee->update($updateData);
$db = App::getInstance()->db();
$db->delete('employee_roles', '`employee_id` = ?', [$employee->id]);
$roleIds = $request->post('roles', []);
if (is_array($roleIds)) {
$currentEmp = App::getInstance()->currentEmployee();
foreach ($roleIds as $roleId) {
$db->insert('employee_roles', [
'employee_id' => (int) $employee->id,
'role_id' => (int) $roleId,
'assigned_at' => date('Y-m-d H:i:s'),
'assigned_by' => $currentEmp ? (int) $currentEmp->id : null,
'is_active' => 1,
]);
}
}
return $this->redirect('/users')->withSuccess('تم تحديث بيانات الموظف بنجاح');
}
public function activity(Request $request, string $id): Response
{
$employee = Employee::find((int) $id);
if (!$employee) {
return $this->redirect('/users')->withError('الموظف غير موجود');
}
$db = App::getInstance()->db();
$attempts = $db->select(
"SELECT * FROM login_attempts WHERE username = ? ORDER BY attempted_at DESC LIMIT 50",
[$employee->username]
);
return $this->view('Users.Views.activity', [
'employee' => $employee,
'attempts' => $attempts,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Users\Models;
use App\Core\Model;
use App\Core\App;
class Employee extends Model
{
protected static string $table = 'employees';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'username', 'password_hash', 'full_name_ar', 'full_name_en',
'email', 'phone', 'branch_id', 'is_active', 'force_password_change',
'password_changed_at', 'last_login_at', 'last_login_ip',
'failed_login_count', 'locked_until',
];
private ?array $cachedPermissions = null;
private ?array $cachedRoles = null;
public static function findByUsername(string $username): ?static
{
$row = static::query()->withArchived()->whereRaw("`employees`.`username` = ?", [$username])->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public function getAllPermissions(): array
{
if ($this->cachedPermissions !== null) {
return $this->cachedPermissions;
}
$db = App::getInstance()->db();
$sql = "
SELECT DISTINCT rp.permission_key
FROM employee_roles er
JOIN role_permissions rp ON rp.role_id = er.role_id
JOIN roles r ON r.id = er.role_id AND r.is_active = 1
WHERE er.employee_id = ?
AND er.is_active = 1
AND (er.expires_at IS NULL OR er.expires_at > NOW())
";
$rows = $db->select($sql, [$this->id]);
$permissions = array_column($rows, 'permission_key');
$roleIds = $this->getRoleIds();
$parentPerms = $this->getParentRolePermissions($roleIds);
$permissions = array_unique(array_merge($permissions, $parentPerms));
$this->cachedPermissions = $permissions;
return $this->cachedPermissions;
}
private function getRoleIds(): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT role_id FROM employee_roles WHERE employee_id = ? AND is_active = 1 AND (expires_at IS NULL OR expires_at > NOW())",
[$this->id]
);
return array_column($rows, 'role_id');
}
private function getParentRolePermissions(array $roleIds): array
{
if (empty($roleIds)) {
return [];
}
$db = App::getInstance()->db();
$permissions = [];
$visited = [];
foreach ($roleIds as $roleId) {
$this->collectParentPermissions((int) $roleId, $permissions, $visited, $db);
}
return $permissions;
}
private function collectParentPermissions(int $roleId, array &$permissions, array &$visited, $db): void
{
if (in_array($roleId, $visited)) {
return;
}
$visited[] = $roleId;
$role = $db->selectOne("SELECT parent_role_id FROM roles WHERE id = ? AND is_active = 1", [$roleId]);
if (!$role || !$role['parent_role_id']) {
return;
}
$parentId = (int) $role['parent_role_id'];
$rows = $db->select("SELECT permission_key FROM role_permissions WHERE role_id = ?", [$parentId]);
foreach ($rows as $row) {
$permissions[] = $row['permission_key'];
}
$this->collectParentPermissions($parentId, $permissions, $visited, $db);
}
public function hasPermission(string $key): bool
{
$permissions = $this->getAllPermissions();
if (in_array('*', $permissions, true)) {
return true;
}
return in_array($key, $permissions, true);
}
public function hasAnyPermission(array $keys): bool
{
foreach ($keys as $key) {
if ($this->hasPermission($key)) {
return true;
}
}
return false;
}
public function hasRole(string $code): bool
{
$roles = $this->getRoleCodes();
return in_array($code, $roles, true);
}
public function getRoleCodes(): array
{
if ($this->cachedRoles !== null) {
return $this->cachedRoles;
}
$db = App::getInstance()->db();
$sql = "
SELECT r.role_code FROM employee_roles er
JOIN roles r ON r.id = er.role_id AND r.is_active = 1
WHERE er.employee_id = ? AND er.is_active = 1
AND (er.expires_at IS NULL OR er.expires_at > NOW())
";
$rows = $db->select($sql, [$this->id]);
$this->cachedRoles = array_column($rows, 'role_code');
return $this->cachedRoles;
}
public function getBranchId(): ?int
{
return $this->branch_id ? (int) $this->branch_id : null;
}
public function isLocked(): bool
{
if (!$this->locked_until) {
return false;
}
return strtotime($this->locked_until) > time();
}
public function incrementFailedLogins(): void
{
$db = App::getInstance()->db();
$count = (int) $this->failed_login_count + 1;
$data = ['failed_login_count' => $count, 'updated_at' => date('Y-m-d H:i:s')];
$maxAttempts = (int) config('auth.max_failed_attempts', 5);
if ($count >= $maxAttempts) {
$lockoutMinutes = (int) config('auth.lockout_minutes', 15);
$data['locked_until'] = date('Y-m-d H:i:s', time() + ($lockoutMinutes * 60));
}
$db->update('employees', $data, '`id` = ?', [$this->id]);
}
public function resetFailedLogins(): void
{
$db = App::getInstance()->db();
$db->update('employees', [
'failed_login_count' => 0,
'locked_until' => null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$this->id]);
}
public function recordLogin(string $ip): void
{
$db = App::getInstance()->db();
$db->update('employees', [
'last_login_at' => date('Y-m-d H:i:s'),
'last_login_ip' => $ip,
'failed_login_count' => 0,
'locked_until' => null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$this->id]);
}
public function getRolesWithNames(): array
{
$db = App::getInstance()->db();
return $db->select("
SELECT r.id, r.role_code, r.name_ar, r.name_en, er.assigned_at, er.expires_at
FROM employee_roles er
JOIN roles r ON r.id = er.role_id
WHERE er.employee_id = ? AND er.is_active = 1
ORDER BY r.name_ar
", [$this->id]);
}
public function getBranchName(): string
{
if (!$this->branch_id) {
return 'جميع الفروع';
}
$db = App::getInstance()->db();
$branch = $db->selectOne("SELECT name_ar FROM branches WHERE id = ?", [$this->branch_id]);
return $branch['name_ar'] ?? '—';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/users', 'Users\Controllers\UserController@index', ['auth'], 'user.view'],
['GET', '/users/create', 'Users\Controllers\UserController@create', ['auth'], 'user.create'],
['POST', '/users', 'Users\Controllers\UserController@store', ['auth', 'csrf'], 'user.create'],
['GET', '/users/{id:\d+}', 'Users\Controllers\UserController@show', ['auth'], 'user.view'],
['GET', '/users/{id:\d+}/edit', 'Users\Controllers\UserController@edit', ['auth'], 'user.edit'],
['POST', '/users/{id:\d+}', 'Users\Controllers\UserController@update', ['auth', 'csrf'], 'user.edit'],
['GET', '/users/{id:\d+}/activity', 'Users\Controllers\UserController@activity', ['auth'], 'user.view'],
];
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل نشاط: <?= e($employee->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/users/<?= (int) $employee->id ?>" class="btn btn-outline">← عودة للملف</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>IP</th>
<th>النتيجة</th>
<th>السبب</th>
</tr>
</thead>
<tbody>
<?php foreach ($attempts as $a): ?>
<tr>
<td style="font-size:13px;"><?= e($a['attempted_at']) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($a['ip_address']) ?></td>
<td>
<?php if ($a['success']): ?>
<span style="color:#059669;font-weight:600;">نجح</span>
<?php else: ?>
<span style="color:#DC2626;font-weight:600;">فشل</span>
<?php endif; ?>
</td>
<td style="color:#6B7280;"><?= e($a['failure_reason'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($attempts)): ?>
<tr><td colspan="4" style="text-align:center;padding:40px;color:#6B7280;">لا يوجد سجل</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إنشاء موظف جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/users">
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">اسم المستخدم <span style="color:#DC2626;">*</span></label>
<input type="text" name="username" value="<?= e(old('username')) ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">كلمة المرور <span style="color:#DC2626;">*</span></label>
<input type="password" name="password" class="form-input" required minlength="8">
<small style="color:#6B7280;">سيُطلب من الموظف تغييرها عند أول تسجيل دخول</small>
</div>
<div class="form-group">
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="full_name_ar" value="<?= e(old('full_name_ar')) ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="full_name_en" value="<?= e(old('full_name_en')) ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" value="<?= e(old('email')) ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">رقم الهاتف</label>
<input type="text" name="phone" value="<?= e(old('phone')) ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">جميع الفروع</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>"><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="margin-bottom:15px;">الأدوار</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:10px;">
<?php foreach ($roles as $role): ?>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" name="roles[]" value="<?= (int) $role['id'] ?>">
<span><?= e($role['name_ar']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<button type="submit" class="btn btn-primary">إنشاء الموظف</button>
<a href="/users" class="btn btn-outline">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل الموظف: <?= e($employee->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/users/<?= (int) $employee->id ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">اسم المستخدم</label>
<input type="text" value="<?= e($employee->username) ?>" class="form-input" disabled style="background:#f3f4f6;">
</div>
<div class="form-group">
<label class="form-label">كلمة مرور جديدة (اتركه فارغاً للإبقاء)</label>
<input type="password" name="new_password" class="form-input" minlength="8">
</div>
<div class="form-group">
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="full_name_ar" value="<?= e($employee->full_name_ar) ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="full_name_en" value="<?= e($employee->full_name_en ?? '') ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" value="<?= e($employee->email ?? '') ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">رقم الهاتف</label>
<input type="text" name="phone" value="<?= e($employee->phone ?? '') ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">جميع الفروع</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= (int) $employee->branch_id === (int) $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الحالة</label>
<select name="is_active" class="form-select">
<option value="1" <?= $employee->is_active ? 'selected' : '' ?>>نشط</option>
<option value="0" <?= !$employee->is_active ? 'selected' : '' ?>>معطل</option>
</select>
</div>
</div>
</div>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="margin-bottom:15px;">الأدوار</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:10px;">
<?php foreach ($allRoles as $role): ?>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" name="roles[]" value="<?= (int) $role['id'] ?>"
<?= in_array($role['id'], $currentRoleIds) ? 'checked' : '' ?>>
<span><?= e($role['name_ar']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<button type="submit" class="btn btn-primary">حفظ التعديلات</button>
<a href="/users" class="btn btn-outline">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة الموظفين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/users/create" class="btn btn-primary">+ موظف جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/users" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<input type="text" name="q" value="<?= e($search) ?>" placeholder="بحث بالاسم أو اسم المستخدم..." class="form-input" style="flex:1;min-width:200px;">
<select name="status" class="form-select" style="width:auto;">
<option value="">الكل</option>
<option value="active" <?= $status === 'active' ? 'selected' : '' ?>>نشط</option>
<option value="inactive" <?= $status === 'inactive' ? 'selected' : '' ?>>معطل</option>
</select>
<button type="submit" class="btn btn-outline">بحث</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اسم المستخدم</th>
<th>الاسم بالعربي</th>
<th>البريد</th>
<th>الفرع</th>
<th>الحالة</th>
<th>آخر دخول</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($employees as $emp): ?>
<tr>
<td><strong><?= e($emp['username']) ?></strong></td>
<td><?= e($emp['full_name_ar']) ?></td>
<td><?= e($emp['email'] ?? '—') ?></td>
<td><?= e($emp['branch_name'] ?? 'جميع الفروع') ?></td>
<td>
<?php if ($emp['is_active']): ?>
<span style="color:#059669;font-weight:600;">● نشط</span>
<?php else: ?>
<span style="color:#DC2626;font-weight:600;">● معطل</span>
<?php endif; ?>
</td>
<td style="font-size:13px;color:#6B7280;"><?= $emp['last_login_at'] ? arabic_date($emp['last_login_at']) : 'لم يسجل دخول' ?></td>
<td>
<div style="display:flex;gap:5px;">
<a href="/users/<?= (int) $emp['id'] ?>" class="btn btn-sm btn-outline">عرض</a>
<a href="/users/<?= (int) $emp['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($employees)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا يوجد موظفون</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($pagination['last_page'] > 1): ?>
<div style="padding:15px;">
<nav class="pagination-wrapper">
<ul class="pagination" style="display:flex;gap:5px;list-style:none;padding:0;justify-content:center;">
<?php if ($pagination['has_prev']): ?>
<li><a href="?page=<?= $pagination['prev_page'] ?>&q=<?= urlencode($search) ?>&status=<?= urlencode($status) ?>" class="btn btn-sm btn-outline">السابق</a></li>
<?php endif; ?>
<?php foreach ($pagination['pages'] as $p): ?>
<?php if ($p === '...'): ?>
<li style="padding:5px;">...</li>
<?php else: ?>
<li><a href="?page=<?= $p ?>&q=<?= urlencode($search) ?>&status=<?= urlencode($status) ?>" class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next']): ?>
<li><a href="?page=<?= $pagination['next_page'] ?>&q=<?= urlencode($search) ?>&status=<?= urlencode($status) ?>" class="btn btn-sm btn-outline">التالي</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الموظف: <?= e($employee->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/users/<?= (int) $employee->id ?>/edit" class="btn btn-outline">تعديل</a>
<a href="/users/<?= (int) $employee->id ?>/activity" class="btn btn-outline">سجل النشاط</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card" style="padding:20px;">
<h3 style="margin-bottom:15px;color:#0D7377;">البيانات الشخصية</h3>
<table style="width:100%;font-size:14px;">
<tr><td style="padding:8px 0;color:#6B7280;width:40%;">اسم المستخدم</td><td style="padding:8px 0;font-weight:600;"><?= e($employee->username) ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الاسم بالعربي</td><td style="padding:8px 0;"><?= e($employee->full_name_ar) ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الاسم بالإنجليزي</td><td style="padding:8px 0;"><?= e($employee->full_name_en ?? '—') ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">البريد الإلكتروني</td><td style="padding:8px 0;"><?= e($employee->email ?? '—') ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الهاتف</td><td style="padding:8px 0;"><?= e($employee->phone ?? '—') ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الفرع</td><td style="padding:8px 0;"><?= e($employee->getBranchName()) ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الحالة</td><td style="padding:8px 0;"><?= $employee->is_active ? '<span style="color:#059669;">● نشط</span>' : '<span style="color:#DC2626;">● معطل</span>' ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">آخر تسجيل دخول</td><td style="padding:8px 0;"><?= $employee->last_login_at ? arabic_date($employee->last_login_at) : 'لم يسجل دخول' ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">آخر IP</td><td style="padding:8px 0;direction:ltr;text-align:right;"><?= e($employee->last_login_ip ?? '—') ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">تاريخ الإنشاء</td><td style="padding:8px 0;"><?= arabic_date($employee->created_at) ?></td></tr>
</table>
</div>
<div>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="margin-bottom:15px;color:#0D7377;">الأدوار</h3>
<?php if (empty($roles)): ?>
<p style="color:#6B7280;">لا توجد أدوار مُعيّنة</p>
<?php else: ?>
<?php foreach ($roles as $role): ?>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #F3F4F6;">
<span style="font-weight:600;"><?= e($role['name_ar']) ?></span>
<small style="color:#6B7280;"><?= e($role['role_code']) ?></small>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="card" style="padding:20px;">
<h3 style="margin-bottom:15px;color:#0D7377;">الجلسات النشطة</h3>
<?php if (empty($sessions)): ?>
<p style="color:#6B7280;">لا توجد جلسات نشطة</p>
<?php else: ?>
<?php foreach ($sessions as $s): ?>
<div style="padding:8px 0;border-bottom:1px solid #F3F4F6;font-size:13px;">
<div>IP: <span style="direction:ltr;display:inline-block;"><?= e($s['ip_address']) ?></span></div>
<div style="color:#6B7280;">آخر نشاط: <?= e($s['last_activity_at']) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
// Menu and permissions already registered in Roles/bootstrap.php (shared group)
\ No newline at end of file
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?= \App\Core\CSRF::token() ?>">
<title><?= $__template->yield('title', 'تسجيل الدخول — نادي النادي شيراتون') ?></title>
<link rel="stylesheet" href="<?= url('assets/css/main.css') ?>">
<style>
body.auth-body { background: linear-gradient(135deg, #0D7377 0%, #095355 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; padding: 20px; }
.auth-card { background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); padding: 40px; width: 100%; max-width: 420px; }
.auth-logo { text-align: center; margin-bottom: 30px; }
.auth-logo h1 { color: #0D7377; font-size: 24px; margin: 0; }
.auth-logo p { color: #6B7280; font-size: 14px; margin-top: 5px; }
</style>
</head>
<body class="auth-body">
<div class="auth-card">
<div class="auth-logo">
<h1>THE CLUB</h1>
<p>نادي النادي شيراتون</p>
</div>
<?php
$alerts = $app->session()->getAlerts();
if (!empty($alerts)):
foreach ($alerts as $alert): ?>
<div class="alert alert-<?= e($alert['type'] ?? 'info') ?>" style="margin-bottom:15px;padding:10px 15px;border-radius:6px;font-size:14px;<?= ($alert['type'] ?? '') === 'error' ? 'background:#FEF2F2;color:#DC2626;border:1px solid #FECACA;' : 'background:#F0FDF4;color:#059669;border:1px solid #BBF7D0;' ?>">
<?= e($alert['message'] ?? '') ?>
</div>
<?php endforeach;
endif; ?>
<?= $__template->yield('content', '') ?>
</div>
<script src="<?= url('assets/js/app.js') ?>"></script>
</body>
</html>
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'password_min_length' => 8,
'password_bcrypt_cost' => 12,
'max_failed_attempts' => 5,
'lockout_minutes' => 15,
'session_lifetime' => 30,
'password_history_count' => 5,
'password_expiry_days' => 90,
'max_concurrent_sessions' => 3,
'default_redirect' => '/users',
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `branches` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`branch_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`address` TEXT NULL,
`phone` VARCHAR(20) NULL,
`manager_name` VARCHAR(200) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`member_count_cache` INT UNSIGNED NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_branches_code` (`branch_code`),
INDEX `idx_branches_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `branches`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `employees` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`full_name_ar` VARCHAR(200) NOT NULL,
`full_name_en` VARCHAR(200) NULL,
`email` VARCHAR(255) NULL,
`phone` VARCHAR(20) NULL,
`branch_id` BIGINT UNSIGNED NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`force_password_change` TINYINT(1) NOT NULL DEFAULT 0,
`password_changed_at` TIMESTAMP NULL DEFAULT NULL,
`last_login_at` TIMESTAMP NULL DEFAULT NULL,
`last_login_ip` VARCHAR(45) NULL,
`failed_login_count` INT UNSIGNED NOT NULL DEFAULT 0,
`locked_until` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` TIMESTAMP NULL DEFAULT NULL,
`archived_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_employees_username` (`username`),
INDEX `idx_employees_branch` (`branch_id`),
INDEX `idx_employees_active` (`is_active`),
INDEX `idx_employees_archived` (`is_archived`),
CONSTRAINT `fk_employees_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `employees`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `roles` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`role_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`description_ar` VARCHAR(500) NULL,
`description_en` VARCHAR(500) NULL,
`is_system` TINYINT(1) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`parent_role_id` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_roles_code` (`role_code`),
INDEX `idx_roles_parent` (`parent_role_id`),
CONSTRAINT `fk_roles_parent` FOREIGN KEY (`parent_role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `roles`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `role_permissions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`role_id` BIGINT UNSIGNED NOT NULL,
`permission_key` VARCHAR(100) NOT NULL,
`granted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`granted_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_role_perm` (`role_id`, `permission_key`),
INDEX `idx_role_permissions_key` (`permission_key`),
CONSTRAINT `fk_role_permissions_role` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `role_permissions`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `employee_roles` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`employee_id` BIGINT UNSIGNED NOT NULL,
`role_id` BIGINT UNSIGNED NOT NULL,
`assigned_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`assigned_by` BIGINT UNSIGNED NULL,
`expires_at` TIMESTAMP NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
UNIQUE KEY `uq_emp_role_active` (`employee_id`, `role_id`, `is_active`),
INDEX `idx_employee_roles_role` (`role_id`),
INDEX `idx_employee_roles_expires` (`expires_at`),
CONSTRAINT `fk_employee_roles_emp` FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_employee_roles_role` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `employee_roles`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `login_attempts` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(500) NULL,
`success` TINYINT(1) NOT NULL DEFAULT 0,
`failure_reason` VARCHAR(200) NULL,
`attempted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_login_attempts_user` (`username`),
INDEX `idx_login_attempts_ip` (`ip_address`),
INDEX `idx_login_attempts_time` (`attempted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `login_attempts`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `active_sessions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`employee_id` BIGINT UNSIGNED NOT NULL,
`session_id` VARCHAR(128) NOT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(500) NULL,
`logged_in_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_activity_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
INDEX `idx_active_sessions_emp` (`employee_id`),
INDEX `idx_active_sessions_session` (`session_id`),
INDEX `idx_active_sessions_active` (`is_active`),
CONSTRAINT `fk_active_sessions_emp` FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `active_sessions`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `password_history` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`employee_id` BIGINT UNSIGNED NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_password_history_emp` (`employee_id`),
CONSTRAINT `fk_password_history_emp` FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `password_history`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$roles = [
['role_code' => 'super_admin', 'name_ar' => 'المدير العام للنظام', 'name_en' => 'Super Admin', 'description_ar' => 'صلاحيات كاملة على جميع الوحدات', 'is_system' => 1],
['role_code' => 'board_member', 'name_ar' => 'عضو مجلس أمناء', 'name_en' => 'Board Member', 'description_ar' => 'الموافقة والرفض والعقوبات والتقارير', 'is_system' => 1],
['role_code' => 'membership_director', 'name_ar' => 'مدير العضويات', 'name_en' => 'Membership Director', 'description_ar' => 'إدارة العضويات والمقابلات والمستندات', 'is_system' => 1],
['role_code' => 'membership_officer', 'name_ar' => 'موظف العضويات', 'name_en' => 'Membership Officer', 'description_ar' => 'إدخال البيانات ورفع المستندات', 'is_system' => 1],
['role_code' => 'treasury_manager', 'name_ar' => 'مدير الخزينة', 'name_en' => 'Treasury Manager', 'description_ar' => 'معالجة المدفوعات وإلغاء الإيصالات', 'is_system' => 1],
['role_code' => 'treasury_officer', 'name_ar' => 'أمين الخزينة', 'name_en' => 'Treasury Officer', 'description_ar' => 'معالجة المدفوعات وإصدار الإيصالات', 'is_system' => 1],
['role_code' => 'sales_agent', 'name_ar' => 'موظف المبيعات', 'name_en' => 'Sales Agent', 'description_ar' => 'استقبال الأعضاء وإصدار الاستمارات', 'is_system' => 1],
['role_code' => 'security_officer', 'name_ar' => 'ضابط الأمن', 'name_en' => 'Security Officer', 'description_ar' => 'التحقق من هوية الأعضاء عند البوابة', 'is_system' => 1],
['role_code' => 'report_viewer', 'name_ar' => 'مراجع التقارير', 'name_en' => 'Report Viewer', 'description_ar' => 'عرض التقارير فقط', 'is_system' => 1],
['role_code' => 'auditor', 'name_ar' => 'المراجع', 'name_en' => 'Auditor', 'description_ar' => 'الوصول للقراءة فقط لجميع البيانات', 'is_system' => 1],
];
$permissionMap = [
'super_admin' => ['*'],
'board_member' => [
'member.view', 'member.search', 'member.view_financial', 'member.change_status',
'spouse.view', 'child.view', 'temp.view',
'interview.conduct', 'interview.decide', 'interview.view',
'transfer.approve', 'transfer.view', 'separation.approve', 'separation.view',
'waiver.approve', 'waiver.view',
'fine.impose', 'fine.view', 'fine.waive',
'report.view_membership', 'report.view_financial', 'report.view_operations', 'report.view_audit',
'payment.view', 'subscription.view', 'subscription.exempt',
'user.view', 'settings.view',
],
'membership_director' => [
'member.create', 'member.view', 'member.edit', 'member.archive', 'member.search', 'member.view_financial', 'member.change_status',
'spouse.add', 'spouse.edit', 'spouse.remove', 'spouse.view',
'child.add', 'child.edit', 'child.remove', 'child.view', 'child.freeze', 'child.separate',
'temp.add', 'temp.edit', 'temp.remove', 'temp.view',
'interview.schedule', 'interview.view',
'transfer.initiate', 'transfer.view', 'separation.initiate', 'separation.view',
'document.upload', 'document.view', 'document.delete',
'carnet.print', 'carnet.replace', 'carnet.view_log',
'forms.view',
'report.view_membership',
],
'membership_officer' => [
'member.create', 'member.view', 'member.edit', 'member.search',
'spouse.add', 'spouse.edit', 'spouse.view',
'child.add', 'child.edit', 'child.view',
'temp.add', 'temp.edit', 'temp.view',
'document.upload', 'document.view',
'forms.view',
],
'treasury_manager' => [
'payment.process_cash', 'payment.process_check', 'payment.process_visa', 'payment.view', 'payment.void_receipt', 'payment.refund',
'installment.create_plan', 'installment.record_payment', 'installment.view', 'installment.modify_plan',
'subscription.collect', 'subscription.view', 'subscription.generate_batch',
'fine.collect', 'fine.view',
'report.view_financial',
'member.view', 'member.search', 'member.view_financial',
],
'treasury_officer' => [
'payment.process_cash', 'payment.process_check', 'payment.process_visa', 'payment.view',
'installment.record_payment', 'installment.view',
'subscription.collect', 'subscription.view',
'fine.collect', 'fine.view',
'member.view', 'member.search', 'member.view_financial',
],
'sales_agent' => [
'member.view', 'member.search',
'forms.view',
],
'security_officer' => [
'member.view', 'member.search',
'carnet.view_log',
],
'report_viewer' => [
'report.view_membership', 'report.view_financial', 'report.view_operations', 'report.view_audit', 'report.export',
'member.view', 'member.search', 'member.view_financial',
],
'auditor' => [
'report.view_membership', 'report.view_financial', 'report.view_operations', 'report.view_audit', 'report.export',
'member.view', 'member.search', 'member.view_financial',
'user.view', 'settings.view',
'payment.view', 'subscription.view', 'installment.view', 'fine.view',
'transfer.view', 'separation.view', 'waiver.view',
'spouse.view', 'child.view', 'temp.view',
'document.view', 'carnet.view_log',
'interview.view',
],
];
foreach ($roles as $role) {
$existing = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", [$role['role_code']]);
if ($existing) {
continue;
}
$roleId = $db->insert('roles', [
'role_code' => $role['role_code'],
'name_ar' => $role['name_ar'],
'name_en' => $role['name_en'],
'description_ar' => $role['description_ar'],
'is_system' => $role['is_system'],
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$permissions = $permissionMap[$role['role_code']] ?? [];
foreach ($permissions as $permKey) {
$db->insert('role_permissions', [
'role_id' => $roleId,
'permission_key' => $permKey,
'granted_at' => date('Y-m-d H:i:s'),
]);
}
}
};
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$existing = $db->selectOne("SELECT id FROM employees WHERE username = ?", ['admin']);
if ($existing) {
return;
}
$passwordHash = password_hash('ChangeMe123!', PASSWORD_BCRYPT, ['cost' => 12]);
$employeeId = $db->insert('employees', [
'username' => 'admin',
'password_hash' => $passwordHash,
'full_name_ar' => 'مدير النظام',
'full_name_en' => 'System Administrator',
'email' => 'admin@theclub.com',
'is_active' => 1,
'force_password_change' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$superRole = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", ['super_admin']);
if ($superRole) {
$db->insert('employee_roles', [
'employee_id' => $employeeId,
'role_id' => (int) $superRole['id'],
'assigned_at' => date('Y-m-d H:i:s'),
'is_active' => 1,
]);
}
$db->insert('password_history', [
'employee_id' => $employeeId,
'password_hash' => $passwordHash,
'created_at' => date('Y-m-d H:i:s'),
]);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); declare(strict_types=1);
// Load environment require_once __DIR__ . '/../app/Core/Autoloader.php';
$envFile = __DIR__ . '/../.env'; \App\Core\Autoloader::register();
// Load .env
$envFile = dirname(__DIR__) . '/.env';
if (file_exists($envFile)) { if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) { if ($lines !== false) {
$line = trim($line); foreach ($lines as $line) {
if ($line === '' || str_starts_with($line, '#')) continue; $line = trim($line);
if (str_contains($line, '=')) { if ($line === '' || str_starts_with($line, '#')) {
[$key, $val] = explode('=', $line, 2); continue;
$key = trim($key); }
$val = trim($val); if (str_contains($line, '=')) {
$_ENV[$key] = $val; [$key, $val] = explode('=', $line, 2);
$_SERVER[$key] = $val; $key = trim($key);
$val = trim($val);
$_ENV[$key] = $val;
$_SERVER[$key] = $val;
}
} }
} }
} }
require_once __DIR__ . '/../app/Core/Autoloader.php';
\App\Core\Autoloader::register();
\App\Core\ExceptionHandler::register(); \App\Core\ExceptionHandler::register();
$app = \App\Core\App::getInstance(); $app = \App\Core\App::getInstance();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment