feat: add role and session access controls

Add configurable role management, expanded project permission coverage, secured module routes, and single-active-session enforcement with online user administration.
This commit is contained in:
DevSoftTech Server 2026-08-09 23:21:16 +05:30
parent b2926d5a0b
commit c263b9e4ac
25 changed files with 1696 additions and 137 deletions

View File

@ -6,10 +6,23 @@ namespace App\Authentication\Authenticators;
use CodeIgniter\Shield\Authentication\Authenticators\Session;
use CodeIgniter\Shield\Authentication\Passwords;
use CodeIgniter\Shield\Entities\User;
use CodeIgniter\Shield\Result;
class LegacySafeSession extends Session
{
public function completeLogin(User $user): void
{
parent::completeLogin($user);
(new \App\Libraries\UserSessionRegistry())->activate((int) $user->id);
}
public function logout(): void
{
(new \App\Libraries\UserSessionRegistry())->deactivateCurrent();
parent::logout();
}
/**
* @phpstan-param array{email?: string, username?: string, password?: string} $credentials
*/

View File

@ -53,6 +53,10 @@ class AuthGroups extends ShieldAuthGroups
'title' => 'Developer',
'description' => 'Site programmers.',
],
'doctor' => [
'title' => 'Doctor',
'description' => 'Clinical doctor access to OPD work, diagnosis reports, and immunization records.',
],
'pharmacy_admin' => [
'title' => 'Pharmacy Admin',
'description' => 'Full pharmacy controls including old bill edits and discount overrides.',
@ -139,13 +143,22 @@ class AuthGroups extends ShieldAuthGroups
'reports.collection.view' => 'Can view collection reports',
'reports.insurance_credit.view' => 'Can view insurance credit reports',
'reports.nabh_audit.view' => 'Can view NABH audit report',
'reports.billing_operations.view' => 'Can view billing operations reports',
'reports.document_issue.view' => 'Can view document issue reports',
'finance.workflow.view' => 'Can access Accounts and Finance workflow dashboard',
'finance.access' => 'Can access finance procurement and payable workflows',
'finance.cash.billing.submit' => 'Can create billing cash entries and submit cash statements',
'finance.cash.accounts.accept' => 'Can accept submitted billing cash statements',
'finance.cash.accounts.verify' => 'Can verify accepted billing cash statements',
'finance.bank.deposit.create' => 'Can create bank deposit register entries',
'finance.bank.audit' => 'Can mark bank transaction audit status',
'finance.bank.statement.update' => 'Can mark updated in bank statement status',
'finance.compliance.view' => 'Can view finance compliance reports',
'finance.doctor_payout.manage' => 'Can manage doctor payouts and agreements',
'finance.vendor.manage' => 'Can manage finance vendors',
'finance.po.manage' => 'Can manage finance purchase orders',
'finance.grn.manage' => 'Can manage goods receipt notes',
'finance.invoice.manage' => 'Can manage vendor and payable invoices',
'diagnosis.access' => 'Can access diagnosis module',
'diagnosis.report.view' => 'Can view diagnosis reports',
'doctor_work.access' => 'Can access doctor work module',
@ -154,6 +167,9 @@ class AuthGroups extends ShieldAuthGroups
'doctor_work.medicine.manage' => 'Can manage OPD medicine',
'doctor_work.advice.manage' => 'Can manage OPD advice master',
'doctor_work.template_workspace.access' => 'Can access clinical templates workspace',
'doctor_work.immunization.access' => 'Can access immunization schedules and patient records',
'doctor_work.immunization.schedule-manage' => 'Can synchronize and modify immunization schedule masters',
'doctor_work.immunization.record-manage' => 'Can generate and complete patient immunization records',
'pharmacy.access' => 'Can access pharmacy module',
'pharmacy.invoice.admin' => 'Can fully manage pharmacy invoice controls (old bills and discount overrides)',
'pharmacy.invoice.discount.10' => 'Can apply pharmacy invoice discount up to 10%',
@ -184,6 +200,15 @@ class AuthGroups extends ShieldAuthGroups
'abdm.taskboard.access' => 'Can access ABDM task board',
'abdm.gateway.use' => 'Can run ABDM gateway actions from UI',
'abdm.bridge_log.view' => 'Can view ABDM Bridge communication log',
'abdm.abha.create' => 'Can create, verify, and link ABHA records for patients',
'billing.payment_request.view' => 'Can view organization payment requests',
'billing.payment_request.manage' => 'Can process organization payment requests',
'billing.refund.view' => 'Can view billing refund requests',
'billing.refund.manage' => 'Can process billing refunds',
'billing.items.view' => 'Can view billing item masters',
'billing.items.manage' => 'Can manage billing item masters',
'billing.packages.view' => 'Can view billing package masters',
'billing.packages.manage' => 'Can manage billing package masters',
];
/**
@ -208,6 +233,8 @@ class AuthGroups extends ShieldAuthGroups
'reports.collection.view',
'reports.insurance_credit.view',
'reports.nabh_audit.view',
'reports.billing_operations.view',
'reports.document_issue.view',
'finance.*',
'diagnosis.*',
'doctor_work.*',
@ -232,6 +259,8 @@ class AuthGroups extends ShieldAuthGroups
'reports.collection.view',
'reports.insurance_credit.view',
'reports.nabh_audit.view',
'reports.billing_operations.view',
'reports.document_issue.view',
'finance.*',
'diagnosis.*',
'doctor_work.*',
@ -255,6 +284,8 @@ class AuthGroups extends ShieldAuthGroups
'reports.collection.view',
'reports.insurance_credit.view',
'reports.nabh_audit.view',
'reports.billing_operations.view',
'reports.document_issue.view',
'finance.*',
'diagnosis.*',
'doctor_work.*',
@ -263,6 +294,14 @@ class AuthGroups extends ShieldAuthGroups
'hospital_stock.*',
'abdm.*',
],
'doctor' => [
'opd.doctor-panel.access',
'doctor_work.access',
'doctor_work.immunization.access',
'doctor_work.immunization.record-manage',
'diagnosis.access',
'diagnosis.report.view',
],
'pharmacy_admin' => [
'pharmacy.*',
],
@ -299,6 +338,11 @@ class AuthGroups extends ShieldAuthGroups
'finance.workflow.view',
'finance.cash.billing.submit',
'finance.bank.deposit.create',
'abdm.abha.create',
'billing.payment_request.view',
'billing.payment_request.manage',
'billing.refund.view',
'billing.refund.manage',
],
'accounts_officer' => [
'finance.workflow.view',

View File

@ -2,6 +2,7 @@
namespace Config;
use App\Filters\SingleActiveSessionFilter;
use CodeIgniter\Config\Filters as BaseFilters;
use CodeIgniter\Filters\Cors;
use CodeIgniter\Filters\CSRF;
@ -34,6 +35,7 @@ class Filters extends BaseFilters
'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class,
'singleSession' => SingleActiveSessionFilter::class,
];
/**
@ -71,6 +73,7 @@ class Filters extends BaseFilters
*/
public array $globals = [
'before' => [
'singleSession',
// 'honeypot',
// 'csrf',
// 'invalidchars',

View File

@ -333,6 +333,7 @@ $routes->post('Finance/bank_pos_settlement_create', 'Finance::bankPosSettlementC
$routes->post('Finance/bank_pos_settlement_accept', 'Finance::bankPosSettlementAccept');
service('auth')->routes($routes);
$routes->get('auth/session-status', 'AuthSession::status');
// Hospital Stock Management (Storestock)
$routes->get('Storestock', 'Storestock::index');
@ -697,14 +698,14 @@ $routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmuni
$routes->post('AbdmGateway/share_wellness_bundle', 'AbdmGateway::shareWellnessBundle', ['filter' => $abdmPermFilter]);
$routes->post('AbdmGateway/share_health_document_bundle', 'AbdmGateway::shareHealthDocumentBundle', ['filter' => $abdmPermFilter]);
$routes->post('AbdmGateway/share_invoice_bundle', 'AbdmGateway::shareInvoiceBundle', ['filter' => $abdmPermFilter]);
$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/sync_uip_master', 'Immunization::syncUipMaster', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/update_schedule/(:num)', 'Immunization::updateSchedule/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/schedule', 'Immunization::schedule', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/patient/(:num)', 'Immunization::patient/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/generate_patient_schedule/(:num)', 'Immunization::generatePatientSchedule/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/complete/(:num)', 'Immunization::complete/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.immunization.access']);
$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
$routes->post('Immunization/sync_uip_master', 'Immunization::syncUipMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
$routes->post('Immunization/update_schedule/(:num)', 'Immunization::updateSchedule/$1', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
$routes->get('Immunization/schedule', 'Immunization::schedule', ['filter' => 'permission:doctor_work.immunization.access']);
$routes->get('Immunization/patient/(:num)', 'Immunization::patient/$1', ['filter' => 'permission:doctor_work.immunization.access']);
$routes->post('Immunization/generate_patient_schedule/(:num)', 'Immunization::generatePatientSchedule/$1', ['filter' => 'permission:doctor_work.immunization.record-manage']);
$routes->post('Immunization/complete/(:num)', 'Immunization::complete/$1', ['filter' => 'permission:doctor_work.immunization.record-manage']);
$routes->post('AbdmGateway/push_health_record', 'AbdmGateway::pushHealthRecord', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/health_records_list', 'AbdmGateway::healthRecordsList', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/bridge_record_status/(:num)', 'AbdmGateway::bridgeRecordStatus/$1', ['filter' => $abdmPermFilter]);
@ -728,15 +729,15 @@ $routes->post('AbdmGateway/abha_aadhaar_verify_otp', 'AbdmGateway::abhaAadhaarVe
$routes->post('AbdmGateway/abha_mobile_generate_otp', 'AbdmGateway::abhaMobileGenerateOtp', ['filter' => $abdmPermFilter]);
$routes->post('AbdmGateway/abha_mobile_verify_otp', 'AbdmGateway::abhaMobileVerifyOtp', ['filter' => $abdmPermFilter]);
// ABHA creation wizard — billing/patient ABHA Create tab
$routes->post('abha/create/initiate', 'Abha::initiate');
$routes->post('abha/create/verify_otp', 'Abha::verifyOtp');
$routes->post('abha/create/communication', 'Abha::communication');
$routes->post('abha/create/verify_comm_otp', 'Abha::verifyCommOtp');
$routes->post('abha/create/confirm_patient', 'Abha::confirmPatient');
$routes->post('abha/create/address', 'Abha::address');
$routes->get('abha/card/(:segment)', 'Abha::card/$1');
$routes->post('abha/register/validate', 'Abha::validateAbha');
$routes->get('abha/register/facility_qr', 'Abha::facilityQr');
$routes->post('abha/create/initiate', 'Abha::initiate', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/verify_otp', 'Abha::verifyOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/communication', 'Abha::communication', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/verify_comm_otp', 'Abha::verifyCommOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/confirm_patient', 'Abha::confirmPatient', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/address', 'Abha::address', ['filter' => 'permission:abdm.abha.create']);
$routes->get('abha/card/(:segment)', 'Abha::card/$1', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/register/validate', 'Abha::validateAbha', ['filter' => 'permission:abdm.abha.create']);
$routes->get('abha/register/facility_qr', 'Abha::facilityQr', ['filter' => 'permission:abdm.abha.create']);
$routes->get('AbdmTaskBoard', 'AbdmTaskBoard::index', ['filter' => $abdmPermFilter]);
$routes->get('AbdmTaskBoard/list', 'AbdmTaskBoard::list', ['filter' => $abdmPermFilter]);
$routes->post('AbdmTaskBoard/perform_action', 'AbdmTaskBoard::performAction', ['filter' => $abdmPermFilter]);
@ -925,17 +926,17 @@ $routes->get('Opd_prescription/show_old_Prescribed/(:num)/(:num)', 'Opd_prescrip
$routes->get('Opd_prescription/show_medical_item/(:num)', 'Opd_prescription::show_medical_item/$1');
$routes->get('Opd_prescription/prescribed_dose/(:num)/(:num)', 'Opd_prescription::prescribed_dose/$1/$2');
$routes->get('Invoice/list_req_payment', 'Invoice::list_req_payment');
$routes->get('Invoice/opdlist', 'Invoice::opdlist');
$routes->get('Invoice/chargeslist', 'Invoice::chargeslist');
$routes->post('Invoice/getRequestTable', 'Invoice::getRequestTable');
$routes->get('Invoice/payment_form/(:num)', 'Invoice::payment_form/$1');
$routes->post('Invoice/req_payment_process', 'Invoice::req_payment_process');
$routes->get('Invoice/print_org_payment_invoice/(:num)', 'Invoice::print_org_payment_invoice/$1');
$routes->get('Invoice/list_refund', 'Invoice::list_refund');
$routes->post('Invoice/getRefundTable', 'Invoice::getRefundTable');
$routes->get('Invoice/refund_form/(:num)', 'Invoice::refund_form/$1');
$routes->post('Invoice/refund_process', 'Invoice::refund_process');
$routes->get('Invoice/list_req_payment', 'Invoice::list_req_payment', ['filter' => 'permission:billing.payment_request.view,billing.payment_request.manage']);
$routes->get('Invoice/opdlist', 'Invoice::opdlist', ['filter' => 'permission:billing.access,billing.opd.edit']);
$routes->get('Invoice/chargeslist', 'Invoice::chargeslist', ['filter' => 'permission:billing.charges.view,billing.access']);
$routes->post('Invoice/getRequestTable', 'Invoice::getRequestTable', ['filter' => 'permission:billing.payment_request.view,billing.payment_request.manage']);
$routes->get('Invoice/payment_form/(:num)', 'Invoice::payment_form/$1', ['filter' => 'permission:billing.payment_request.view,billing.payment_request.manage']);
$routes->post('Invoice/req_payment_process', 'Invoice::req_payment_process', ['filter' => 'permission:billing.payment_request.manage']);
$routes->get('Invoice/print_org_payment_invoice/(:num)', 'Invoice::print_org_payment_invoice/$1', ['filter' => 'permission:billing.payment_request.view,billing.payment_request.manage']);
$routes->get('Invoice/list_refund', 'Invoice::list_refund', ['filter' => 'permission:billing.refund.view,billing.refund.manage']);
$routes->post('Invoice/getRefundTable', 'Invoice::getRefundTable', ['filter' => 'permission:billing.refund.view,billing.refund.manage']);
$routes->get('Invoice/refund_form/(:num)', 'Invoice::refund_form/$1', ['filter' => 'permission:billing.refund.view,billing.refund.manage']);
$routes->post('Invoice/refund_process', 'Invoice::refund_process', ['filter' => 'permission:billing.refund.manage']);
$routes->get('Lab_Admin/report_list', 'Setting\Template::report_list');
$routes->get('Lab_Admin/report_ultrasound_list/(:num)', 'Setting\Template::report_ultrasound_list/$1');
@ -988,10 +989,10 @@ $routes->post('Opdcase/showfee', 'Opdcase::showfee');
$routes->post('Opdcase/confirm_opd', 'Opdcase::confirm_opd');
$routes->get('Opdcase/invoice/(:num)', 'Opd::invoice/$1');
$routes->get('Report', 'Report::index');
$routes->get('Report/index', 'Report::index');
$routes->get('report', 'Report::index');
$routes->get('report/index', 'Report::index');
$routes->get('Report', 'Report::index', ['filter' => 'permission:reports.access']);
$routes->get('Report/index', 'Report::index', ['filter' => 'permission:reports.access']);
$routes->get('report', 'Report::index', ['filter' => 'permission:reports.access']);
$routes->get('report/index', 'Report::index', ['filter' => 'permission:reports.access']);
$reportsCollectionFilter = 'permission:reports.collection.view,reports.access';
$routes->get('Report/collection_report', 'Report::collection_report', ['filter' => $reportsCollectionFilter]);
@ -1005,24 +1006,24 @@ $routes->get('Report/report_total_payment_app_show/(:segment)/(:segment)/(:segme
$routes->get('Report/report_total_payment_total_amount_show/(:segment)/(:segment)/(:segment)', 'Report::report_total_payment_total_amount_show/$1/$2/$3', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/report_total_payment_total_amount_show/(:segment)/(:segment)/(:segment)/(:num)', 'Report::report_total_payment_total_amount_show/$1/$2/$3/$4', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/billing_operations_report', 'Report::billing_operations_report');
$routes->get('Report/billing_operations_report_data/(:segment)/(:segment)', 'Report::billing_operations_report_data/$1/$2');
$routes->get('Report/billing_operations_report_data/(:segment)/(:segment)/(:num)', 'Report::billing_operations_report_data/$1/$2/$3');
$routes->get('Report/billing_operations_report', 'Report::billing_operations_report', ['filter' => 'permission:reports.billing_operations.view']);
$routes->get('Report/billing_operations_report_data/(:segment)/(:segment)', 'Report::billing_operations_report_data/$1/$2', ['filter' => 'permission:reports.billing_operations.view']);
$routes->get('Report/billing_operations_report_data/(:segment)/(:segment)/(:num)', 'Report::billing_operations_report_data/$1/$2/$3', ['filter' => 'permission:reports.billing_operations.view']);
$routes->get('Report/document_list', 'Report::document_list');
$routes->get('Report/document_list_data/(:segment)/(:num)', 'Report::document_list_data/$1/$2');
$routes->get('Report/document_list_data/(:segment)/(:num)/(:num)', 'Report::document_list_data/$1/$2/$3');
$routes->get('Report/document_list', 'Report::document_list', ['filter' => 'permission:reports.document_issue.view']);
$routes->get('Report/document_list_data/(:segment)/(:num)', 'Report::document_list_data/$1/$2', ['filter' => 'permission:reports.document_issue.view']);
$routes->get('Report/document_list_data/(:segment)/(:num)/(:num)', 'Report::document_list_data/$1/$2/$3', ['filter' => 'permission:reports.document_issue.view']);
// Legacy Report5 compatibility for Document Issue Report.
$routes->get('Report5/document_list', 'Report::document_list');
$routes->get('Report5/document_list_data/(:segment)/(:num)', 'Report::document_list_data/$1/$2');
$routes->get('Report5/document_list_data/(:segment)/(:num)/(:num)', 'Report::document_list_data/$1/$2/$3');
$routes->get('Report5/document_list', 'Report::document_list', ['filter' => 'permission:reports.document_issue.view']);
$routes->get('Report5/document_list_data/(:segment)/(:num)', 'Report::document_list_data/$1/$2', ['filter' => 'permission:reports.document_issue.view']);
$routes->get('Report5/document_list_data/(:segment)/(:num)/(:num)', 'Report::document_list_data/$1/$2/$3', ['filter' => 'permission:reports.document_issue.view']);
$routes->get('Report/diagnosis_report', 'Report::diagnosis_report', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)', 'Report::diagnosis_report_data/$1');
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2');
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2/$3');
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::diagnosis_report_data/$1/$2/$3/$4');
$routes->get('Report/diagnosis_report_data/(:segment)', 'Report::diagnosis_report_data/$1', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2/$3', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::diagnosis_report_data/$1/$2/$3/$4', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$reportsNabhFilter = 'permission:reports.nabh_audit.view,reports.access';
$routes->get('Report/nabh_audit_report', 'Report::nabh_audit_report', ['filter' => $reportsNabhFilter]);
@ -1049,12 +1050,12 @@ $routes->get('Report/ayushman_case_dashboard_data/(:segment)/(:segment)', 'Repor
$routes->get('Report/ayushman_case_dashboard_data/(:segment)/(:segment)/(:num)', 'Report::ayushman_case_dashboard_data/$1/$2/$3', ['filter' => $reportsInsuranceFilter]);
// Legacy Report4 compatibility for Insurance Case reports
$routes->get('Report4/echs_ipd_list_main', 'Report::echs_ipd_list_main');
$routes->get('Report4/echs_opd_list_main', 'Report::echs_opd_list_main');
$routes->get('Report4/echs_ipd_list_data/(:segment)/(:segment)/(:segment)', 'Report::echs_ipd_list_data/$1/$2/$3');
$routes->get('Report4/echs_ipd_list_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::echs_ipd_list_data/$1/$2/$3/$4');
$routes->get('Report4/echs_opd_list_data/(:segment)/(:segment)/(:segment)', 'Report::echs_opd_list_data/$1/$2/$3');
$routes->get('Report4/echs_opd_list_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::echs_opd_list_data/$1/$2/$3/$4');
$routes->get('Report4/echs_ipd_list_main', 'Report::echs_ipd_list_main', ['filter' => $reportsInsuranceFilter]);
$routes->get('Report4/echs_opd_list_main', 'Report::echs_opd_list_main', ['filter' => $reportsInsuranceFilter]);
$routes->get('Report4/echs_ipd_list_data/(:segment)/(:segment)/(:segment)', 'Report::echs_ipd_list_data/$1/$2/$3', ['filter' => $reportsInsuranceFilter]);
$routes->get('Report4/echs_ipd_list_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::echs_ipd_list_data/$1/$2/$3/$4', ['filter' => $reportsInsuranceFilter]);
$routes->get('Report4/echs_opd_list_data/(:segment)/(:segment)/(:segment)', 'Report::echs_opd_list_data/$1/$2/$3', ['filter' => $reportsInsuranceFilter]);
$routes->get('Report4/echs_opd_list_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::echs_opd_list_data/$1/$2/$3/$4', ['filter' => $reportsInsuranceFilter]);
// Organization Packing Routes
$routes->get('org-packing', 'OrgPacking::index');
@ -1195,15 +1196,25 @@ $routes->group('setting', static function($routes) {
$settingsBedFilter = 'permission:settings.bed_status.view,admin.settings,admin.access';
$settingsChargesFilter = 'permission:settings.charges.access,admin.settings,admin.access';
$routes->get('admin', 'Setting\\Admin::index', ['filter' => $settingsAdminFilter]);
$routes->get('admin/user-management', 'Setting\\UserManagement::index');
$routes->get('admin/user-management/new', 'Setting\\UserManagement::create');
$routes->post('admin/user-management/new', 'Setting\\UserManagement::store');
$routes->get('admin/user-management/edit/(:num)', 'Setting\\UserManagement::edit/$1');
$routes->post('admin/user-management/edit/(:num)', 'Setting\\UserManagement::update/$1');
$routes->get('admin/user-management/reset-password/(:num)', 'Setting\\UserManagement::resetPasswordForm/$1');
$routes->post('admin/user-management/reset-password/(:num)', 'Setting\\UserManagement::resetPassword/$1');
$routes->get('admin/user-management/permissions', 'Setting\\UserManagement::permissions');
$routes->post('admin/user-management/permissions', 'Setting\\UserManagement::savePermissions');
$routes->get('admin/user-management', 'Setting\\UserManagement::index', ['filter' => $settingsAdminFilter]);
$routes->get('admin/user-management/new', 'Setting\\UserManagement::create', ['filter' => 'permission:users.create,users.manage-admins']);
$routes->post('admin/user-management/new', 'Setting\\UserManagement::store', ['filter' => ['permission:users.create,users.manage-admins', 'csrf']]);
$routes->get('admin/user-management/edit/(:num)', 'Setting\\UserManagement::edit/$1', ['filter' => 'permission:users.edit,users.manage-admins']);
$routes->post('admin/user-management/edit/(:num)', 'Setting\\UserManagement::update/$1', ['filter' => ['permission:users.edit,users.manage-admins', 'csrf']]);
$routes->get('admin/user-management/reset-password/(:num)', 'Setting\\UserManagement::resetPasswordForm/$1', ['filter' => 'permission:users.edit,users.manage-admins']);
$routes->post('admin/user-management/reset-password/(:num)', 'Setting\\UserManagement::resetPassword/$1', ['filter' => ['permission:users.edit,users.manage-admins', 'csrf']]);
$routes->get('admin/user-management/permissions', 'Setting\\UserManagement::permissions', ['filter' => 'permission:users.manage-admins']);
$routes->post('admin/user-management/permissions', 'Setting\\UserManagement::savePermissions', ['filter' => ['permission:users.manage-admins', 'csrf']]);
$routes->get('admin/user-management/sessions', 'Setting\\UserManagement::sessions', ['filter' => 'permission:users.edit,users.manage-admins']);
$routes->post('admin/user-management/sessions/force-logout/(:num)', 'Setting\\UserManagement::forceLogout/$1', ['filter' => ['permission:users.edit,users.manage-admins', 'csrf']]);
$routes->post('admin/user-management/delete/(:num)', 'Setting\\UserManagement::delete/$1', ['filter' => ['permission:users.delete,users.manage-admins', 'csrf']]);
$routes->get('admin/roles', 'Setting\\RoleManagement::index', ['filter' => 'permission:users.manage-admins']);
$routes->get('admin/roles/new', 'Setting\\RoleManagement::create', ['filter' => 'permission:users.manage-admins']);
$routes->post('admin/roles', 'Setting\\RoleManagement::store', ['filter' => ['permission:users.manage-admins', 'csrf']]);
$routes->get('admin/roles/edit/(:num)', 'Setting\\RoleManagement::edit/$1', ['filter' => 'permission:users.manage-admins']);
$routes->post('admin/roles/edit/(:num)', 'Setting\\RoleManagement::update/$1', ['filter' => ['permission:users.manage-admins', 'csrf']]);
$routes->post('admin/roles/status/(:num)', 'Setting\\RoleManagement::status/$1', ['filter' => ['permission:users.manage-admins', 'csrf']]);
$routes->post('admin/roles/delete/(:num)', 'Setting\\RoleManagement::delete/$1', ['filter' => ['permission:users.manage-admins', 'csrf']]);
$routes->get('admin/ai-settings', 'Setting\\AiSettings::index');
$routes->post('admin/ai-settings/save', 'Setting\\AiSettings::save');
$routes->post('admin/ai-settings/test', 'Setting\\AiSettings::test');
@ -1323,7 +1334,7 @@ $routes->group('setting', static function($routes) {
$routes->get('charges', 'Setting\\Charges::index', ['filter' => $settingsChargesFilter]);
});
$routes->group('item', static function($routes) {
$routes->group('item', ['filter' => 'permission:billing.items.view,billing.items.manage,billing.access,settings.charges.access,admin.settings'], static function($routes) {
$routes->get('search', 'Item::search');
$routes->get('search-itemtype', 'Item::searchItemType');
$routes->get('search-adv/(:num)', 'Item::searchAdv/$1');
@ -1331,17 +1342,17 @@ $routes->group('item', static function($routes) {
$routes->get('export-excel/(:num)', 'Item::exportExcel/$1');
$routes->match(['GET', 'POST'], 'item-record/(:num)', 'Item::itemRecord/$1');
$routes->get('itemtype-record/(:num)', 'Item::itemTypeRecord/$1');
$routes->get('add', 'Item::addRecord');
$routes->get('add-type', 'Item::addItemTypeRecord');
$routes->post('create', 'Item::createRecord');
$routes->post('update', 'Item::updateRecord');
$routes->post('create-type', 'Item::createItemTypeRecord');
$routes->post('update-type', 'Item::updateItemTypeRecord');
$routes->post('insurance/add', 'Item::addInsuranceItemRecord');
$routes->post('insurance/remove', 'Item::removeInsuranceItem');
$routes->get('add', 'Item::addRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->get('add-type', 'Item::addItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('create', 'Item::createRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('update', 'Item::updateRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('create-type', 'Item::createItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('update-type', 'Item::updateItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('insurance/add', 'Item::addInsuranceItemRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('insurance/remove', 'Item::removeInsuranceItem', ['filter' => 'permission:billing.items.manage,admin.settings']);
});
$routes->group('item-ipd', static function($routes) {
$routes->group('item-ipd', ['filter' => 'permission:billing.items.view,billing.items.manage,billing.access,settings.charges.access,admin.settings'], static function($routes) {
$routes->get('search', 'ItemIpd::search');
$routes->get('search-itemtype', 'ItemIpd::searchItemType');
$routes->get('search-adv/(:num)', 'ItemIpd::searchAdv/$1');
@ -1349,19 +1360,19 @@ $routes->group('item-ipd', static function($routes) {
$routes->get('export-excel/(:num)', 'ItemIpd::exportExcel/$1');
$routes->match(['GET', 'POST'], 'item-record/(:num)', 'ItemIpd::itemRecord/$1');
$routes->get('itemtype-record/(:num)', 'ItemIpd::itemTypeRecord/$1');
$routes->get('add', 'ItemIpd::addRecord');
$routes->get('add-type', 'ItemIpd::addItemTypeRecord');
$routes->post('create', 'ItemIpd::createRecord');
$routes->post('update', 'ItemIpd::updateRecord');
$routes->post('create-type', 'ItemIpd::createItemTypeRecord');
$routes->post('update-type', 'ItemIpd::updateItemTypeRecord');
$routes->post('delete-type', 'ItemIpd::deleteItemTypeRecord');
$routes->post('change-sort-type', 'ItemIpd::changeItemTypeSort');
$routes->post('insurance/add', 'ItemIpd::addInsuranceItemRecord');
$routes->post('insurance/remove', 'ItemIpd::removeInsuranceItem');
$routes->get('add', 'ItemIpd::addRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->get('add-type', 'ItemIpd::addItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('create', 'ItemIpd::createRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('update', 'ItemIpd::updateRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('create-type', 'ItemIpd::createItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('update-type', 'ItemIpd::updateItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('delete-type', 'ItemIpd::deleteItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('change-sort-type', 'ItemIpd::changeItemTypeSort', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('insurance/add', 'ItemIpd::addInsuranceItemRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('insurance/remove', 'ItemIpd::removeInsuranceItem', ['filter' => 'permission:billing.items.manage,admin.settings']);
});
$routes->group('package', static function($routes) {
$routes->group('package', ['filter' => 'permission:billing.packages.view,billing.packages.manage,billing.access,settings.charges.access,admin.settings'], static function($routes) {
$routes->get('search', 'Package::search');
$routes->get('search-itemtype', 'Package::searchItemType');
$routes->get('search-adv/(:num)', 'Package::searchAdv/$1');
@ -1369,17 +1380,17 @@ $routes->group('package', static function($routes) {
$routes->get('export-excel/(:num)', 'Package::exportExcel/$1');
$routes->match(['GET', 'POST'], 'item-record/(:num)', 'Package::itemRecord/$1');
$routes->get('itemtype-record/(:num)', 'Package::itemTypeRecord/$1');
$routes->get('add', 'Package::addRecord');
$routes->get('add-type', 'Package::addItemTypeRecord');
$routes->post('create', 'Package::createRecord');
$routes->post('update', 'Package::updateRecord');
$routes->post('create-type', 'Package::createItemTypeRecord');
$routes->post('update-type', 'Package::updateItemTypeRecord');
$routes->post('insurance/add', 'Package::addInsuranceItemRecord');
$routes->post('insurance/remove', 'Package::removeInsuranceItem');
$routes->get('add', 'Package::addRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->get('add-type', 'Package::addItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('create', 'Package::createRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('update', 'Package::updateRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('create-type', 'Package::createItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('update-type', 'Package::updateItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('insurance/add', 'Package::addInsuranceItemRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('insurance/remove', 'Package::removeInsuranceItem', ['filter' => 'permission:billing.packages.manage,admin.settings']);
});
$routes->group('Package', static function($routes) {
$routes->group('Package', ['filter' => 'permission:billing.packages.view,billing.packages.manage,billing.access,settings.charges.access,admin.settings'], static function($routes) {
$routes->get('search', 'Package::search');
$routes->get('search_itemtype', 'Package::searchItemType');
$routes->get('search_adv/(:num)', 'Package::searchAdv/$1');
@ -1387,17 +1398,17 @@ $routes->group('Package', static function($routes) {
$routes->get('export-excel/(:num)', 'Package::exportExcel/$1');
$routes->match(['GET', 'POST'], 'item_record/(:num)', 'Package::itemRecord/$1');
$routes->get('itemtype_record/(:num)', 'Package::itemTypeRecord/$1');
$routes->get('AddRecord', 'Package::addRecord');
$routes->get('AddItemTypeRecord', 'Package::addItemTypeRecord');
$routes->post('CreateRecord', 'Package::createRecord');
$routes->post('UpdateRecord', 'Package::updateRecord');
$routes->post('CreateItemTypeRecord', 'Package::createItemTypeRecord');
$routes->post('UpdateItemTypeRecord', 'Package::updateItemTypeRecord');
$routes->post('AddInsuranceItemRecord', 'Package::addInsuranceItemRecord');
$routes->post('remove_record_item', 'Package::removeInsuranceItem');
$routes->get('AddRecord', 'Package::addRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->get('AddItemTypeRecord', 'Package::addItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('CreateRecord', 'Package::createRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('UpdateRecord', 'Package::updateRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('CreateItemTypeRecord', 'Package::createItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('UpdateItemTypeRecord', 'Package::updateItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('AddInsuranceItemRecord', 'Package::addInsuranceItemRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('remove_record_item', 'Package::removeInsuranceItem', ['filter' => 'permission:billing.packages.manage,admin.settings']);
});
$routes->group('IPD_Package', static function($routes) {
$routes->group('IPD_Package', ['filter' => 'permission:billing.packages.view,billing.packages.manage,billing.access,settings.charges.access,admin.settings'], static function($routes) {
$routes->get('search', 'Package::search');
$routes->get('search_itemtype', 'Package::searchItemType');
$routes->get('search_adv/(:num)', 'Package::searchAdv/$1');
@ -1405,17 +1416,17 @@ $routes->group('IPD_Package', static function($routes) {
$routes->get('export-excel/(:num)', 'Package::exportExcel/$1');
$routes->match(['GET', 'POST'], 'item_record/(:num)', 'Package::itemRecord/$1');
$routes->get('itemtype_record/(:num)', 'Package::itemTypeRecord/$1');
$routes->get('AddRecord', 'Package::addRecord');
$routes->get('AddItemTypeRecord', 'Package::addItemTypeRecord');
$routes->post('CreateRecord', 'Package::createRecord');
$routes->post('UpdateRecord', 'Package::updateRecord');
$routes->post('CreateItemTypeRecord', 'Package::createItemTypeRecord');
$routes->post('UpdateItemTypeRecord', 'Package::updateItemTypeRecord');
$routes->post('AddInsuranceItemRecord', 'Package::addInsuranceItemRecord');
$routes->post('remove_record_item', 'Package::removeInsuranceItem');
$routes->get('AddRecord', 'Package::addRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->get('AddItemTypeRecord', 'Package::addItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('CreateRecord', 'Package::createRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('UpdateRecord', 'Package::updateRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('CreateItemTypeRecord', 'Package::createItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('UpdateItemTypeRecord', 'Package::updateItemTypeRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('AddInsuranceItemRecord', 'Package::addInsuranceItemRecord', ['filter' => 'permission:billing.packages.manage,admin.settings']);
$routes->post('remove_record_item', 'Package::removeInsuranceItem', ['filter' => 'permission:billing.packages.manage,admin.settings']);
});
$routes->group('Item_IPD', static function($routes) {
$routes->group('Item_IPD', ['filter' => 'permission:billing.items.view,billing.items.manage,billing.access,settings.charges.access,admin.settings'], static function($routes) {
$routes->get('search', 'ItemIpd::search');
$routes->get('search_itemtype', 'ItemIpd::searchItemType');
$routes->get('search_adv/(:num)', 'ItemIpd::searchAdv/$1');
@ -1423,13 +1434,13 @@ $routes->group('Item_IPD', static function($routes) {
$routes->get('export-excel/(:num)', 'ItemIpd::exportExcel/$1');
$routes->match(['GET', 'POST'], 'item_record/(:num)', 'ItemIpd::itemRecord/$1');
$routes->get('itemtype_record/(:num)', 'ItemIpd::itemTypeRecord/$1');
$routes->get('AddRecord', 'ItemIpd::addRecord');
$routes->get('AddItemTypeRecord', 'ItemIpd::addItemTypeRecord');
$routes->post('CreateRecord', 'ItemIpd::createRecord');
$routes->post('UpdateRecord', 'ItemIpd::updateRecord');
$routes->post('CreateItemTypeRecord', 'ItemIpd::createItemTypeRecord');
$routes->post('UpdateItemTypeRecord', 'ItemIpd::updateItemTypeRecord');
$routes->post('change_sort_itemtype', 'ItemIpd::changeItemTypeSort');
$routes->post('AddInsuranceItemRecord', 'ItemIpd::addInsuranceItemRecord');
$routes->post('remove_record_item', 'ItemIpd::removeInsuranceItem');
$routes->get('AddRecord', 'ItemIpd::addRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->get('AddItemTypeRecord', 'ItemIpd::addItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('CreateRecord', 'ItemIpd::createRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('UpdateRecord', 'ItemIpd::updateRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('CreateItemTypeRecord', 'ItemIpd::createItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('UpdateItemTypeRecord', 'ItemIpd::updateItemTypeRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('change_sort_itemtype', 'ItemIpd::changeItemTypeSort', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('AddInsuranceItemRecord', 'ItemIpd::addInsuranceItemRecord', ['filter' => 'permission:billing.items.manage,admin.settings']);
$routes->post('remove_record_item', 'ItemIpd::removeInsuranceItem', ['filter' => 'permission:billing.items.manage,admin.settings']);
});

View File

@ -0,0 +1,21 @@
<?php
namespace App\Controllers;
class AuthSession extends BaseController
{
public function status()
{
if (! function_exists('auth') || ! auth()->loggedIn()) {
return $this->response->setStatusCode(401)->setJSON([
'active' => false,
'login_url' => base_url('login'),
]);
}
return $this->response->setJSON([
'active' => true,
'checked_at' => date('Y-m-d H:i:s'),
]);
}
}

View File

@ -1794,6 +1794,13 @@ class Finance extends BaseController
'finance.bank.deposit.create',
'finance.bank.audit',
'finance.bank.statement.update',
'finance.access',
'finance.compliance.view',
'finance.doctor_payout.manage',
'finance.vendor.manage',
'finance.po.manage',
'finance.grn.manage',
'finance.invoice.manage',
])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Controllers\Setting;
use App\Controllers\BaseController;
use App\Libraries\RoleRegistry;
use InvalidArgumentException;
use Throwable;
class RoleManagement extends BaseController
{
public function index(): string
{
return $this->listView();
}
public function create(): string
{
return $this->formView();
}
public function store(): string
{
return $this->save(null);
}
public function edit(int $roleId): string
{
$role = (new RoleRegistry($this->db))->find($roleId);
if ($role === null) {
return $this->listView(null, ['Role not found.']);
}
return $this->formView($role);
}
public function update(int $roleId): string
{
return $this->save($roleId);
}
public function status(int $roleId)
{
$registry = new RoleRegistry($this->db);
$role = $registry->find($roleId);
try {
if ($role === null) {
throw new InvalidArgumentException('Role not found.');
}
$active = (int) $this->request->getPost('is_active') === 1;
$registry->setActive($roleId, $active, $this->currentUserId());
$this->auditClinicalUpdate('role_master', 'status', $roleId, ['is_active' => (int) $role['is_active']], ['is_active' => $active ? 1 : 0]);
return $this->response->setJSON(['update' => 1, 'message' => $active ? 'Role activated.' : 'Role deactivated.', 'csrf_hash' => csrf_hash()]);
} catch (Throwable $e) {
return $this->response->setStatusCode(422)->setJSON(['update' => 0, 'error_text' => $e->getMessage(), 'csrf_hash' => csrf_hash()]);
}
}
public function delete(int $roleId)
{
$registry = new RoleRegistry($this->db);
$role = $registry->find($roleId);
try {
if ($role === null) {
throw new InvalidArgumentException('Role not found.');
}
$registry->delete($roleId);
$this->auditClinicalUpdate('role_master', 'delete', $roleId, $role, ['deleted' => true]);
return $this->response->setJSON(['update' => 1, 'message' => 'Role deleted.', 'csrf_hash' => csrf_hash()]);
} catch (Throwable $e) {
return $this->response->setStatusCode(422)->setJSON(['update' => 0, 'error_text' => $e->getMessage(), 'csrf_hash' => csrf_hash()]);
}
}
private function save(?int $roleId): string
{
$registry = new RoleRegistry($this->db);
$oldRole = $roleId ? $registry->find($roleId) : null;
$input = [
'alias' => $oldRole['alias'] ?? $this->request->getPost('alias'),
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'permissions' => (array) $this->request->getPost('permissions'),
];
try {
$savedId = $registry->save($roleId, $input, $this->currentUserId());
$newRole = $registry->find($savedId);
$this->auditClinicalUpdate('role_master', $roleId ? 'update' : 'create', $savedId, $oldRole, $newRole);
return $this->listView($roleId ? 'Role updated successfully.' : 'Role created successfully.');
} catch (Throwable $e) {
$role = array_merge($oldRole ?? [], $input);
return $this->formView($role, [$e->getMessage()]);
}
}
private function listView(?string $message = null, array $errors = []): string
{
return view('Setting/Admin/RoleManagement/index', [
'roles' => (new RoleRegistry($this->db))->all(),
'message' => $message,
'errors' => $errors,
]);
}
private function formView(?array $role = null, array $errors = []): string
{
$permissions = (array) setting('AuthGroups.permissions');
$wildcards = [];
foreach (array_keys($permissions) as $permission) {
$scope = strstr($permission, '.', true);
if ($scope !== false) {
$wildcards[$scope . '.*'] = 'All ' . ucwords(str_replace('_', ' ', $scope)) . ' permissions';
}
}
return view('Setting/Admin/RoleManagement/form', [
'role' => $role,
'permissions' => $permissions,
'wildcards' => $wildcards,
'errors' => $errors,
]);
}
private function currentUserId(): int
{
return function_exists('auth') ? (int) (auth()->user()->id ?? 0) : 0;
}
}

View File

@ -3,11 +3,39 @@
namespace App\Controllers\Setting;
use App\Controllers\BaseController;
use App\Libraries\UserSessionRegistry;
use CodeIgniter\Shield\Entities\User;
use CodeIgniter\Shield\Models\UserModel;
class UserManagement extends BaseController
{
private function currentUserId(): int
{
return function_exists('auth') ? (int) (auth()->user()->id ?? 0) : 0;
}
private function canManageAdmins(): bool
{
return function_exists('auth') && auth()->loggedIn() && auth()->user()->can('users.manage-admins');
}
private function availableRoles(): array
{
$roles = (array) setting('AuthGroups.groups');
if (! $this->canManageAdmins()) {
foreach (['superadmin', 'admin', 'developer'] as $privilegedRole) {
unset($roles[$privilegedRole]);
}
}
return $roles;
}
private function isPrivilegedUser(User $user): bool
{
return $user->inGroup('superadmin', 'admin', 'developer') || $user->can('users.manage-admins');
}
public function index(): string
{
$userModel = model(UserModel::class);
@ -25,23 +53,25 @@ class UserManagement extends BaseController
public function create(): string
{
return view('Setting/Admin/UserManagement/create', [
'roles' => setting('AuthGroups.groups'),
'roles' => $this->availableRoles(),
]);
}
public function store()
{
$rules = [
'username' => 'required|min_length[3]|max_length[30]',
'username' => 'required|min_length[3]|max_length[30]|regex_match[/^[a-zA-Z0-9.]+$/]',
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|min_length[8]',
'role' => 'required',
'person_name' => 'permit_empty|max_length[120]',
'phone_no' => 'permit_empty|regex_match[/^[0-9+()\\s-]{7,20}$/]',
];
if (! $this->validate($rules)) {
if ($this->request->isAJAX()) {
return view('Setting/Admin/UserManagement/create', [
'roles' => setting('AuthGroups.groups'),
'roles' => $this->availableRoles(),
'errors' => $this->validator->getErrors(),
'formData' => $this->request->getPost(),
]);
@ -51,12 +81,12 @@ class UserManagement extends BaseController
}
$role = strtolower((string) $this->request->getPost('role'));
$allowedRoles = array_keys(setting('AuthGroups.groups'));
$allowedRoles = array_keys($this->availableRoles());
if (! in_array($role, $allowedRoles, true)) {
$errors = ['role' => 'Invalid role selected.'];
if ($this->request->isAJAX()) {
return view('Setting/Admin/UserManagement/create', [
'roles' => setting('AuthGroups.groups'),
'roles' => $this->availableRoles(),
'errors' => $errors,
'formData' => $this->request->getPost(),
]);
@ -72,11 +102,31 @@ class UserManagement extends BaseController
$user->active = 1;
$userModel = model(UserModel::class);
$userModel->save($user);
try {
if (! $userModel->save($user)) {
throw new \RuntimeException(implode(' ', $userModel->errors()));
}
} catch (\Throwable $e) {
return view('Setting/Admin/UserManagement/create', [
'roles' => $this->availableRoles(),
'errors' => ['user' => $e->getMessage() !== '' ? $e->getMessage() : 'Unable to create user.'],
'formData' => $this->request->getPost(),
]);
}
$savedUser = $userModel->find($userModel->getInsertID());
if ($savedUser !== null) {
$savedUser->addGroup($role);
$this->syncIdentityMeta(
(int) $savedUser->id,
trim((string) $this->request->getPost('person_name')),
trim((string) $this->request->getPost('phone_no'))
);
$this->auditClinicalUpdate('user_management', 'create', $savedUser->id, null, [
'username' => $savedUser->username,
'role' => $role,
'active' => 1,
]);
}
if ($this->request->isAJAX()) {
@ -111,10 +161,14 @@ class UserManagement extends BaseController
$selectedUser = $userModel->withPermissions()->find($selectedUserId);
}
$roleContext = $this->rolePermissionContext($selectedUser);
return view('Setting/Admin/UserManagement/permissions', [
'users' => $users,
'permissions' => setting('AuthGroups.permissions'),
'selectedUser' => $selectedUser,
'selectedRoleTitles' => $roleContext['titles'],
'inheritedPermissions' => $roleContext['permissions'],
]);
}
@ -138,6 +192,13 @@ class UserManagement extends BaseController
]);
}
if ($this->isPrivilegedUser($user) && ! $this->canManageAdmins()) {
return view('Setting/Admin/UserManagement/index', [
'users' => $userModel->withIdentities()->withGroups()->findAll(),
'errors' => ['You cannot edit an administrator account.'],
]);
}
$emailIdentity = $user->getEmailIdentity();
$email = $emailIdentity ? (string) ($emailIdentity->secret ?? '') : '';
$meta = $this->decodeIdentityExtra($emailIdentity->extra ?? null);
@ -147,13 +208,15 @@ class UserManagement extends BaseController
'email' => $email,
'person_name' => trim((string) ($meta['full_name'] ?? '')),
'phone_no' => trim((string) ($meta['phone_no'] ?? '')),
'roles' => $this->availableRoles(),
'current_role' => (string) (($user->getGroups() ?? [])[0] ?? ''),
]);
}
public function update(int $userId)
{
$userModel = model(UserModel::class);
$user = $userModel->find($userId);
$user = $userModel->withGroups()->find($userId);
if ($user === null) {
$users = $userModel
->withIdentities()
@ -166,12 +229,26 @@ class UserManagement extends BaseController
]);
}
if ($this->isPrivilegedUser($user) && ! $this->canManageAdmins()) {
return view('Setting/Admin/UserManagement/index', [
'users' => $userModel->withIdentities()->withGroups()->findAll(),
'errors' => ['You cannot edit an administrator account.'],
]);
}
$username = trim((string) $this->request->getPost('username'));
$email = trim((string) $this->request->getPost('email'));
$personName = trim((string) $this->request->getPost('person_name'));
$phoneNo = trim((string) $this->request->getPost('phone_no'));
$password = (string) $this->request->getPost('password');
$active = $this->request->getPost('active') ? 1 : 0;
$role = strtolower(trim((string) $this->request->getPost('role')));
$oldGroups = $user->getGroups() ?? [];
$oldState = [
'username' => (string) ($user->username ?? ''),
'groups' => $oldGroups,
'active' => (int) ($user->active ?? 0),
];
$errors = [];
if ($username === '' || strlen($username) < 3 || strlen($username) > 30 || preg_match('/\A[a-zA-Z0-9\.]+\z/', $username) !== 1) {
@ -189,6 +266,12 @@ class UserManagement extends BaseController
if ($password !== '' && strlen($password) < 8) {
$errors['password'] = 'Password must be at least 8 characters.';
}
if (! array_key_exists($role, $this->availableRoles())) {
$errors['role'] = 'Invalid role selected.';
}
if ($userId === $this->currentUserId() && $active !== 1) {
$errors['active'] = 'You cannot deactivate your own account.';
}
$tables = config('Auth')->tables;
$usersTable = (string) ($tables['users'] ?? 'users');
@ -223,6 +306,8 @@ class UserManagement extends BaseController
'email' => $email,
'person_name' => $personName,
'phone_no' => $phoneNo,
'roles' => $this->availableRoles(),
'current_role' => $role,
'errors' => $errors,
'formData' => [
'username' => $username,
@ -230,6 +315,7 @@ class UserManagement extends BaseController
'person_name' => $personName,
'phone_no' => $phoneNo,
'active' => $active,
'role' => $role,
],
]);
}
@ -242,7 +328,17 @@ class UserManagement extends BaseController
}
$userModel->save($user);
$user->syncGroups($role);
$this->syncIdentityMeta($userId, $personName, $phoneNo);
$this->auditClinicalUpdate('user_management', 'update', $userId, $oldState, [
'username' => $username,
'groups' => [$role],
'active' => $active,
]);
if ($active !== 1) {
(new UserSessionRegistry($this->db))->revokeUser($userId, $this->currentUserId(), 'account_deactivated');
}
$users = $userModel
->withIdentities()
@ -272,6 +368,13 @@ class UserManagement extends BaseController
]);
}
if ($this->isPrivilegedUser($user) && ! $this->canManageAdmins()) {
return view('Setting/Admin/UserManagement/index', [
'users' => $userModel->withIdentities()->withGroups()->findAll(),
'errors' => ['You cannot reset an administrator password.'],
]);
}
return view('Setting/Admin/UserManagement/reset_password', [
'user' => $user,
]);
@ -280,7 +383,7 @@ class UserManagement extends BaseController
public function resetPassword(int $userId)
{
$userModel = model(UserModel::class);
$user = $userModel->find($userId);
$user = $userModel->withGroups()->find($userId);
if ($user === null) {
$users = $userModel
->withIdentities()
@ -292,6 +395,12 @@ class UserManagement extends BaseController
'errors' => ['User not found.'],
]);
}
if ($this->isPrivilegedUser($user) && ! $this->canManageAdmins()) {
return view('Setting/Admin/UserManagement/index', [
'users' => $userModel->withIdentities()->withGroups()->findAll(),
'errors' => ['You cannot reset an administrator password.'],
]);
}
$password = trim((string) $this->request->getPost('password'));
$passwordConfirm = trim((string) $this->request->getPost('password_confirm'));
@ -352,6 +461,8 @@ class UserManagement extends BaseController
'force_reset' => 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
(new UserSessionRegistry($this->db))->revokeUser($userId, $this->currentUserId(), 'password_reset');
$this->auditClinicalUpdate('user_management', 'password_reset', $userId, null, ['force_reset' => 1]);
$users = $userModel
->withIdentities()
@ -473,7 +584,9 @@ class UserManagement extends BaseController
return redirect()->back()->withInput()->with('errors', $errors);
}
$oldPermissions = $userModel->withPermissions()->find($userId)?->getPermissions() ?? [];
$user->syncPermissions(...$permissions);
$this->auditClinicalUpdate('user_management', 'permissions', $userId, $oldPermissions, $permissions);
if ($this->request->isAJAX()) {
$users = $userModel
@ -482,11 +595,14 @@ class UserManagement extends BaseController
->findAll();
$selectedUser = $userModel->withPermissions()->find($userId);
$roleContext = $this->rolePermissionContext($selectedUser);
return view('Setting/Admin/UserManagement/permissions', [
'users' => $users,
'permissions' => setting('AuthGroups.permissions'),
'selectedUser' => $selectedUser,
'selectedRoleTitles' => $roleContext['titles'],
'inheritedPermissions' => $roleContext['permissions'],
'message' => 'Permissions updated successfully.',
]);
}
@ -495,4 +611,117 @@ class UserManagement extends BaseController
->to('setting/admin/user-management/permissions?user_id=' . $userId)
->with('message', 'Permissions updated successfully.');
}
/**
* @return array{titles: list<string>, permissions: list<string>}
*/
private function rolePermissionContext(?User $user): array
{
if ($user === null) {
return ['titles' => [], 'permissions' => []];
}
$availablePermissions = array_keys((array) setting('AuthGroups.permissions'));
$groups = (array) setting('AuthGroups.groups');
$matrix = (array) setting('AuthGroups.matrix');
$inheritedPermissions = [];
$roleTitles = [];
foreach ($user->getGroups() ?? [] as $role) {
$roleTitles[] = (string) ($groups[$role]['title'] ?? $role);
foreach ($matrix[$role] ?? [] as $grant) {
if (str_ends_with($grant, '.*')) {
$prefix = substr($grant, 0, -1);
foreach ($availablePermissions as $permission) {
if (str_starts_with($permission, $prefix)) {
$inheritedPermissions[] = $permission;
}
}
} elseif (in_array($grant, $availablePermissions, true)) {
$inheritedPermissions[] = $grant;
}
}
}
return [
'titles' => array_values(array_unique($roleTitles)),
'permissions' => array_values(array_unique($inheritedPermissions)),
];
}
public function sessions(): string
{
$registry = new UserSessionRegistry($this->db);
$registry->cleanup();
$rows = $registry->onlineUsers();
foreach ($rows as &$row) {
$meta = $this->decodeIdentityExtra($row['extra'] ?? null);
$row['person_name'] = trim((string) ($meta['full_name'] ?? ''));
unset($row['extra']);
}
unset($row);
return view('Setting/Admin/UserManagement/sessions', [
'sessions' => $rows,
'current_user_id' => $this->currentUserId(),
]);
}
public function forceLogout(int $userId)
{
if ($userId === $this->currentUserId()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Use Sign Out to end your own session.']);
}
$user = model(UserModel::class)->withGroups()->find($userId);
if ($user === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'User not found.']);
}
if ($this->isPrivilegedUser($user) && ! $this->canManageAdmins()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'You cannot log out an administrator.']);
}
$count = (new UserSessionRegistry($this->db))->revokeUser($userId, $this->currentUserId());
$this->auditClinicalUpdate('user_management', 'force_logout', $userId, null, ['sessions_revoked' => $count]);
return $this->response->setJSON([
'update' => 1,
'error_text' => $count > 0 ? 'User session ended.' : 'No active session found.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function delete(int $userId)
{
if ($userId === $this->currentUserId()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'You cannot delete your own account.']);
}
$userModel = model(UserModel::class);
$user = $userModel->withGroups()->find($userId);
if ($user === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'User not found.']);
}
if ($this->isPrivilegedUser($user) && ! $this->canManageAdmins()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'You cannot delete an administrator.']);
}
(new UserSessionRegistry($this->db))->revokeUser($userId, $this->currentUserId(), 'account_deleted');
$this->auditClinicalUpdate('user_management', 'delete', $userId, [
'username' => $user->username,
'groups' => $user->getGroups() ?? [],
], ['deleted' => true]);
if (! $userModel->delete($userId)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to delete user.']);
}
return $this->response->setJSON([
'update' => 1,
'error_text' => 'User deleted successfully.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateUserActiveSessionsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true],
'user_id' => ['type' => 'INT', 'unsigned' => true],
'session_token' => ['type' => 'CHAR', 'constraint' => 64],
'session_id_hash' => ['type' => 'CHAR', 'constraint' => 64],
'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => true],
'user_agent' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'login_at' => ['type' => 'DATETIME'],
'last_activity' => ['type' => 'DATETIME'],
'logout_at' => ['type' => 'DATETIME', 'null' => true],
'is_active' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1],
'revoked_reason' => ['type' => 'VARCHAR', 'constraint' => 60, 'null' => true],
'revoked_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->addUniqueKey('session_token', 'uk_user_active_session_token');
$this->forge->addKey(['user_id', 'is_active'], false, false, 'idx_user_active_sessions_user');
$this->forge->addKey(['is_active', 'last_activity'], false, false, 'idx_user_active_sessions_online');
$this->forge->createTable('user_active_sessions', true);
}
public function down()
{
$this->forge->dropTable('user_active_sessions', true);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UseInnoDbForUserActiveSessions extends Migration
{
public function up()
{
$table = $this->db->query("SHOW TABLES LIKE 'user_active_sessions'")->getRowArray();
if ($table !== null && $this->db->DBDriver === 'MySQLi') {
$this->db->query('ALTER TABLE user_active_sessions ENGINE=InnoDB');
}
}
public function down()
{
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use Config\AuthGroups;
class CreateAuthRoleMasterTables extends Migration
{
public function up()
{
$this->forge->addField([
'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true],
'alias' => ['type' => 'VARCHAR', 'constraint' => 64],
'title' => ['type' => 'VARCHAR', 'constraint' => 120],
'description' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'is_active' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1],
'is_builtin' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0],
'created_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'updated_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->addUniqueKey('alias', 'uk_auth_roles_alias');
$this->forge->addKey(['is_active', 'title'], false, false, 'idx_auth_roles_active_title');
$this->forge->createTable('auth_roles', true, ['ENGINE' => 'InnoDB']);
$this->forge->addField([
'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true],
'role_id' => ['type' => 'BIGINT', 'unsigned' => true],
'permission' => ['type' => 'VARCHAR', 'constraint' => 191],
'created_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addPrimaryKey('id');
$this->forge->addUniqueKey(['role_id', 'permission'], 'uk_auth_role_permission');
$this->forge->addKey('permission', false, false, 'idx_auth_role_permissions_permission');
$this->forge->addForeignKey('role_id', 'auth_roles', 'id', 'CASCADE', 'CASCADE', 'fk_auth_role_permissions_role');
$this->forge->createTable('auth_role_permissions', true, ['ENGINE' => 'InnoDB']);
$config = new AuthGroups();
$now = date('Y-m-d H:i:s');
foreach ($config->groups as $alias => $role) {
$this->db->table('auth_roles')->insert([
'alias' => $alias,
'title' => $role['title'] ?? $alias,
'description' => $role['description'] ?? null,
'is_active' => 1,
'is_builtin' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$roleId = (int) ($this->db->table('auth_roles')
->select('id')
->where('alias', $alias)
->get(1)
->getRowArray()['id'] ?? 0);
foreach ($config->matrix[$alias] ?? [] as $permission) {
$this->db->table('auth_role_permissions')->insert([
'role_id' => $roleId,
'permission' => $permission,
'created_at' => $now,
]);
}
}
}
public function down()
{
$this->forge->dropTable('auth_role_permissions', true);
$this->forge->dropTable('auth_roles', true);
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Database\Migrations;
use App\Libraries\RoleRegistry;
use CodeIgniter\Database\Migration;
class AddDevelopedModulePermissions extends Migration
{
private const NEW_PERMISSIONS = [
'reports.billing_operations.view',
'reports.document_issue.view',
'finance.access',
'finance.compliance.view',
'finance.doctor_payout.manage',
'finance.vendor.manage',
'finance.po.manage',
'finance.grn.manage',
'finance.invoice.manage',
'doctor_work.immunization.access',
'doctor_work.immunization.schedule-manage',
'doctor_work.immunization.record-manage',
'abdm.abha.create',
'billing.payment_request.view',
'billing.payment_request.manage',
'billing.refund.view',
'billing.refund.manage',
'billing.items.view',
'billing.items.manage',
'billing.packages.view',
'billing.packages.manage',
];
public function up()
{
if (! $this->tableExists('auth_roles') || ! $this->tableExists('auth_role_permissions')) {
return;
}
$now = date('Y-m-d H:i:s');
$doctor = $this->db->table('auth_roles')->where('alias', 'doctor')->get(1)->getRowArray();
if ($doctor === null) {
$this->db->table('auth_roles')->insert([
'alias' => 'doctor',
'title' => 'Doctor',
'description' => 'Clinical doctor access to OPD work, diagnosis reports, and immunization records.',
'is_active' => 1,
'is_builtin' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$doctor = $this->db->table('auth_roles')->where('alias', 'doctor')->get(1)->getRowArray();
}
$roleGrants = [
'doctor' => [
'opd.doctor-panel.access',
'doctor_work.access',
'doctor_work.immunization.access',
'doctor_work.immunization.record-manage',
'diagnosis.access',
'diagnosis.report.view',
],
'superadmin' => ['reports.billing_operations.view', 'reports.document_issue.view'],
'admin' => ['reports.billing_operations.view', 'reports.document_issue.view'],
'developer' => ['reports.billing_operations.view', 'reports.document_issue.view'],
'billing_cashier' => [
'abdm.abha.create',
'billing.payment_request.view',
'billing.payment_request.manage',
'billing.refund.view',
'billing.refund.manage',
],
];
foreach ($roleGrants as $alias => $permissions) {
$role = $this->db->table('auth_roles')->select('id')->where('alias', $alias)->get(1)->getRowArray();
if ($role === null) {
continue;
}
foreach ($permissions as $permission) {
$this->insertRolePermission((int) $role['id'], $permission, $now);
}
}
if ($this->tableExists('auth_permissions_users')) {
$managerIds = array_column(
$this->db->table('auth_permissions_users')->select('user_id')->where('permission', 'users.manage-admins')->get()->getResultArray(),
'user_id'
);
foreach ($managerIds as $userId) {
foreach (self::NEW_PERMISSIONS as $permission) {
$exists = $this->db->table('auth_permissions_users')->where('user_id', (int) $userId)->where('permission', $permission)->countAllResults();
if ($exists === 0) {
$this->db->table('auth_permissions_users')->insert([
'user_id' => (int) $userId,
'permission' => $permission,
'created_at' => $now,
]);
}
}
}
}
(new RoleRegistry($this->db))->publish();
}
public function down()
{
if ($this->tableExists('auth_permissions_users')) {
$this->db->table('auth_permissions_users')->whereIn('permission', self::NEW_PERMISSIONS)->delete();
}
if ($this->tableExists('auth_role_permissions')) {
$this->db->table('auth_role_permissions')->whereIn('permission', self::NEW_PERMISSIONS)->delete();
}
if ($this->tableExists('auth_roles')) {
$this->db->table('auth_roles')->where('alias', 'doctor')->where('is_builtin', 1)->delete();
(new RoleRegistry($this->db))->publish();
}
}
private function insertRolePermission(int $roleId, string $permission, string $createdAt): void
{
$exists = $this->db->table('auth_role_permissions')->where('role_id', $roleId)->where('permission', $permission)->countAllResults();
if ($exists === 0) {
$this->db->table('auth_role_permissions')->insert([
'role_id' => $roleId,
'permission' => $permission,
'created_at' => $createdAt,
]);
}
}
private function tableExists(string $table): bool
{
return $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray() !== null;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Filters;
use App\Libraries\UserSessionRegistry;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class SingleActiveSessionFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (! function_exists('auth') || ! auth()->loggedIn()) {
return null;
}
$userId = (int) (auth()->user()->id ?? 0);
if ($userId <= 0) {
return null;
}
$registry = new UserSessionRegistry();
$status = $registry->ensureCurrent($userId);
if (($status['active'] ?? false) === true) {
return null;
}
$message = ($status['reason'] ?? '') === 'signed_in_elsewhere'
? 'Your account was signed in on another system. This session has been logged out.'
: 'Your login session is no longer active. Please sign in again.';
auth()->logout();
$isAjax = strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest';
if ($isAjax || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) {
return service('response')->setStatusCode(409)->setJSON([
'session_terminated' => true,
'message' => $message,
'login_url' => base_url('login'),
]);
}
return redirect()->to(base_url('login'))->with('error', $message);
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
}
}

View File

@ -0,0 +1,219 @@
<?php
namespace App\Libraries;
use CodeIgniter\Database\BaseConnection;
use InvalidArgumentException;
use RuntimeException;
class RoleRegistry
{
private BaseConnection $db;
public function __construct(?BaseConnection $db = null)
{
$this->db = $db ?? db_connect();
}
public function all(): array
{
$roles = $this->db->table('auth_roles r')
->select('r.*, COUNT(DISTINCT rp.id) AS permission_count, COUNT(DISTINCT gu.id) AS user_count')
->join('auth_role_permissions rp', 'rp.role_id = r.id', 'left')
->join('auth_groups_users gu', 'gu.group = r.alias', 'left')
->groupBy('r.id')
->orderBy('r.is_builtin', 'DESC')
->orderBy('r.title', 'ASC')
->get()
->getResultArray();
return $roles;
}
public function find(int $roleId): ?array
{
$role = $this->db->table('auth_roles')->where('id', $roleId)->get(1)->getRowArray();
if ($role === null) {
return null;
}
$role['permissions'] = array_column(
$this->db->table('auth_role_permissions')->select('permission')->where('role_id', $roleId)->orderBy('permission')->get()->getResultArray(),
'permission'
);
return $role;
}
public function activeGroups(): array
{
$groups = [];
foreach ($this->db->table('auth_roles')->where('is_active', 1)->orderBy('title')->get()->getResultArray() as $role) {
$groups[$role['alias']] = [
'title' => $role['title'],
'description' => $role['description'] ?? '',
];
}
return $groups;
}
public function save(?int $roleId, array $input, int $userId): int
{
$alias = strtolower(trim((string) ($input['alias'] ?? '')));
$title = trim((string) ($input['title'] ?? ''));
$description = trim((string) ($input['description'] ?? ''));
$permissions = $this->validatePermissions((array) ($input['permissions'] ?? []));
if (! preg_match('/^[a-z][a-z0-9_]{2,63}$/', $alias)) {
throw new InvalidArgumentException('Role code must start with a letter and contain only lowercase letters, numbers, and underscores.');
}
if ($title === '' || mb_strlen($title) > 120) {
throw new InvalidArgumentException('Role name is required and must be at most 120 characters.');
}
if (mb_strlen($description) > 255) {
throw new InvalidArgumentException('Description must be at most 255 characters.');
}
$existing = $this->db->table('auth_roles')->where('alias', $alias)->get(1)->getRowArray();
if ($existing !== null && (int) $existing['id'] !== (int) $roleId) {
throw new InvalidArgumentException('Role code is already in use.');
}
$current = $roleId ? $this->find($roleId) : null;
if ($roleId && $current === null) {
throw new InvalidArgumentException('Role not found.');
}
if ($current !== null && (int) $current['is_builtin'] === 1 && $alias !== $current['alias']) {
throw new InvalidArgumentException('Built-in role codes cannot be changed.');
}
if (($current['alias'] ?? '') === 'superadmin') {
$permissions = $current['permissions'];
}
$now = date('Y-m-d H:i:s');
$this->db->transStart();
if ($current === null) {
$this->db->table('auth_roles')->insert([
'alias' => $alias,
'title' => $title,
'description' => $description !== '' ? $description : null,
'is_active' => 1,
'is_builtin' => 0,
'created_by' => $userId ?: null,
'updated_by' => $userId ?: null,
'created_at' => $now,
'updated_at' => $now,
]);
$roleId = (int) $this->db->insertID();
} else {
$this->db->table('auth_roles')->where('id', $roleId)->update([
'title' => $title,
'description' => $description !== '' ? $description : null,
'updated_by' => $userId ?: null,
'updated_at' => $now,
]);
$this->db->table('auth_role_permissions')->where('role_id', $roleId)->delete();
}
foreach ($permissions as $permission) {
$this->db->table('auth_role_permissions')->insert([
'role_id' => $roleId,
'permission' => $permission,
'created_by' => $userId ?: null,
'created_at' => $now,
]);
}
$this->db->transComplete();
if (! $this->db->transStatus()) {
throw new RuntimeException('Unable to save role.');
}
$this->publish();
return (int) $roleId;
}
public function setActive(int $roleId, bool $active, int $userId): void
{
$role = $this->find($roleId);
if ($role === null) {
throw new InvalidArgumentException('Role not found.');
}
if ((int) $role['is_builtin'] === 1) {
throw new InvalidArgumentException('Built-in roles cannot be deactivated.');
}
$this->db->table('auth_roles')->where('id', $roleId)->update([
'is_active' => $active ? 1 : 0,
'updated_by' => $userId ?: null,
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->publish();
}
public function delete(int $roleId): void
{
$role = $this->find($roleId);
if ($role === null) {
throw new InvalidArgumentException('Role not found.');
}
if ((int) $role['is_builtin'] === 1) {
throw new InvalidArgumentException('Built-in roles cannot be deleted.');
}
if ((int) $role['is_active'] === 1) {
throw new InvalidArgumentException('Deactivate the role before deleting it.');
}
$assignments = $this->db->table('auth_groups_users')->where('group', $role['alias'])->countAllResults();
if ($assignments > 0) {
throw new InvalidArgumentException('This role is assigned to users and cannot be deleted.');
}
$this->db->table('auth_roles')->where('id', $roleId)->delete();
$this->publish();
}
public function publish(): void
{
$groups = $this->activeGroups();
$matrix = [];
$rows = $this->db->table('auth_roles r')
->select('r.alias,rp.permission')
->join('auth_role_permissions rp', 'rp.role_id = r.id', 'left')
->where('r.is_active', 1)
->orderBy('r.id')
->get()
->getResultArray();
foreach ($groups as $alias => $_role) {
$matrix[$alias] = [];
}
foreach ($rows as $row) {
if (! empty($row['permission'])) {
$matrix[$row['alias']][] = $row['permission'];
}
}
setting('AuthGroups.groups', $groups);
setting('AuthGroups.matrix', $matrix);
}
private function validatePermissions(array $permissions): array
{
$permissions = array_values(array_unique(array_filter(array_map(static fn ($value): string => strtolower(trim((string) $value)), $permissions))));
$known = array_keys((array) setting('AuthGroups.permissions'));
$allowedWildcards = [];
foreach ($known as $permission) {
$scope = strstr($permission, '.', true);
if ($scope !== false) {
$allowedWildcards[] = $scope . '.*';
}
}
$allowed = array_merge($known, array_unique($allowedWildcards));
foreach ($permissions as $permission) {
if (! in_array($permission, $allowed, true)) {
throw new InvalidArgumentException('Unknown or unsupported permission: ' . $permission);
}
}
return $permissions;
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Libraries;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\HTTP\IncomingRequest;
class UserSessionRegistry
{
public const SESSION_TOKEN_KEY = 'hms_active_session_token';
private const TOUCH_KEY = 'hms_active_session_touched_at';
private const ONLINE_SECONDS = 120;
private BaseConnection $db;
public function __construct(?BaseConnection $db = null)
{
$this->db = $db ?? db_connect();
}
public function activate(int $userId): string
{
$this->ensureTable();
$token = bin2hex(random_bytes(32));
$now = date('Y-m-d H:i:s');
$this->db->transStart();
$this->db->table('user_active_sessions')
->where('user_id', $userId)
->where('is_active', 1)
->update([
'is_active' => 0,
'logout_at' => $now,
'revoked_reason' => 'signed_in_elsewhere',
]);
$request = service('request');
$this->db->table('user_active_sessions')->insert([
'user_id' => $userId,
'session_token' => $token,
'session_id_hash' => hash('sha256', session_id()),
'ip_address' => $request instanceof IncomingRequest ? $request->getIPAddress() : null,
'user_agent' => $request instanceof IncomingRequest ? substr($request->getUserAgent()->getAgentString(), 0, 255) : null,
'login_at' => $now,
'last_activity' => $now,
'is_active' => 1,
]);
$this->db->transComplete();
session()->set(self::SESSION_TOKEN_KEY, $token);
session()->set(self::TOUCH_KEY, time());
return $token;
}
public function ensureCurrent(int $userId): array
{
$this->ensureTable();
$token = trim((string) session()->get(self::SESSION_TOKEN_KEY));
if ($token === '') {
$this->activate($userId);
return ['active' => true, 'reason' => null];
}
$row = $this->db->table('user_active_sessions')
->select('is_active,revoked_reason')
->where('user_id', $userId)
->where('session_token', $token)
->get(1)
->getRowArray();
if (empty($row) || (int) ($row['is_active'] ?? 0) !== 1) {
return [
'active' => false,
'reason' => (string) ($row['revoked_reason'] ?? 'session_expired'),
];
}
$lastTouch = (int) session()->get(self::TOUCH_KEY);
if ($lastTouch <= time() - 30) {
$this->db->table('user_active_sessions')
->where('user_id', $userId)
->where('session_token', $token)
->where('is_active', 1)
->update(['last_activity' => date('Y-m-d H:i:s')]);
session()->set(self::TOUCH_KEY, time());
}
return ['active' => true, 'reason' => null];
}
public function deactivateCurrent(string $reason = 'logout'): void
{
if (! $this->db->tableExists('user_active_sessions')) {
return;
}
$token = trim((string) session()->get(self::SESSION_TOKEN_KEY));
if ($token !== '') {
$this->db->table('user_active_sessions')
->where('session_token', $token)
->where('is_active', 1)
->update([
'is_active' => 0,
'logout_at' => date('Y-m-d H:i:s'),
'revoked_reason' => $reason,
]);
}
}
public function revokeUser(int $userId, int $revokedBy, string $reason = 'admin_force_logout'): int
{
$this->ensureTable();
$this->db->table('user_active_sessions')
->where('user_id', $userId)
->where('is_active', 1)
->update([
'is_active' => 0,
'logout_at' => date('Y-m-d H:i:s'),
'revoked_reason' => $reason,
'revoked_by' => $revokedBy,
]);
return $this->db->affectedRows();
}
public function onlineUsers(): array
{
$this->ensureTable();
$cutoff = date('Y-m-d H:i:s', time() - self::ONLINE_SECONDS);
return $this->db->table('user_active_sessions s')
->select('s.id,s.user_id,s.ip_address,s.user_agent,s.login_at,s.last_activity,u.username,i.extra')
->join('users u', 'u.id = s.user_id', 'inner')
->join('auth_identities i', "i.user_id = u.id AND i.type = 'email_password'", 'left')
->where('s.is_active', 1)
->where('s.last_activity >=', $cutoff)
->orderBy('s.last_activity', 'DESC')
->get()
->getResultArray();
}
public function cleanup(): void
{
if (! $this->db->tableExists('user_active_sessions')) {
return;
}
$this->db->table('user_active_sessions')
->where('is_active', 1)
->where('last_activity <', date('Y-m-d H:i:s', time() - 7200))
->update([
'is_active' => 0,
'logout_at' => date('Y-m-d H:i:s'),
'revoked_reason' => 'expired',
]);
}
private function ensureTable(): void
{
if ($this->db->tableExists('user_active_sessions')) {
return;
}
$this->db->query("CREATE TABLE IF NOT EXISTS user_active_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
session_token CHAR(64) NOT NULL,
session_id_hash CHAR(64) NOT NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
login_at DATETIME NOT NULL,
last_activity DATETIME NOT NULL,
logout_at DATETIME NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
revoked_reason VARCHAR(60) NULL,
revoked_by INT UNSIGNED NULL,
UNIQUE KEY uk_user_active_session_token (session_token),
KEY idx_user_active_sessions_user (user_id,is_active),
KEY idx_user_active_sessions_online (is_active,last_activity)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
}

View File

@ -0,0 +1,83 @@
<?php
$role = $role ?? [];
$isEdit = ! empty($role['id']);
$isSuperAdmin = ($role['alias'] ?? '') === 'superadmin';
$selectedPermissions = array_values((array) ($role['permissions'] ?? []));
$allPermissions = array_merge($wildcards ?? [], $permissions ?? []);
$grouped = [];
foreach ($allPermissions as $key => $label) {
$scope = strstr($key, '.', true) ?: 'other';
$grouped[$scope][$key] = $label;
}
?>
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<div><h3 class="card-title mb-0"><?= $isEdit ? 'Edit Role' : 'New Role' ?></h3><div class="text-muted small">Users assigned this role inherit every selected permission.</div></div>
<button class="btn btn-light" type="button" onclick="load_form_div('<?= base_url('setting/admin/roles') ?>','maindiv','Role Master');"><i class="bi bi-arrow-left"></i> Back to Role Master</button>
</div>
<div class="card-body">
<?php if (! empty($errors)) : ?><div class="alert alert-danger"><?php foreach ($errors as $error) : ?><div><?= esc($error) ?></div><?php endforeach ?></div><?php endif ?>
<?php if ($isSuperAdmin) : ?><div class="alert alert-info">Super Admin permissions are protected against lockout and cannot be changed.</div><?php endif ?>
<form id="frm_role_master" action="<?= $isEdit ? base_url('setting/admin/roles/edit/' . (int) $role['id']) : base_url('setting/admin/roles') ?>" method="post">
<?= csrf_field() ?>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="alias">Role Code</label>
<input class="form-control" id="alias" name="alias" type="text" maxlength="64" value="<?= esc($role['alias'] ?? '') ?>" placeholder="e.g. lab_manager" <?= $isEdit ? 'readonly' : 'required' ?>>
<div class="form-text">Permanent lowercase system code; it cannot be changed later.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="title">Role Name</label>
<input class="form-control" id="title" name="title" type="text" maxlength="120" value="<?= esc($role['title'] ?? '') ?>" required>
</div>
<div class="col-md-4">
<label class="form-label" for="description">Description</label>
<input class="form-control" id="description" name="description" type="text" maxlength="255" value="<?= esc($role['description'] ?? '') ?>">
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-4 mb-2">
<div><strong>Inherited Permissions</strong><div class="text-muted small">Wildcard selections such as Pharmacy: All include current and future permissions in that module.</div></div>
<?php if (! $isSuperAdmin) : ?><button class="btn btn-sm btn-outline-secondary" type="button" id="roleClearPermissions">Clear All</button><?php endif ?>
</div>
<div class="row g-3">
<?php foreach ($grouped as $scope => $items) : ?>
<div class="col-12 col-md-6 col-xl-4">
<div class="border rounded p-3 h-100">
<div class="fw-bold mb-2"><?= esc(ucwords(str_replace('_', ' ', $scope))) ?></div>
<?php foreach ($items as $key => $label) : ?>
<div class="form-check mb-1">
<input class="form-check-input role-permission" type="checkbox" name="permissions[]" id="role_perm_<?= esc(md5($key)) ?>" value="<?= esc($key) ?>" <?= in_array($key, $selectedPermissions, true) ? 'checked' : '' ?> <?= $isSuperAdmin ? 'disabled' : '' ?>>
<label class="form-check-label" for="role_perm_<?= esc(md5($key)) ?>"><?= esc($label) ?> <code><?= esc($key) ?></code></label>
</div>
<?php endforeach ?>
</div>
</div>
<?php endforeach ?>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save Role</button>
<button class="btn btn-light" type="button" onclick="load_form_div('<?= base_url('setting/admin/roles') ?>','maindiv','Role Master');">Cancel</button>
</div>
</form>
</div>
</div>
<script>
(function() {
$(document).off('click.roleClear', '#roleClearPermissions').on('click.roleClear', '#roleClearPermissions', function() {
$('.role-permission').prop('checked', false);
});
$(document).off('submit.roleMaster', '#frm_role_master').on('submit.roleMaster', '#frm_role_master', function(event) {
event.preventDefault();
var form = this;
$.post($(form).attr('action'), $(form).serialize()).done(function(html) {
$('#maindiv').html(html);
}).fail(function(xhr) {
alert((xhr.responseJSON && xhr.responseJSON.error_text) || 'Unable to save role.');
});
});
})();
</script>

View File

@ -0,0 +1,79 @@
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<h3 class="card-title mb-0">Role Master</h3>
<div class="text-muted small">Define job roles and their inherited permissions.</div>
</div>
<div class="card-tools ms-auto d-flex gap-2">
<button class="btn btn-primary" type="button" onclick="load_form_div('<?= base_url('setting/admin/roles/new') ?>','maindiv','New Role');">
<i class="bi bi-plus-lg"></i> New Role
</button>
<button class="btn btn-light" type="button" onclick="load_form_div('<?= base_url('setting/admin/user-management') ?>','maindiv','User Management');">
<i class="bi bi-arrow-left"></i> Back to Users
</button>
</div>
</div>
<div class="card-body">
<?php if (! empty($message)) : ?><div class="alert alert-success"><?= esc($message) ?></div><?php endif ?>
<?php if (! empty($errors)) : ?>
<div class="alert alert-danger"><?php foreach ($errors as $error) : ?><div><?= esc($error) ?></div><?php endforeach ?></div>
<?php endif ?>
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead><tr><th>Role</th><th>Code</th><th>Permissions</th><th>Users</th><th>Status</th><th>Type</th><th>Action</th></tr></thead>
<tbody>
<?php foreach (($roles ?? []) as $role) : ?>
<tr>
<td><strong><?= esc($role['title']) ?></strong><div class="text-muted small"><?= esc($role['description'] ?? '') ?></div></td>
<td><code><?= esc($role['alias']) ?></code></td>
<td><?= (int) $role['permission_count'] ?></td>
<td><?= (int) $role['user_count'] ?></td>
<td><span class="badge <?= (int) $role['is_active'] === 1 ? 'bg-success' : 'bg-secondary' ?>"><?= (int) $role['is_active'] === 1 ? 'Active' : 'Inactive' ?></span></td>
<td><?= (int) $role['is_builtin'] === 1 ? '<span class="badge bg-light text-dark">Built-in</span>' : 'Custom' ?></td>
<td>
<div class="d-flex gap-1 flex-wrap">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="load_form_div('<?= base_url('setting/admin/roles/edit/' . (int) $role['id']) ?>','maindiv','Edit Role');">Edit</button>
<?php if ((int) $role['is_builtin'] !== 1) : ?>
<button class="btn btn-sm btn-outline-secondary btn-role-status" type="button" data-role-id="<?= (int) $role['id'] ?>" data-active="<?= (int) $role['is_active'] === 1 ? 0 : 1 ?>"><?= (int) $role['is_active'] === 1 ? 'Deactivate' : 'Activate' ?></button>
<?php if ((int) $role['is_active'] !== 1 && (int) $role['user_count'] === 0) : ?>
<button class="btn btn-sm btn-outline-danger btn-role-delete" type="button" data-role-id="<?= (int) $role['id'] ?>" data-title="<?= esc($role['title'], 'attr') ?>">Delete</button>
<?php endif ?>
<?php endif ?>
</div>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
(function() {
var csrfName = '<?= csrf_token() ?>';
var csrfHash = '<?= csrf_hash() ?>';
function postRole(url, data) {
data[csrfName] = csrfHash;
$.post(url, data, null, 'json').done(function(response) {
csrfHash = response.csrf_hash || csrfHash;
load_form_div('<?= base_url('setting/admin/roles') ?>', 'maindiv', 'Role Master');
}).fail(function(xhr) {
var response = xhr.responseJSON || {};
csrfHash = response.csrf_hash || csrfHash;
alert(response.error_text || 'Unable to update role.');
});
}
$(document).off('click.roleStatus', '.btn-role-status').on('click.roleStatus', '.btn-role-status', function() {
postRole('<?= base_url('setting/admin/roles/status') ?>/' + $(this).data('role-id'), {is_active: $(this).data('active')});
});
$(document).off('click.roleDelete', '.btn-role-delete').on('click.roleDelete', '.btn-role-delete', function() {
if (!confirm('Delete role ' + $(this).data('title') + '?')) return;
postRole('<?= base_url('setting/admin/roles/delete') ?>/' + $(this).data('role-id'), {});
});
})();
</script>

View File

@ -33,6 +33,14 @@
<input class="form-control" id="email" name="email" type="email" value="<?= esc($formData['email'] ?? old('email')) ?>" required>
<div class="invalid-feedback">Email is required.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="person_name">Person Name</label>
<input class="form-control" id="person_name" name="person_name" type="text" maxlength="120" value="<?= esc($formData['person_name'] ?? old('person_name')) ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="phone_no">Phone No.</label>
<input class="form-control" id="phone_no" name="phone_no" type="text" maxlength="20" value="<?= esc($formData['phone_no'] ?? old('phone_no')) ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="password">Password</label>
<input class="form-control" id="password" name="password" type="password" required>
@ -51,6 +59,7 @@
<?php endif ?>
</select>
<div class="invalid-feedback">Role is required.</div>
<div class="form-text">Choose the closest job role for baseline access. Add exceptional access later from Additional User Permissions.</div>
</div>
</div>
@ -69,7 +78,7 @@
return;
}
$(form).on('submit', function(event) {
$(form).off('submit.userCreate').on('submit.userCreate', function(event) {
event.preventDefault();
$.post($(form).attr('action'), $(form).serialize())

View File

@ -38,6 +38,18 @@
<label class="form-label" for="phone_no">Phone No.</label>
<input class="form-control" id="phone_no" name="phone_no" type="text" maxlength="20" value="<?= esc($formData['phone_no'] ?? ($phone_no ?? '')) ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="role">Role</label>
<select class="form-select" id="role" name="role" required>
<option value="">Select role</option>
<?php foreach (($roles ?? []) as $roleKey => $roleInfo) : ?>
<option value="<?= esc($roleKey) ?>" <?= (string) ($formData['role'] ?? ($current_role ?? '')) === (string) $roleKey ? 'selected' : '' ?>>
<?= esc($roleInfo['title'] ?? $roleKey) ?>
</option>
<?php endforeach ?>
</select>
<div class="form-text">The role provides baseline access. Use Additional User Permissions only when this user needs extra access beyond the role.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="password">New Password (Optional)</label>
<input class="form-control" id="password" name="password" type="password" autocomplete="new-password" placeholder="Leave blank to keep current password">
@ -65,7 +77,7 @@
return;
}
$(form).on('submit', function(event) {
$(form).off('submit.userEdit').on('submit.userEdit', function(event) {
event.preventDefault();
$.post($(form).attr('action'), $(form).serialize())
.done(function(html) {

View File

@ -7,9 +7,17 @@
<i class="bi bi-person-plus"></i>
New User
</button>
<button class="btn btn-outline-primary" type="button" onclick="load_form_div('<?= base_url('setting/admin/user-management/permissions') ?>','maindiv','Give Permission');">
<button class="btn btn-outline-dark" type="button" onclick="load_form_div('<?= base_url('setting/admin/roles') ?>','maindiv','Role Master');">
<i class="bi bi-person-badge"></i>
Role Master
</button>
<button class="btn btn-outline-primary" type="button" onclick="load_form_div('<?= base_url('setting/admin/user-management/permissions') ?>','maindiv','Additional Permissions');">
<i class="bi bi-shield-lock"></i>
Give Permission
Additional Permissions
</button>
<button class="btn btn-outline-success" type="button" onclick="load_form_div('<?= base_url('setting/admin/user-management/sessions') ?>','maindiv','Active Sessions');">
<i class="bi bi-person-check"></i>
Who is Online
</button>
</div>
</div>
@ -100,6 +108,9 @@
<div class="d-flex gap-1">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="load_form_div('<?= base_url('setting/admin/user-management/edit/' . (int) ($user->id ?? 0)) ?>','maindiv','Edit User');">Edit</button>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="load_form_div('<?= base_url('setting/admin/user-management/reset-password/' . (int) ($user->id ?? 0)) ?>','maindiv','Reset Password');">Reset Password</button>
<?php if ((int) ($user->id ?? 0) !== (int) (auth()->user()->id ?? 0)) : ?>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-user" data-user-id="<?= (int) ($user->id ?? 0) ?>" data-username="<?= esc((string) ($user->username ?? ''), 'attr') ?>">Delete</button>
<?php endif ?>
</div>
</td>
</tr>
@ -141,5 +152,25 @@
$('#um_filter_person,#um_filter_phone').val('');
applyUserFilters();
});
$(document).off('click.userDelete', '.btn-delete-user').on('click.userDelete', '.btn-delete-user', function() {
var userId = parseInt($(this).data('user-id') || '0', 10);
var username = ($(this).data('username') || 'this user').toString();
if (userId <= 0 || !window.confirm('Delete ' + username + '? The account will no longer be able to sign in.')) {
return;
}
$.post('<?= base_url('setting/admin/user-management/delete') ?>/' + userId, {
'<?= csrf_token() ?>': $('input[name="<?= csrf_token() ?>"]').first().val() || '<?= csrf_hash() ?>'
}, function(data) {
if (!data || parseInt(data.update || '0', 10) !== 1) {
window.alert((data && data.error_text) || 'Unable to delete user.');
return;
}
load_form_div('<?= base_url('setting/admin/user-management') ?>', 'maindiv', 'User Management');
}, 'json').fail(function(xhr) {
window.alert((xhr.responseJSON && xhr.responseJSON.error_text) || 'Unable to delete user.');
});
});
})();
</script>

View File

@ -1,6 +1,9 @@
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h3 class="card-title mb-0">Give Permissions</h3>
<div>
<h3 class="card-title mb-0">Additional User Permissions</h3>
<div class="text-muted small">Role permissions are inherited automatically. Use this page only for extra access needed by one user.</div>
</div>
<div class="card-tools ms-auto">
<button class="btn btn-light" type="button" onclick="load_form_div('<?= base_url('setting/admin/user-management') ?>','maindiv','User Management');">
<i class="bi bi-arrow-left"></i>
@ -49,7 +52,15 @@
</div>
<div class="mt-4">
<label class="form-label">Permissions</label>
<?php if (! empty($selectedUser)) : ?>
<div class="alert alert-secondary py-2 mb-3">
<strong>Assigned role:</strong>
<?= esc(! empty($selectedRoleTitles) ? implode(', ', $selectedRoleTitles) : 'No role') ?>.
Permissions marked <span class="badge bg-secondary">Inherited from Role</span> are already available through this role.
</div>
<?php endif ?>
<label class="form-label">Direct grants</label>
<div class="text-muted small mb-2">Checked boxes are additional permissions saved specifically for this user. Unchecking one does not remove access inherited from the role.</div>
<div class="alert alert-info py-2 mb-2">
Pharmacy module access is controlled by <strong>pharmacy.access</strong>. Editing past Walk-in/Registered pharmacy invoices requires <strong>pharmacy.invoice.edit-old</strong> (or <strong>pharmacy.invoice.admin</strong>). Discount caps can be granted with <strong>pharmacy.invoice.discount.10</strong>, <strong>pharmacy.invoice.discount.20</strong>, <strong>pharmacy.invoice.discount.30</strong>, or full override using <strong>pharmacy.invoice.admin</strong>. Base user discount can be set in hospital setting key <strong>PHARMACY_NORMAL_MAX_DISCOUNT_PERCENT</strong>.
</div>
@ -57,6 +68,7 @@
<?php
$selectedPermissions = [];
$permissions = $permissions ?? [];
$inheritedPermissions = $inheritedPermissions ?? [];
if (! empty($selectedUser)) {
$selectedPermissions = $selectedUser->getPermissions() ?? [];
}
@ -134,6 +146,9 @@
<input class="form-check-input" type="checkbox" id="perm_<?= esc($permissionKey) ?>" name="permissions[]" value="<?= esc($permissionKey) ?>" <?= in_array($permissionKey, $selectedPermissions, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="perm_<?= esc($permissionKey) ?>">
<?= esc($permissionLabel) ?>
<?php if (in_array($permissionKey, $inheritedPermissions, true)) : ?>
<span class="badge bg-secondary ms-1">Inherited from Role</span>
<?php endif ?>
</label>
</div>
<?php endforeach ?>
@ -164,7 +179,7 @@
return;
}
loadButton.addEventListener('click', function() {
$(document).off('click.userPermissionsLoad', '#loadPermissions').on('click.userPermissionsLoad', '#loadPermissions', function() {
var userId = userSelect.value;
if (!userId) {
return;
@ -177,7 +192,7 @@
return;
}
$(form).on('submit', function(event) {
$(form).off('submit.userPermissions').on('submit.userPermissions', function(event) {
event.preventDefault();
$.post($(form).attr('action'), $(form).serialize())

View File

@ -67,11 +67,11 @@
$('#pin_hint').text('PIN generated. Share this temporary PIN with user.');
}
$('#btn_generate_pin').on('click', function() {
$('#btn_generate_pin').off('click.userReset').on('click.userReset', function() {
applyGeneratedPin(makeSixDigitPin());
});
$('#btn_copy_pin').on('click', function() {
$('#btn_copy_pin').off('click.userReset').on('click.userReset', function() {
var pin = ($('#password').val() || '').toString();
if (!/^\d{6}$/.test(pin)) {
$('#pin_hint').text('Enter/generate valid 6-digit PIN first.');
@ -92,7 +92,7 @@
applyGeneratedPin(makeSixDigitPin());
$(form).on('submit', function(event) {
$(form).off('submit.userReset').on('submit.userReset', function(event) {
event.preventDefault();
$.post($(form).attr('action'), $(form).serialize())
.done(function(html) {

View File

@ -0,0 +1,103 @@
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<h3 class="card-title mb-0">Active HMS Sessions</h3>
<div class="text-muted small">Users active within the last 2 minutes</div>
</div>
<div class="card-tools ms-auto d-flex gap-2">
<button class="btn btn-outline-primary" type="button" id="btn_refresh_user_sessions">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-light" type="button" onclick="load_form_div('<?= base_url('setting/admin/user-management') ?>','maindiv','User Management');">
<i class="bi bi-arrow-left"></i> Back to Users
</button>
</div>
</div>
<div class="card-body">
<?php $sessions = $sessions ?? []; ?>
<div class="alert alert-info py-2">
<strong><?= count($sessions) ?></strong> user<?= count($sessions) === 1 ? '' : 's' ?> currently online. A user can have only one active HMS session.
</div>
<div id="user_session_notice"></div>
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>User</th>
<th>Login Time</th>
<th>Last Activity</th>
<th>IP Address</th>
<th>Browser / Device</th>
<th style="width:130px;">Action</th>
</tr>
</thead>
<tbody>
<?php if ($sessions === []) : ?>
<tr><td colspan="6" class="text-center text-muted">No active users.</td></tr>
<?php else : ?>
<?php foreach ($sessions as $row) : ?>
<?php $isCurrent = (int) ($row['user_id'] ?? 0) === (int) ($current_user_id ?? 0); ?>
<tr>
<td>
<div class="fw-semibold"><?= esc((string) ($row['person_name'] ?: ($row['username'] ?? ''))) ?></div>
<div class="small text-muted"><?= esc((string) ($row['username'] ?? '')) ?></div>
<?php if ($isCurrent) : ?><span class="badge bg-success">This session</span><?php endif ?>
</td>
<td><?= esc(date('d/m/Y h:i A', strtotime((string) ($row['login_at'] ?? 'now')))) ?></td>
<td><?= esc(date('d/m/Y h:i:s A', strtotime((string) ($row['last_activity'] ?? 'now')))) ?></td>
<td><?= esc((string) ($row['ip_address'] ?? '-')) ?></td>
<td class="small text-break" style="max-width:320px;"><?= esc((string) ($row['user_agent'] ?? '-')) ?></td>
<td>
<?php if ($isCurrent) : ?>
<span class="text-muted small">Use Sign Out</span>
<?php else : ?>
<button type="button" class="btn btn-outline-danger btn-sm btn-force-user-logout" data-user-id="<?= (int) ($row['user_id'] ?? 0) ?>" data-user-name="<?= esc((string) ($row['username'] ?? 'user'), 'attr') ?>">
Force Logout
</button>
<?php endif ?>
</td>
</tr>
<?php endforeach ?>
<?php endif ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
(function() {
var listUrl = '<?= base_url('setting/admin/user-management/sessions') ?>';
$(document)
.off('click.userSessionsRefresh', '#btn_refresh_user_sessions')
.on('click.userSessionsRefresh', '#btn_refresh_user_sessions', function() {
load_form_div(listUrl, 'maindiv', 'Active Sessions');
})
.off('click.userSessionsLogout', '.btn-force-user-logout')
.on('click.userSessionsLogout', '.btn-force-user-logout', function() {
var button = this;
var userId = parseInt(button.getAttribute('data-user-id') || '0', 10);
var userName = button.getAttribute('data-user-name') || 'this user';
if (userId <= 0 || !window.confirm('Force logout ' + userName + '?')) {
return;
}
button.disabled = true;
$.post('<?= base_url('setting/admin/user-management/sessions/force-logout') ?>/' + userId, {
'<?= csrf_token() ?>': $('input[name="<?= csrf_token() ?>"]').first().val() || '<?= csrf_hash() ?>'
}, function(data) {
if (!data || parseInt(data.update || '0', 10) !== 1) {
$('#user_session_notice').html('<div class="alert alert-danger">' + $('<div>').text((data && data.error_text) || 'Unable to end session.').html() + '</div>');
button.disabled = false;
return;
}
load_form_div(listUrl, 'maindiv', 'Active Sessions');
}, 'json').fail(function(xhr) {
var message = (xhr.responseJSON && xhr.responseJSON.error_text) || 'Unable to end session.';
$('#user_session_notice').html('<div class="alert alert-danger">' + $('<div>').text(message).html() + '</div>');
button.disabled = false;
});
});
})();
</script>

View File

@ -3,7 +3,7 @@
<?php $canBillingSubmit = $user && method_exists($user, 'can') ? ($user->can('finance.cash.billing.submit') || $user->can('finance.*')) : false; ?>
<?php $canAccountsVerify = $user && method_exists($user, 'can') ? ($user->can('finance.cash.accounts.accept') || $user->can('finance.cash.accounts.verify') || $user->can('finance.*')) : false; ?>
<?php $canBankAudit = $user && method_exists($user, 'can') ? ($user->can('finance.bank.deposit.create') || $user->can('finance.bank.audit') || $user->can('finance.bank.statement.update') || $user->can('finance.*')) : false; ?>
<?php $canMedicalStoreCredit = $user && method_exists($user, 'can') ? ($user->can('finance.workflow.view') || $user->can('finance.cash.accounts.accept') || $user->can('finance.cash.accounts.verify') || $user->can('finance.doctor_payout.manage') || $user->can('finance.*')) : false; ?>
<?php $canMedicalStoreCredit = $user && method_exists($user, 'can') ? ($user->can('finance.workflow.view') || $user->can('finance.access') || $user->can('finance.cash.accounts.accept') || $user->can('finance.cash.accounts.verify') || $user->can('finance.doctor_payout.manage') || $user->can('finance.*')) : false; ?>
<div class="mb-3">
<h2 class="mb-1">Accounts And Finance</h2>

View File

@ -32,6 +32,52 @@
<link href="<?= base_url('assets/css/style.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/css/custom-theme-dark-borders.css') ?>" rel="stylesheet">
<script>
(function() {
var checkingSession = false;
var terminated = false;
function checkActiveSession() {
if (checkingSession || terminated || document.hidden) {
return;
}
checkingSession = true;
fetch('<?= base_url('auth/session-status') ?>', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
cache: 'no-store',
credentials: 'same-origin'
}).then(function(response) {
if (response.status !== 409) {
return null;
}
return response.json();
}).then(function(payload) {
if (!payload || !payload.session_terminated || terminated) {
return;
}
terminated = true;
window.alert(payload.message || 'This login session was ended. Please sign in again.');
window.location.assign(payload.login_url || '<?= base_url('login') ?>');
}).catch(function() {
}).finally(function() {
checkingSession = false;
});
}
window.setInterval(checkActiveSession, 15000);
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
checkActiveSession();
}
});
window.setTimeout(checkActiveSession, 3000);
})();
</script>
<script>
// Early fallback so javascript:load_form(...) links never fail before full app scripts initialize.
if (typeof window.load_form !== 'function') {