diff --git a/.env b/.env index 6443556..1e48905 100644 --- a/.env +++ b/.env @@ -63,7 +63,7 @@ session.expiration = 43200 database.default.hostname = 127.0.0.1 database.default.database = school database.default.username = root -database.default.password = +database.default.password = rootpassword database.default.DBDriver = MySQLi database.default.DBPrefix = database.default.port = 3306 diff --git a/app/Config/Database.php b/app/Config/Database.php index 78561d0..0af3e90 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -9,43 +9,51 @@ class Database extends Config public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR; public string $defaultGroup = 'default'; - public array $default = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => 'u280815660_melabidi', - 'password' => '>tNxlRzP/W8', - 'database' => 'u280815660_school', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => '', - 'pConnect' => false, - 'DBDebug' => (ENVIRONMENT !== 'development'), - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, - ]; + public array $default = []; + public array $tests = []; - public array $tests = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => 'u280815660_melabidi', - 'password' => '>tNxlRzP/W8', - 'database' => 'u280815660_school', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => 'db_', - 'pConnect' => false, - 'DBDebug' => true, - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, - ]; + public function __construct() + { + parent::__construct(); + + $this->default = [ + 'DSN' => '', + 'hostname' => env('database.default.hostname'), + 'username' => env('database.default.username'), + 'password' => env('database.default.password'), + 'database' => env('database.default.database'), + 'DBDriver' => env('database.default.DBDriver', 'MySQLi'), + 'DBPrefix' => '', + 'pConnect' => false, + 'DBDebug' => (ENVIRONMENT !== 'development'), + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => (int) env('database.default.port', 3306), + ]; + + $this->tests = [ + 'DSN' => '', + 'hostname' => env('database.tests.hostname', env('database.default.hostname')), + 'username' => env('database.tests.username', env('database.default.username')), + 'password' => env('database.tests.password', env('database.default.password')), + 'database' => env('database.tests.database', env('database.default.database')), + 'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')), + 'DBPrefix' => env('database.tests.DBPrefix', 'db_'), + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => (int) env('database.tests.port', env('database.default.port', 3306)), + ]; + } } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 113b887..1b3eb7c 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -230,6 +230,10 @@ $routes->get('reset_password', 'View\UserController::resetPassword'); //$routes->get('/blocked', 'View\UserController::blocked'); +$routes->get('confirm_authorized_user', 'View\AuthorizedUsersController::confirm'); +$routes->get('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::setPassword/$1'); +$routes->post('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::savePassword/$1'); + $routes->post('assign_class_student', 'View\StudentController::assignClassStudent'); $routes->post('remove_class_student', 'View\StudentController::removeClassStudent'); $routes->post('administrator/remove_class_student', 'View\StudentController::removeClassStudent'); // alias to avoid 404s diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 96aa341..aee212d 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -469,6 +469,7 @@ class AuthController extends Controller // Generate a secure token for the password reset helper('text'); $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); // Calculate the expiration time for the token (1 hour from now) $expires_at = Time::now()->addHours(1); @@ -477,7 +478,7 @@ class AuthController extends Controller $passwordResetModel = new PasswordResetModel(); $passwordResetModel->insert([ 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); diff --git a/app/Controllers/View/AuthorizedUsersController.php b/app/Controllers/View/AuthorizedUsersController.php index 5e9ae4a..a6cc46c 100644 --- a/app/Controllers/View/AuthorizedUsersController.php +++ b/app/Controllers/View/AuthorizedUsersController.php @@ -10,6 +10,8 @@ use CodeIgniter\I18n\Time; class AuthorizedUsersController extends ResourceController { + private const TOKEN_TTL_HOURS = 24; + protected $userModel; protected $authorizedUserModel; @@ -18,6 +20,30 @@ class AuthorizedUsersController extends ResourceController $this->userModel = new UserModel(); $this->authorizedUserModel = new AuthorizedUserModel(); } + + private function requireLogin() + { + if (!session()->get('is_logged_in')) { + return $this->failUnauthorized('Authentication required.'); + } + + return null; + } + + private function requireOwnership(array $authorizedUser) + { + $userId = (int) session()->get('user_id'); + if ($userId <= 0 || (int) ($authorizedUser['user_id'] ?? 0) !== $userId) { + return $this->failForbidden('You do not have access to this resource.'); + } + + return null; + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } /** * Return a list of authorized users for the logged-in main user. * @@ -25,7 +51,10 @@ class AuthorizedUsersController extends ResourceController */ public function index() { - + if ($resp = $this->requireLogin()) { + return $resp; + } + $userId = session()->get('user_id'); $authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll(); @@ -40,12 +69,20 @@ class AuthorizedUsersController extends ResourceController */ public function show($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + $authorizedUser = $this->authorizedUserModel->find($id); if (!$authorizedUser) { return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + return $this->respond($authorizedUser); } @@ -56,6 +93,10 @@ class AuthorizedUsersController extends ResourceController */ public function create() { + if ($resp = $this->requireLogin()) { + return $resp; + } + $email = strtolower($this->request->getPost('email')); // Validate email @@ -66,19 +107,20 @@ class AuthorizedUsersController extends ResourceController $user = $this->userModel->where('email', $email)->first(); if (!$user) { - return $this->failNotFound('No user found with this email.'); + return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']); } // Generate a token for confirmation helper('text'); $token = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($token); // Add entry to the authorized_users table $this->authorizedUserModel->insert([ 'user_id' => session()->get('user_id'), // Main user ID 'authorized_user_id' => $user['id'], 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'status' => 'Pending' ]); @@ -96,6 +138,10 @@ class AuthorizedUsersController extends ResourceController */ public function update($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + // Fetch the authorized user $authorizedUser = $this->authorizedUserModel->find($id); @@ -103,6 +149,10 @@ class AuthorizedUsersController extends ResourceController return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + // Update the authorized user’s information (e.g., email) $email = strtolower($this->request->getPost('email')); if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -122,12 +172,20 @@ class AuthorizedUsersController extends ResourceController */ public function delete($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + $authorizedUser = $this->authorizedUserModel->find($id); if (!$authorizedUser) { return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + // Delete the authorized user record $this->authorizedUserModel->delete($id); @@ -147,16 +205,28 @@ class AuthorizedUsersController extends ResourceController return $this->fail('Invalid confirmation link.'); } - $authorizedUser = $this->authorizedUserModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$authorizedUser) { return $this->fail('Invalid or expired confirmation link.'); } - // Mark the authorized user as active - $this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]); + // Mark the authorized user as active and rotate token for password setup + $nextToken = bin2hex(random_bytes(48)); + $nextTokenHash = $this->hashToken($nextToken); + $this->authorizedUserModel->update($authorizedUser['id'], [ + 'status' => 'Active', + 'token' => $nextTokenHash, + ]); - return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']); + return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id'] . '?token=' . $nextToken); } /** @@ -167,13 +237,36 @@ class AuthorizedUsersController extends ResourceController */ public function setPassword($authorizedUserId) { + $token = (string) $this->request->getGet('token'); + if ($token === '') { + return $this->fail('Invalid confirmation link.'); + } + + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); + + if (!$authorizedUser) { + return $this->fail('Invalid or expired confirmation link.'); + } + $user = $this->userModel->find($authorizedUserId); if (!$user) { return $this->failNotFound('User not found.'); } - return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]); + return view('user/set_authorized_user_password', [ + 'userId' => $authorizedUserId, + 'token' => $token, + ]); } /** @@ -181,38 +274,59 @@ class AuthorizedUsersController extends ResourceController * * @return ResponseInterface */ - /* - public function savePassword() + public function savePassword($authorizedUserId = null) { - // Validate the request $validation = \Config\Services::validation(); $validation->setRules([ - 'password' => 'required|min_length[6]', + 'password' => [ + 'label' => 'Password', + 'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\\-=\\+*#$%&!?])[A-Za-z\\d@\\-=\\+*#$%&!?]{8,}$/]', + ], 'password_confirm' => 'required|matches[password]', - 'user_id' => 'required|integer' + 'user_id' => 'required|integer', + 'token' => 'required', ]); if (!$this->validate($validation->getRules())) { return $this->failValidationErrors($validation->getErrors()); } - // Get the validated input - $userId = $this->request->getPost('user_id'); - $password = $this->request->getPost('password'); + $userId = (int) $this->request->getPost('user_id'); + $token = (string) $this->request->getPost('token'); + $authorizedUserId = $authorizedUserId !== null ? (int) $authorizedUserId : $userId; - $model = new UserModel(); - $user = $model->find($userId); + if ($userId <= 0 || $authorizedUserId <= 0 || $userId !== $authorizedUserId) { + return $this->fail('Invalid request.'); + } + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); + + if (!$authorizedUser) { + return $this->fail('Invalid or expired confirmation link.'); + } + + $user = $this->userModel->find($authorizedUserId); if (!$user) { return $this->failNotFound('User not found.'); } - // Save the password - $model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]); + $password = (string) $this->request->getPost('password'); + $hashedPassword = pbkdf2_hash($password); + + $this->userModel->update($authorizedUserId, ['password' => $hashedPassword]); + $this->authorizedUserModel->update($authorizedUser['id'], ['token' => null]); return $this->respond(['message' => 'Password has been successfully set.']); } -*/ /** * Sends a confirmation email to the authorized user. * @@ -242,4 +356,4 @@ class AuthorizedUsersController extends ResourceController log_message('error', 'Failed to send authorized user confirmation email to ' . $email); } } -} \ No newline at end of file +} diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php index 6e6e7ef..6a62edc 100644 --- a/app/Controllers/View/FlagController.php +++ b/app/Controllers/View/FlagController.php @@ -421,17 +421,45 @@ class FlagController extends Controller log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state')); $currentFlagModel = new CurrentFlagModel(); + $userId = session()->get('user_id'); // Get the new flag state from the form $newState = $this->request->getPost('flag_state'); + $stateDescription = (string) ($this->request->getPost('state_description') ?? ''); + $actionTaken = (string) ($this->request->getPost('action_taken') ?? ''); if (!$newState) { session()->setFlashdata('error', 'incident state not provided.'); return $this->index(); } + $update = ['flag_state' => $newState]; + if ($newState === 'Closed') { + $update['updated_by_closed'] = $userId; + if ($stateDescription !== '') { + $update['close_description'] = $stateDescription; + } + if ($actionTaken !== '') { + $update['action_taken'] = $actionTaken; + } + } elseif ($newState === 'Canceled') { + $update['updated_by_canceled'] = $userId; + if ($stateDescription !== '') { + $update['cancel_description'] = $stateDescription; + } + if ($actionTaken !== '') { + $update['action_taken'] = $actionTaken; + } + } + // Update the flag state in the database - if ($currentFlagModel->update($id, ['flag_state' => $newState])) { + if ($currentFlagModel->update($id, $update)) { + if ($newState === 'Closed' || $newState === 'Canceled') { + $flagData = $currentFlagModel->find($id); + if ($flagData) { + return $this->moveToHistory($flagData); + } + } session()->setFlashdata('success', 'Incident state updated successfully!'); } else { $errors = $currentFlagModel->errors(); diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index f323d6e..6aa2480 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -1216,6 +1216,14 @@ class GradingController extends Controller $flagModel = new CurrentFlagModel(); $semKey = strtolower(trim($semester)); + $redirectUrl = base_url('grading/below-60'); + $query = http_build_query([ + 'semester' => $semester, + 'school_year' => $schoolYear, + ]); + if ($query !== '') { + $redirectUrl .= '?' . $query; + } $existing = $flagModel ->where('student_id', $studentId) @@ -1226,11 +1234,14 @@ class GradingController extends Controller $userId = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); + $ok = true; if ($existing) { $data = [ 'flag_state' => $status, 'flag_datetime' => $now, + 'semester' => $semester, + 'school_year' => $schoolYear, 'updated_at' => $now, ]; if ($status === 'Open') { @@ -1246,7 +1257,7 @@ class GradingController extends Controller $data['close_description'] = trim($prev . PHP_EOL . $note); } } - $flagModel->update((int)$existing['id'], $data); + $ok = (bool) $flagModel->update((int)$existing['id'], $data); } else { $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); @@ -1269,10 +1280,21 @@ class GradingController extends Controller $data['updated_by_closed'] = $userId; if ($note !== '') $data['close_description'] = $note; } - $flagModel->insert($data); + $ok = (bool) $flagModel->insert($data); } - return redirect()->back()->with('status', 'Status updated.'); + if (!$ok) { + log_message('error', 'updateBelowSixtyStatus failed', [ + 'student_id' => $studentId, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'status' => $status, + 'errors' => $flagModel->errors(), + ]); + return redirect()->to($redirectUrl)->with('error', 'Failed to update status.'); + } + + return redirect()->to($redirectUrl)->with('status', 'Status updated.'); } public function scheduleBelowSixty() @@ -1797,9 +1819,10 @@ class GradingController extends Controller } $statusMap = []; + $noteMap = []; if (!empty($studentIds)) { $flagRows = $this->db->table('current_flag') - ->select('student_id, flag_state') + ->select('student_id, flag_state, open_description, close_description') ->where('flag', 'grade') ->where('school_year', $schoolYear) ->where("LOWER(TRIM(semester))", $semesterKey) @@ -1810,6 +1833,12 @@ class GradingController extends Controller $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) continue; $statusMap[$sid] = (string)($row['flag_state'] ?? ''); + $openNote = trim((string)($row['open_description'] ?? '')); + $closeNote = trim((string)($row['close_description'] ?? '')); + $noteMap[$sid] = [ + 'open' => $openNote, + 'closed' => $closeNote, + ]; } } @@ -1818,6 +1847,15 @@ class GradingController extends Controller $row['comment'] = $commentMap[$sid] ?? ''; $flagState = strtolower(trim((string)($statusMap[$sid] ?? ''))); $row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open'; + $noteBag = $noteMap[$sid] ?? ['open' => '', 'closed' => '']; + $rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open']; + if ($rawNote !== '') { + $lines = preg_split('/\R/', $rawNote); + $lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== '')); + $row['note'] = $lines ? end($lines) : ''; + } else { + $row['note'] = ''; + } } unset($row); diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index d563aa7..027d08f 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -717,6 +717,7 @@ class ParentController extends BaseController // Step 1: Generate a secure token for email verification $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); // Step 2: Determine user type based on relationship $userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary'; @@ -776,7 +777,7 @@ class ParentController extends BaseController 'state' => strtoupper($userData['state']), 'zip' => $userData['zip'], 'accept_school_policy' => $userData['accept_school_policy'] ?? 0, - 'token' => $token, + 'token' => $tokenHash, 'is_verified' => 0, 'status' => 'Inactive', 'user_type' => $userType, diff --git a/app/Controllers/View/RegisterController.php b/app/Controllers/View/RegisterController.php index fe22344..4970690 100644 --- a/app/Controllers/View/RegisterController.php +++ b/app/Controllers/View/RegisterController.php @@ -173,16 +173,8 @@ class RegisterController extends Controller $existingUser = $this->userModel->where('email', $post['email'])->first(); if ($existingUser) { - // Step 2: Check if the user has a token (i.e., not verified yet) - if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) { - // User exists and is unverified - return redirect()->back()->withInput()->with('error', - 'This email address is already registered and is pending activation. Please check your email to activate your account.'); - } else { - // User exists and is already active or has no token - return redirect()->back()->withInput()->with('error', - 'The email address you entered is already in use. Please try a different one.'); - } + return redirect()->back()->withInput()->with('error', + 'This email address is already registered. Please check your email or log in.'); } /* ───────────── 6. Determine role ───────────── */ @@ -194,6 +186,7 @@ class RegisterController extends Controller /* ───────────── 7. Build & insert user ───────────── */ $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); $userData = [ 'firstname' => $post['firstname'], 'lastname' => $post['lastname'], @@ -205,7 +198,7 @@ class RegisterController extends Controller 'city' => $post['city'], 'state' => $post['state'], 'zip' => $post['zip'], - 'token' => $token, + 'token' => $tokenHash, 'is_verified'=> 0, 'accept_school_policy' => (int) $post['accept_school_policy'], 'status' => 'Inactive', @@ -357,4 +350,4 @@ class RegisterController extends Controller -} \ No newline at end of file +} diff --git a/app/Controllers/View/SubjectCurriculumController.php b/app/Controllers/View/SubjectCurriculumController.php index adcb695..2273b72 100644 --- a/app/Controllers/View/SubjectCurriculumController.php +++ b/app/Controllers/View/SubjectCurriculumController.php @@ -99,6 +99,7 @@ class SubjectCurriculumController extends BaseController ->orderBy('classes.class_name', 'ASC') ->orderBy('subject', 'ASC') ->orderBy('unit_number', 'ASC') + ->orderBy("CAST(SUBSTRING_INDEX(subject_curriculum_items.chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false) ->orderBy('chapter_name', 'ASC') ->get() ->getResultArray(); diff --git a/app/Controllers/View/UserController.php b/app/Controllers/View/UserController.php index f8d1c6f..2f7e64e 100644 --- a/app/Controllers/View/UserController.php +++ b/app/Controllers/View/UserController.php @@ -21,6 +21,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php'; class UserController extends BaseController { + private const ACTIVATION_TTL_HOURS = 48; protected $userModel; protected $roleModel; protected $userRoleModel; @@ -49,6 +50,37 @@ class UserController extends BaseController $this->resetRequestModel = new PasswordResetRequestModel(); } + private function denyAccess(string $message) + { + if ($this->request->isAJAX() || $this->request->getHeaderLine('Accept') === 'application/json') { + return service('response') + ->setStatusCode(403) + ->setJSON(['status' => 'error', 'message' => $message]); + } + + session()->setFlashdata('error', $message); + return redirect()->to('/access_denied'); + } + + private function requirePermission(string $permission) + { + if (!session()->get('is_logged_in')) { + return redirect()->to('/login'); + } + + $userId = (int) session()->get('user_id'); + if ($userId <= 0 || !has_permission($userId, $permission)) { + return $this->denyAccess("You don't have permission to use this feature."); + } + + return null; + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } + // Method to show the home page public function home() { @@ -75,6 +107,10 @@ class UserController extends BaseController public function userList() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + helper('url'); return view('user/user_list', [ @@ -85,6 +121,10 @@ class UserController extends BaseController // Method to show the list of users public function index() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + // Fetch users along with their assigned roles $builder = $this->db->table('users'); $builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at'); @@ -122,6 +162,10 @@ class UserController extends BaseController public function userListData() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + return $this->response->setJSON([ 'users' => $this->buildUsersWithRoles(), ]); @@ -253,6 +297,10 @@ class UserController extends BaseController // Method to store a new user public function store() { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + // Validate input data $validation = \Config\Services::validation(); $validation->setRules([ @@ -315,6 +363,10 @@ class UserController extends BaseController // Method to show the form for editing an existing user public function edit($id) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + $data['user'] = $this->userModel->find($id); $data['roles'] = $this->roleModel->findAll(); $userRoles = $this->userRoleModel->where('user_id', $id)->findAll(); @@ -327,6 +379,10 @@ class UserController extends BaseController // Method to delete an existing user public function delete($id) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + $this->userModel->delete($id); // Delete the user's roles from the user_roles table @@ -369,27 +425,21 @@ class UserController extends BaseController $email = strtolower($this->request->getPost('email')); $user = $this->userModel->where('email', $email)->first(); - // --- Handle unknown email --- - if (!$user) { - session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.'); - log_message('info', "Password reset requested for non-existing user {$email}"); - return redirect()->back(); - } - - // --- Handle unverified accounts --- - if ((int) $user['is_verified'] === 0) { - session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.'); - log_message('info', "Password reset blocked for unverified user {$email}"); + // --- Handle unknown or unverified email --- + if (!$user || (int) $user['is_verified'] === 0) { + session()->setFlashdata('success', 'If this email is registered, you will receive a reset link.'); + log_message('info', "Password reset requested for {$email} (user missing or unverified)."); return redirect()->back(); } // --- Verified user: continue with reset --- $token = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($token); $expires_at = Time::now()->addHours(1); $this->passwordResetModel->insert([ 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); @@ -447,7 +497,12 @@ class UserController extends BaseController } // You may want to validate the token here - $resetEntry = $this->passwordResetModel->where('token', $token) + $tokenHash = $this->hashToken($token); + $resetEntry = $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() ->where('expires_at >=', Time::now()) ->first(); @@ -462,6 +517,10 @@ class UserController extends BaseController //This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry. public function processResetPassword() { + if (strtolower($this->request->getMethod()) !== 'post') { + return redirect()->to('/')->with('error', 'Invalid request.'); + } + $token = $this->request->getPost('token'); $newPassword = $this->request->getPost('password'); $passConfirm = $this->request->getPost('pass_confirm'); @@ -490,7 +549,12 @@ class UserController extends BaseController } // Find the password reset entry - $resetEntry = $this->passwordResetModel->where('token', $token) + $tokenHash = $this->hashToken($token); + $resetEntry = $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() ->where('expires_at >=', Time::now()) ->first(); @@ -519,7 +583,12 @@ class UserController extends BaseController ]); // Delete the used token from the password reset table - $this->passwordResetModel->where('token', $token)->delete(); + $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->delete(); // Retrieve the user's IP address from the request $ipAddress = $this->request->getIPAddress(); @@ -546,10 +615,16 @@ class UserController extends BaseController public function confirm($token) { - log_message('info', 'Processing email confirmation with token: ' . $token); + log_message('info', 'Processing email confirmation.'); - - $user = $this->userModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -570,7 +645,14 @@ class UserController extends BaseController { //echo "Reached setPassword with token: " . esc($token); //echo "Token received: " . $token; - $user = $this->userModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -584,6 +666,10 @@ class UserController extends BaseController public function savePassword() { + if (strtolower($this->request->getMethod()) !== 'post') { + return redirect()->to('/')->with('error', 'Invalid request.'); + } + $validation = \Config\Services::validation(); $validation->setRules([ 'password' => [ @@ -615,9 +701,17 @@ class UserController extends BaseController $token = $this->request->getPost('token'); $password = $this->request->getPost('password'); - $user = $this->userModel->where('id', $userId)->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->where('id', $userId) + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); - log_message('debug', "Attempting to set password for user $userId with token $token"); + log_message('debug', "Attempting to set password for user $userId"); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -670,20 +764,39 @@ class UserController extends BaseController $roleKey = (string) $this->request->getPost('role'); log_message('info', 'Role selected: ' . $roleKey); - $roleModel = new RoleModel(); - $route = $roleModel->getRouteByNameOrSlug($roleKey); - - if ($route === null) { - log_message('error', 'Invalid or inactive role selected: ' . $roleKey); - return redirect()->back()->with('error', 'Invalid role selected.'); - } - $userId = (int) session()->get('user_id'); log_message('info', 'User ID: ' . $userId); + if ($userId <= 0) { + return $this->denyAccess("You don't have permission to use this feature."); + } + + $roleRow = $this->db->table('user_roles ur') + ->join('roles r', 'r.id = ur.role_id', 'inner') + ->select('r.name, r.slug, r.dashboard_route') + ->where('ur.user_id', $userId) + ->where('r.is_active', 1) + ->groupStart() + ->where('LOWER(r.name)', strtolower($roleKey)) + ->orWhere('LOWER(r.slug)', strtolower($roleKey)) + ->groupEnd() + ->get() + ->getRowArray(); + + if (empty($roleRow)) { + log_message('error', 'Invalid or unassigned role selected: ' . $roleKey); + return redirect()->back()->with('error', 'Invalid role selected.'); + } + + $route = $roleRow['dashboard_route'] ?? null; + if ($route === null) { + log_message('error', 'No dashboard route configured for role: ' . $roleKey); + return redirect()->back()->with('error', 'Invalid role selected.'); + } + // Persist the *exact* role name or slug—choose your convention. - // If you want to store the canonical name, fetch the row and use $row['name']. - $this->userModel->update($userId, ['role' => $roleKey]); + // Store the canonical name to avoid arbitrary role strings. + $this->userModel->update($userId, ['role' => $roleRow['name']]); log_message('info', 'Role updated in database.'); log_message('info', 'Redirecting to role dashboard: ' . $route); @@ -702,6 +815,10 @@ class UserController extends BaseController public function delete_role($roleId) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + // Fetch the role to be deleted $role = $this->roleModel->find($roleId); if (!$role) { @@ -731,6 +848,10 @@ class UserController extends BaseController public function loginActivity() { + if ($resp = $this->requirePermission('view_login_activity')) { + return $resp; + } + helper('url'); $perPage = (int) ($this->request->getGet('per_page') ?? 25); @@ -743,6 +864,10 @@ class UserController extends BaseController public function loginActivityData() { + if ($resp = $this->requirePermission('view_login_activity')) { + return $resp; + } + $perPage = (int) ($this->request->getGet('per_page') ?? 25); $page = (int) ($this->request->getGet('page') ?? 1); @@ -752,6 +877,10 @@ class UserController extends BaseController // Method to update an existing user public function updateUser() { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.'); } @@ -800,9 +929,6 @@ class UserController extends BaseController 'status' => trim((string)$this->request->getPost('status')), 'is_suspended' => $toBool('is_suspended'), 'is_verified' => $toBool('is_verified'), - 'token' => trim((string)$this->request->getPost('token')), - 'updated_at' => $toDT('updated_at'), - 'created_at' => $toDT('created_at'), ]; // Validation diff --git a/app/Models/ConfigurationModel.php b/app/Models/ConfigurationModel.php index 86ca89b..0a6463d 100644 --- a/app/Models/ConfigurationModel.php +++ b/app/Models/ConfigurationModel.php @@ -22,11 +22,13 @@ class ConfigurationModel extends Model */ public function getConfigValueByKey(string $key) { - // Deterministic read in case historical duplicates exist - $result = $this->where('config_key', $key) + // Use a fresh builder to avoid stale state from shared model builder. + $builder = $this->db->table($this->table); + $result = $builder->where('config_key', $key) ->orderBy('id', 'DESC') - ->first(); - return $result ? $result['config_value'] : null; + ->get(1) + ->getRowArray(); + return $result['config_value'] ?? null; } /** diff --git a/app/Models/SubjectCurriculumModel.php b/app/Models/SubjectCurriculumModel.php index 24a5196..d83e308 100644 --- a/app/Models/SubjectCurriculumModel.php +++ b/app/Models/SubjectCurriculumModel.php @@ -27,6 +27,7 @@ class SubjectCurriculumModel extends Model return $this->where('class_id', $classId) ->where('subject', $subject) ->orderBy('unit_number', 'ASC') + ->orderBy("CAST(SUBSTRING_INDEX(chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false) ->orderBy('chapter_name', 'ASC') ->findAll(); } diff --git a/app/Views/administrator/daily_attendance_analysis.php b/app/Views/administrator/daily_attendance_analysis.php index b0ca7a7..4036c39 100644 --- a/app/Views/administrator/daily_attendance_analysis.php +++ b/app/Views/administrator/daily_attendance_analysis.php @@ -250,6 +250,54 @@ $analysisSectionTotals[] = (int)($s['total_students'] ?? 0); } + // ---- Student absences/late list ---- + $sectionLabelByKey = []; + foreach ($grades as $classId => $sections) { + foreach ($sections as $section) { + $sectionKey = (string)($section['class_section_id'] ?? ($section['id'] ?? '')); + if ($sectionKey === '') continue; + $secNameRaw = trim((string)($section['class_section_name'] ?? '')); + $sectionLabelByKey[$sectionKey] = $secNameRaw !== '' ? $secNameRaw : ('Section ' . $sectionKey); + } + } + + $studentIssueRows = []; + foreach ($studentsBySection as $sectionKey => $students) { + foreach ($students as $stu) { + $sid = (int)($stu['id'] ?? 0); + if ($sid <= 0) continue; + $entries = $attendanceData[$sectionKey][$sid] ?? []; + if (!is_array($entries)) continue; + $abs = 0; + $late = 0; + foreach ($entries as $e) { + $d = substr((string)($e['date'] ?? ''), 0, 10); + if ($d === '') continue; + if ($filterStart !== '' && $d < $filterStart) continue; + if ($filterEnd !== '' && $d > $filterEnd) continue; + $st = strtolower(trim((string)($e['status'] ?? ''))); + if ($st === 'absent') { + $abs++; + } elseif ($st === 'late') { + $late++; + } + } + if (($abs + $late) <= 0) continue; + $studentIssueRows[] = [ + 'name' => trim((string)($stu['firstname'] ?? '') . ' ' . (string)($stu['lastname'] ?? '')), + 'section' => $sectionLabelByKey[(string)$sectionKey] ?? ('Section ' . $sectionKey), + 'absent' => $abs, + 'late' => $late, + ]; + } + } + + usort($studentIssueRows, static function ($a, $b) { + $sec = strcmp($a['section'], $b['section']); + if ($sec !== 0) return $sec; + return strcmp($a['name'], $b['name']); + }); + $totalDaysForPercent = 0; if ($filterStart === '' && $filterEnd === '' && !empty($totalPassedDays)) { $totalDaysForPercent = (int)$totalPassedDays; @@ -373,6 +421,37 @@ +
| Student Name | +Class Section | +Nbr of ABS | +Nbr of LATE | +
|---|---|---|---|
| No absences or late records in the selected range. | +|||
| = esc($row['name']) ?> | += esc($row['section']) ?> | += (int)$row['absent'] ?> | += (int)$row['late'] ?> | +