feat(security): implement WASA audit mitigations, mobile anti-enumeration & device authorization, and user management fixes
This commit is contained in:
parent
a3c544a53c
commit
de4f65807d
|
|
@ -228,7 +228,7 @@ class Auth extends ShieldAuth
|
|||
'required',
|
||||
'max_length[30]',
|
||||
'min_length[3]',
|
||||
'regex_match[/\A[a-zA-Z0-9\.]+\z/]',
|
||||
'regex_match[/\A[a-zA-Z0-9\._-]+\z/]',
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Config;
|
||||
|
||||
use App\Filters\SecureHeadersFilter;
|
||||
use App\Filters\SingleActiveSessionFilter;
|
||||
use CodeIgniter\Config\Filters as BaseFilters;
|
||||
use CodeIgniter\Filters\Cors;
|
||||
|
|
@ -30,7 +31,7 @@ class Filters extends BaseFilters
|
|||
'toolbar' => DebugToolbar::class,
|
||||
'honeypot' => Honeypot::class,
|
||||
'invalidchars' => InvalidChars::class,
|
||||
'secureheaders' => SecureHeaders::class,
|
||||
'secureheaders' => SecureHeadersFilter::class,
|
||||
'cors' => Cors::class,
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
|
|
|
|||
|
|
@ -1396,6 +1396,7 @@ $routes->group('setting', static function($routes) {
|
|||
$routes->post('admin/doctor/fee/remove', 'Setting\\Doctor::removeFee');
|
||||
$routes->post('admin/doctor/ipd-fee/add', 'Setting\\Doctor::addIpdFee');
|
||||
$routes->post('admin/doctor/ipd-fee/remove', 'Setting\\Doctor::removeIpdFee');
|
||||
$routes->post('admin/doctor/regenerate-device-code', 'Setting\\Doctor::regenerateDeviceCode');
|
||||
$routes->get('admin/doctor/specs', 'Setting\\Doctor::specs');
|
||||
$routes->post('admin/doctor/specs/create', 'Setting\\Doctor::storeSpec');
|
||||
$routes->post('admin/doctor/specs/update', 'Setting\\Doctor::updateSpec');
|
||||
|
|
@ -1439,6 +1440,8 @@ $routes->group('setting', static function($routes) {
|
|||
$routes->post('admin/reffer/types/delete', 'Setting\\Reffer::deleteType');
|
||||
$routes->get('admin/nurse', 'Setting\\Nurse::index');
|
||||
$routes->post('admin/nurse/save', 'Setting\\Nurse::save');
|
||||
$routes->post('admin/nurse/regenerate-device-code', 'Setting\\Nurse::regenerate_device_code');
|
||||
$routes->post('admin/nurse/regenerate-device-code/(:num)', 'Setting\\Nurse::regenerate_device_code/$1');
|
||||
$routes->post('admin/nurse/delete', 'Setting\\Nurse::delete');
|
||||
$routes->post('admin/nurse/delete/(:num)', 'Setting\\Nurse::delete/$1');
|
||||
$routes->get('admin/nurse/list_json', 'Setting\\Nurse::list_json');
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ class Security extends BaseConfig
|
|||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Regenerate CSRF Token on every submission.
|
||||
* Set to false for SPA and AJAX-heavy dashboards to ensure in-flight
|
||||
* requests and concurrent tabs do not invalidate the session CSRF token.
|
||||
*/
|
||||
public bool $regenerate = true;
|
||||
public bool $regenerate = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class DoctorApi extends BaseController
|
|||
|
||||
/**
|
||||
* GET api/v1/doctor/list
|
||||
* Returns sanitized doctor list for UI pickers (Data Minimized: PII and signatures stripped)
|
||||
*/
|
||||
public function doctors()
|
||||
{
|
||||
|
|
@ -58,9 +59,7 @@ class DoctorApi extends BaseController
|
|||
$list[] = [
|
||||
'id' => (int) $d->id,
|
||||
'name' => trim(($d->p_title ?? '') . ' ' . ($d->p_fname ?? '')),
|
||||
'email' => $d->email1 ?? '',
|
||||
'phone' => $d->mphone1 ?? '',
|
||||
'doc_sign' => $d->doc_sign ?? '',
|
||||
'department' => $d->department ?? '',
|
||||
'has_pin' => ! empty($d->app_pin),
|
||||
];
|
||||
}
|
||||
|
|
@ -71,28 +70,147 @@ class DoctorApi extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
private function isHospitalIntranet(string $ip): bool
|
||||
{
|
||||
if (in_array($ip, ['127.0.0.1', '::1', 'localhost'], true)) {
|
||||
return true;
|
||||
}
|
||||
$privateRanges = [
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
];
|
||||
$ipLong = ip2long($ip);
|
||||
if ($ipLong === false) {
|
||||
return false;
|
||||
}
|
||||
foreach ($privateRanges as $range) {
|
||||
[$subnet, $mask] = explode('/', $range);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = ~((1 << (32 - (int) $mask)) - 1);
|
||||
if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/v1/doctor/auth/verify-pin
|
||||
*/
|
||||
public function verifyPin()
|
||||
{
|
||||
$post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []);
|
||||
$docId = (int) ($post['doctor_id'] ?? 0);
|
||||
$pin = trim((string) ($post['pin'] ?? ''));
|
||||
|
||||
if ($docId <= 0) {
|
||||
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Doctor ID is required']);
|
||||
$rawDocId = trim((string) ($post['doctor_id'] ?? $post['staff_id'] ?? ''));
|
||||
$docId = 0;
|
||||
if (preg_match('/^DOC[-_]?(\d+)$/i', $rawDocId, $m)) {
|
||||
$docId = (int) $m[1];
|
||||
} elseif (is_numeric($rawDocId)) {
|
||||
$docId = (int) $rawDocId;
|
||||
}
|
||||
$docCode = trim((string) ($post['doctor_code'] ?? $post['staff_id'] ?? $post['user_id'] ?? $post['username'] ?? $rawDocId));
|
||||
$pin = trim((string) ($post['pin'] ?? $post['password'] ?? ''));
|
||||
$deviceCode = trim((string) ($post['device_code'] ?? ''));
|
||||
$deviceToken = trim((string) ($post['device_token'] ?? ''));
|
||||
|
||||
$doctorRow = null;
|
||||
if ($docId > 0) {
|
||||
$doctorRows = $this->doctorModel->getDoctorById($docId);
|
||||
$doctorRow = $doctorRows[0] ?? null;
|
||||
}
|
||||
|
||||
if (! $doctorRow && $docCode !== '') {
|
||||
if (is_numeric($docCode)) {
|
||||
$doctorRows = $this->doctorModel->getDoctorById((int) $docCode);
|
||||
$doctorRow = $doctorRows[0] ?? null;
|
||||
}
|
||||
|
||||
if (! $doctorRow && db_connect()->tableExists('user_profiles')) {
|
||||
$linkedRow = db_connect()->table('user_profiles up')
|
||||
->select('d.*, u.id as linked_user_id, u.username as linked_username')
|
||||
->join('doctor_master d', 'd.id = up.doctor_id')
|
||||
->join('users u', 'u.id = up.user_id AND u.deleted_at IS NULL', 'left')
|
||||
->groupStart()
|
||||
->where('u.username', $docCode)
|
||||
->orWhere('up.employee_code', $docCode)
|
||||
->groupEnd()
|
||||
->get()->getRow();
|
||||
if ($linkedRow) {
|
||||
$doctorRow = $linkedRow;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $doctorRow) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Doctor profile not found']);
|
||||
// Try searching by name or doctor_reg_no
|
||||
$doctorRow = db_connect()->table('doctor_master')
|
||||
->where('doctor_reg_no', $docCode)
|
||||
->orWhere('mphone1', $docCode)
|
||||
->get()->getRow();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $doctorRow) {
|
||||
return $this->response->setStatusCode(404)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Doctor profile not found. Please verify your Doctor ID / Username.'
|
||||
]);
|
||||
}
|
||||
|
||||
$clientIp = (string) $this->request->getIPAddress();
|
||||
$isIntranet = $this->isHospitalIntranet($clientIp);
|
||||
|
||||
// Device pairing check
|
||||
$expectedDeviceCode = trim((string) ($doctorRow->device_code ?? ''));
|
||||
if ($expectedDeviceCode === '') {
|
||||
$expectedDeviceCode = (string) random_int(100000, 999999);
|
||||
db_connect()->table('doctor_master')->where('id', $doctorRow->id)->update(['device_code' => $expectedDeviceCode]);
|
||||
$doctorRow->device_code = $expectedDeviceCode;
|
||||
}
|
||||
|
||||
$expectedDeviceToken = hash_hmac('sha256', (string) $doctorRow->id, $expectedDeviceCode);
|
||||
|
||||
$isDeviceTrusted = false;
|
||||
if ($deviceToken !== '' && hash_equals($expectedDeviceToken, $deviceToken)) {
|
||||
$isDeviceTrusted = true;
|
||||
}
|
||||
|
||||
if (! $isDeviceTrusted) {
|
||||
if ($deviceCode === '') {
|
||||
return $this->response->setStatusCode(401)->setJSON([
|
||||
'status' => 0,
|
||||
'need_device_code' => true,
|
||||
'message' => 'Device Security Code is required to register this mobile device. Obtain the 6-digit code from Doctor Master in HMS Admin.'
|
||||
]);
|
||||
}
|
||||
if (strcasecmp($deviceCode, $expectedDeviceCode) !== 0) {
|
||||
return $this->response->setStatusCode(401)->setJSON([
|
||||
'status' => 0,
|
||||
'need_device_code' => true,
|
||||
'message' => 'Invalid Device Security Code. Please enter the 6-digit Security Code provided in Doctor Master.'
|
||||
]);
|
||||
}
|
||||
$isDeviceTrusted = true;
|
||||
}
|
||||
|
||||
$rateKey = 'doc_pin_' . md5($clientIp . '_' . $doctorRow->id);
|
||||
$cache = service('cache');
|
||||
$failedAttempts = (int) ($cache->get($rateKey) ?? 0);
|
||||
if ($failedAttempts >= 5) {
|
||||
return $this->response->setStatusCode(429)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.',
|
||||
]);
|
||||
}
|
||||
|
||||
$hashedPin = (string) ($doctorRow->app_pin ?? '');
|
||||
|
||||
// If PIN is not set yet and a new PIN is provided with a valid deviceCode, set it automatically
|
||||
if ($hashedPin === '' && $pin !== '' && strlen($pin) >= 4) {
|
||||
$hashedPin = password_hash($pin, PASSWORD_DEFAULT);
|
||||
db_connect()->table('doctor_master')->where('id', $doctorRow->id)->update(['app_pin' => $hashedPin]);
|
||||
$doctorRow->app_pin = $hashedPin;
|
||||
}
|
||||
|
||||
if ($hashedPin === '') {
|
||||
return $this->response->setJSON([
|
||||
'status' => 2,
|
||||
|
|
@ -104,25 +222,29 @@ class DoctorApi extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
$clientIp = (string) $this->request->getIPAddress();
|
||||
$rateKey = 'doc_pin_' . md5($clientIp . '_' . $docId);
|
||||
$cache = service('cache');
|
||||
$failedAttempts = (int) ($cache->get($rateKey) ?? 0);
|
||||
if ($failedAttempts >= 5) {
|
||||
return $this->response->setStatusCode(429)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Verify PIN or Account Password
|
||||
$isValid = password_verify($pin, $hashedPin) || ($pin === $hashedPin);
|
||||
|
||||
if (! $isValid && ! empty($doctorRow->linked_user_id)) {
|
||||
try {
|
||||
$userModel = model(\CodeIgniter\Shield\Models\UserModel::class);
|
||||
$user = $userModel->findById((int) $doctorRow->linked_user_id);
|
||||
if ($user && method_exists($user, 'checkPassword') && $user->checkPassword($pin)) {
|
||||
$isValid = true;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
if (! $isValid) {
|
||||
$cache->save($rateKey, $failedAttempts + 1, 600);
|
||||
return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Incorrect Security PIN']);
|
||||
return $this->response->setStatusCode(401)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Incorrect Security PIN or Password.'
|
||||
]);
|
||||
}
|
||||
|
||||
$cache->delete($rateKey);
|
||||
|
||||
if (session_status() === PHP_SESSION_ACTIVE || ! session_id()) {
|
||||
session()->set('pwa_auth_doctor_id', (int) $doctorRow->id);
|
||||
}
|
||||
|
|
@ -130,9 +252,11 @@ class DoctorApi extends BaseController
|
|||
return $this->response->setJSON([
|
||||
'status' => 1,
|
||||
'message' => 'Authentication successful',
|
||||
'device_token' => $expectedDeviceToken,
|
||||
'is_intranet' => $isIntranet,
|
||||
'doctor' => [
|
||||
'id' => (int) $doctorRow->id,
|
||||
'name' => trim(($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '')),
|
||||
'name' => trim(($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '') . ' ' . ($doctorRow->p_lname ?? '')),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class NursingApi extends BaseController
|
|||
b.status, coalesce(b.bed_status, 'available') as bed_status,
|
||||
w.ward_name, c.category_name,
|
||||
i.id as ipd_id, i.ipd_code, i.p_id, i.r_doc_id, i.r_doc_name,
|
||||
p.p_fname as patient_name, p.p_code as uhid, p.mphone1, p.gender, p.age, p.dob,
|
||||
p.p_fname as patient_name, p.p_code as uhid, p.gender, p.age,
|
||||
coalesce(ipd_doc_list.doc_name, concat('Dr. ', i.r_doc_name)) as doctor_name
|
||||
FROM bed_master b
|
||||
LEFT JOIN ward_master w ON b.ward_id = w.id
|
||||
|
|
@ -106,15 +106,56 @@ class NursingApi extends BaseController
|
|||
|
||||
/**
|
||||
* GET api/v1/nursing/nurses
|
||||
* Returns sanitized active nurse staff list for PWA staff switcher (Data Minimized & Credentials Stripped)
|
||||
*/
|
||||
public function nurses()
|
||||
{
|
||||
$nurses = $this->nurseModel->getActiveNurses();
|
||||
$safeList = [];
|
||||
foreach ($nurses as $n) {
|
||||
$safeList[] = [
|
||||
'id' => (int) ($n['id'] ?? 0),
|
||||
'nurse_code' => (string) ($n['nurse_code'] ?? ''),
|
||||
'name' => (string) ($n['name'] ?? ''),
|
||||
'gender' => (string) ($n['gender'] ?? ''),
|
||||
'designation' => (string) ($n['designation'] ?? 'Staff Nurse'),
|
||||
'department' => (string) ($n['department'] ?? 'Nursing'),
|
||||
'has_pin' => ! empty($n['app_pin']),
|
||||
'is_active' => (int) ($n['is_active'] ?? 1),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 1,
|
||||
'data' => $this->nurseModel->getActiveNurses()
|
||||
'data' => $safeList,
|
||||
]);
|
||||
}
|
||||
|
||||
private function isHospitalIntranet(string $ip): bool
|
||||
{
|
||||
if (in_array($ip, ['127.0.0.1', '::1', 'localhost'], true)) {
|
||||
return true;
|
||||
}
|
||||
$privateRanges = [
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
];
|
||||
$ipLong = ip2long($ip);
|
||||
if ($ipLong === false) {
|
||||
return false;
|
||||
}
|
||||
foreach ($privateRanges as $range) {
|
||||
[$subnet, $mask] = explode('/', $range);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = ~((1 << (32 - (int) $mask)) - 1);
|
||||
if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/v1/nursing/auth/verify-pin
|
||||
*/
|
||||
|
|
@ -122,21 +163,102 @@ class NursingApi extends BaseController
|
|||
{
|
||||
$post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []);
|
||||
$nurseId = (int) ($post['nurse_id'] ?? 0);
|
||||
$nurseCode = trim((string) ($post['nurse_code'] ?? ''));
|
||||
$pin = trim((string) ($post['pin'] ?? ''));
|
||||
$nurseCode = trim((string) ($post['staff_id'] ?? $post['nurse_code'] ?? $post['user_id'] ?? $post['username'] ?? ''));
|
||||
$pin = trim((string) ($post['pin'] ?? $post['password'] ?? ''));
|
||||
$deviceCode = trim((string) ($post['device_code'] ?? ''));
|
||||
$deviceToken = trim((string) ($post['device_token'] ?? ''));
|
||||
|
||||
if ($nurseId <= 0 && $nurseCode !== '') {
|
||||
$nurseRow = db_connect()->table('nurse_master')->where('nurse_code', $nurseCode)->get()->getRowArray();
|
||||
} else {
|
||||
$nurseRow = null;
|
||||
if ($nurseId > 0) {
|
||||
$nurseRow = $this->nurseModel->getNurseById($nurseId);
|
||||
}
|
||||
|
||||
if (! $nurseRow && $nurseCode !== '') {
|
||||
// 1. Direct search by nurse_code or id
|
||||
$nurseRow = db_connect()->table('nurse_master')
|
||||
->where('nurse_code', $nurseCode)
|
||||
->orWhere('id', is_numeric($nurseCode) ? (int)$nurseCode : 0)
|
||||
->get()->getRowArray();
|
||||
|
||||
// 2. Search by linked username or employee_code
|
||||
if (! $nurseRow && db_connect()->tableExists('user_profiles')) {
|
||||
$linkedRow = db_connect()->table('user_profiles up')
|
||||
->select('n.*, u.id as linked_user_id, u.username as linked_username')
|
||||
->join('nurse_master n', 'n.id = up.nurse_id')
|
||||
->join('users u', 'u.id = up.user_id AND u.deleted_at IS NULL', 'left')
|
||||
->groupStart()
|
||||
->where('u.username', $nurseCode)
|
||||
->orWhere('up.employee_code', $nurseCode)
|
||||
->groupEnd()
|
||||
->get()->getRowArray();
|
||||
if ($linkedRow) {
|
||||
$nurseRow = $linkedRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $nurseRow) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Nurse profile not found']);
|
||||
return $this->response->setStatusCode(404)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Staff profile not found. Please verify your Staff ID / Username.'
|
||||
]);
|
||||
}
|
||||
|
||||
$clientIp = (string) $this->request->getIPAddress();
|
||||
$isIntranet = $this->isHospitalIntranet($clientIp);
|
||||
|
||||
// Device pairing check
|
||||
$expectedDeviceCode = trim((string) ($nurseRow['device_code'] ?? ''));
|
||||
if ($expectedDeviceCode === '') {
|
||||
$expectedDeviceCode = (string) random_int(100000, 999999);
|
||||
db_connect()->table('nurse_master')->where('id', $nurseRow['id'])->update(['device_code' => $expectedDeviceCode]);
|
||||
$nurseRow['device_code'] = $expectedDeviceCode;
|
||||
}
|
||||
|
||||
$expectedDeviceToken = hash_hmac('sha256', $nurseRow['id'] . '_' . ($nurseRow['nurse_code'] ?? ''), $expectedDeviceCode);
|
||||
|
||||
$isDeviceTrusted = false;
|
||||
if ($deviceToken !== '' && hash_equals($expectedDeviceToken, $deviceToken)) {
|
||||
$isDeviceTrusted = true;
|
||||
}
|
||||
|
||||
if (! $isDeviceTrusted) {
|
||||
if ($deviceCode === '') {
|
||||
return $this->response->setStatusCode(401)->setJSON([
|
||||
'status' => 0,
|
||||
'need_device_code' => true,
|
||||
'message' => 'Device Security Code is required to register this mobile device. Obtain the 6-digit code from Nursing Staff Master in HMS Admin.'
|
||||
]);
|
||||
}
|
||||
if (strcasecmp($deviceCode, $expectedDeviceCode) !== 0) {
|
||||
return $this->response->setStatusCode(401)->setJSON([
|
||||
'status' => 0,
|
||||
'need_device_code' => true,
|
||||
'message' => 'Invalid Device Security Code. Please enter the 6-digit Security Code provided in Nursing Staff Master.'
|
||||
]);
|
||||
}
|
||||
$isDeviceTrusted = true;
|
||||
}
|
||||
|
||||
$rateKey = 'nurse_pin_' . md5($clientIp . '_' . ($nurseRow['id'] ?? 0));
|
||||
$cache = service('cache');
|
||||
$failedAttempts = (int) ($cache->get($rateKey) ?? 0);
|
||||
if ($failedAttempts >= 5) {
|
||||
return $this->response->setStatusCode(429)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.',
|
||||
]);
|
||||
}
|
||||
|
||||
$hashedPin = (string) ($nurseRow['app_pin'] ?? '');
|
||||
|
||||
// If PIN is not set yet and a new PIN is provided with a valid deviceCode, set it automatically
|
||||
if ($hashedPin === '' && $pin !== '' && strlen($pin) >= 4) {
|
||||
$hashedPin = password_hash($pin, PASSWORD_DEFAULT);
|
||||
db_connect()->table('nurse_master')->where('id', $nurseRow['id'])->update(['app_pin' => $hashedPin]);
|
||||
$nurseRow['app_pin'] = $hashedPin;
|
||||
}
|
||||
|
||||
if ($hashedPin === '') {
|
||||
return $this->response->setJSON([
|
||||
'status' => 2,
|
||||
|
|
@ -150,25 +272,31 @@ class NursingApi extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
$clientIp = (string) $this->request->getIPAddress();
|
||||
$rateKey = 'nurse_pin_' . md5($clientIp . '_' . ($nurseRow['id'] ?? 0));
|
||||
$cache = service('cache');
|
||||
$failedAttempts = (int) ($cache->get($rateKey) ?? 0);
|
||||
if ($failedAttempts >= 5) {
|
||||
return $this->response->setStatusCode(429)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Verify PIN or Account Password
|
||||
$isValid = password_verify($pin, $hashedPin) || ($pin === $hashedPin);
|
||||
|
||||
// If not matched, check linked Shield user account password if linked
|
||||
if (! $isValid && ! empty($nurseRow['linked_user_id'])) {
|
||||
try {
|
||||
$userModel = model(\CodeIgniter\Shield\Models\UserModel::class);
|
||||
$user = $userModel->findById((int) $nurseRow['linked_user_id']);
|
||||
if ($user && method_exists($user, 'checkPassword') && $user->checkPassword($pin)) {
|
||||
$isValid = true;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
if (! $isValid) {
|
||||
$cache->save($rateKey, $failedAttempts + 1, 600);
|
||||
return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Incorrect Security PIN']);
|
||||
return $this->response->setStatusCode(401)->setJSON([
|
||||
'status' => 0,
|
||||
'message' => 'Incorrect Security PIN or Password.'
|
||||
]);
|
||||
}
|
||||
|
||||
$cache->delete($rateKey);
|
||||
|
||||
// Issue/return device pairing token
|
||||
if (session_status() === PHP_SESSION_ACTIVE || ! session_id()) {
|
||||
session()->set('pwa_auth_nurse_id', (int) $nurseRow['id']);
|
||||
}
|
||||
|
|
@ -176,11 +304,14 @@ class NursingApi extends BaseController
|
|||
return $this->response->setJSON([
|
||||
'status' => 1,
|
||||
'message' => 'Authentication successful',
|
||||
'device_token' => $expectedDeviceToken,
|
||||
'is_intranet' => $isIntranet,
|
||||
'nurse' => [
|
||||
'id' => (int) $nurseRow['id'],
|
||||
'nurse_code' => $nurseRow['nurse_code'],
|
||||
'name' => $nurseRow['name'],
|
||||
'designation' => $nurseRow['designation'] ?? 'Staff Nurse',
|
||||
'department' => $nurseRow['department'] ?? 'Nursing',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3195,6 +3195,7 @@ class Ipd extends BaseController
|
|||
return $this->response->setJSON([
|
||||
'update' => 1,
|
||||
'message' => $message,
|
||||
'csrf_hash' => csrf_hash(),
|
||||
'html' => view('billing/ipd/panel_discharge', $panelData),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ class Doctor extends BaseController
|
|||
$data['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$deviceCode = trim((string) ($this->request->getPost('device_code') ?? ''));
|
||||
$data['device_code'] = $deviceCode !== '' ? $deviceCode : str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$insertId = $doctorModel->insert($data);
|
||||
|
||||
if ($insertId <= 0) {
|
||||
|
|
@ -258,6 +261,11 @@ class Doctor extends BaseController
|
|||
$data['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$deviceCode = trim((string) ($this->request->getPost('device_code') ?? ''));
|
||||
if ($deviceCode !== '') {
|
||||
$data['device_code'] = $deviceCode;
|
||||
}
|
||||
|
||||
$updated = $doctorModel->updateDoctor($data, $docId);
|
||||
|
||||
return $this->response->setJSON([
|
||||
|
|
@ -267,6 +275,20 @@ class Doctor extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
public function regenerateDeviceCode()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Invalid request']);
|
||||
}
|
||||
$id = (int) ($this->request->getPost('id') ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Invalid doctor ID']);
|
||||
}
|
||||
$newCode = str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$this->db->table('doctor_master')->where('id', $id)->update(['device_code' => $newCode]);
|
||||
return $this->response->setJSON(['ok' => true, 'device_code' => $newCode]);
|
||||
}
|
||||
|
||||
public function doctorRecordSpec()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ class Nurse extends BaseController
|
|||
'is_active' => $isActive,
|
||||
];
|
||||
|
||||
$deviceCode = trim((string) ($this->request->getPost('device_code') ?? ''));
|
||||
if ($deviceCode !== '') {
|
||||
$payload['device_code'] = $deviceCode;
|
||||
} elseif ($id === 0) {
|
||||
$payload['device_code'] = str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
if ($appPin !== '') {
|
||||
$payload['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
|
@ -88,6 +95,17 @@ class Nurse extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
public function regenerate_device_code($id = null)
|
||||
{
|
||||
$id = (int) ($id ?? $this->request->getPost('id') ?? 0);
|
||||
if ($id <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Invalid nurse ID.']);
|
||||
}
|
||||
$newCode = str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$this->nurseModel->updateNurse($id, ['device_code' => $newCode]);
|
||||
return $this->response->setJSON(['ok' => true, 'device_code' => $newCode]);
|
||||
}
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
$id = (int) ($id ?? $this->request->getPost('id') ?? 0);
|
||||
|
|
|
|||
|
|
@ -74,8 +74,17 @@ class UserManagement extends BaseController
|
|||
|
||||
public function store()
|
||||
{
|
||||
$username = trim((string) $this->request->getPost('username'));
|
||||
$email = trim((string) $this->request->getPost('email'));
|
||||
if ($email === '' && $username !== '') {
|
||||
$email = strtolower($username) . '@hospital.local';
|
||||
$postData = $this->request->getPost();
|
||||
$postData['email'] = $email;
|
||||
$this->request->setGlobal('post', $postData);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'username' => 'required|min_length[3]|max_length[30]|regex_match[/^[a-zA-Z0-9.]+$/]',
|
||||
'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',
|
||||
|
|
@ -304,8 +313,8 @@ class UserManagement extends BaseController
|
|||
];
|
||||
|
||||
$errors = [];
|
||||
if ($username === '' || strlen($username) < 3 || strlen($username) > 30 || preg_match('/\A[a-zA-Z0-9\.]+\z/', $username) !== 1) {
|
||||
$errors['username'] = 'Login ID must be 3-30 chars and only letters, numbers, dot.';
|
||||
if ($username === '' || strlen($username) < 3 || strlen($username) > 30 || preg_match('/\A[a-zA-Z0-9._-]+\z/', $username) !== 1) {
|
||||
$errors['username'] = 'Login ID must be 3-30 chars and only letters, numbers, dot (.), hyphen (-), underscore (_).';
|
||||
}
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors['email'] = 'Valid email is required.';
|
||||
|
|
|
|||
72
app/Filters/SecureHeadersFilter.php
Normal file
72
app/Filters/SecureHeadersFilter.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Enterprise Application Security Headers Filter (WASA & OWASP Compliant)
|
||||
*/
|
||||
class SecureHeadersFilter implements FilterInterface
|
||||
{
|
||||
/**
|
||||
* Recommended security headers matching OWASP Secure Headers Project
|
||||
* and WASA (Web Application Security Assessment) requirements.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $headers = [
|
||||
// Protect against clickjacking
|
||||
'X-Frame-Options' => 'SAMEORIGIN',
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
|
||||
// Restrict file downloads in legacy IE
|
||||
'X-Download-Options' => 'noopen',
|
||||
|
||||
// Restrict Adobe Flash / PDF cross-domain policies
|
||||
'X-Permitted-Cross-Domain-Policies' => 'none',
|
||||
|
||||
// Protect referrer leakage
|
||||
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||
|
||||
// Enforce HTTPS
|
||||
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload',
|
||||
|
||||
// Restrict unnecessary browser device capabilities
|
||||
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=(), payment=()',
|
||||
|
||||
// Cross-Origin isolation protections
|
||||
'Cross-Origin-Opener-Policy' => 'same-origin-allow-popups',
|
||||
|
||||
// Content Security Policy (allowing self, inline scripts, styles, data URIs for canvas/charts)
|
||||
'Content-Security-Policy' => "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; frame-ancestors 'self';",
|
||||
];
|
||||
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
foreach ($this->headers as $header => $value) {
|
||||
// Only set if not already explicitly defined by a specific controller
|
||||
if (! $response->hasHeader($header)) {
|
||||
$response->setHeader($header, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// Provide fresh CSRF token header for AJAX/fetch calls
|
||||
if (function_exists('csrf_hash')) {
|
||||
$response->setHeader('X-CSRF-TOKEN', csrf_hash());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ class DoctorModel
|
|||
if (! in_array('app_pin', $fields, true)) {
|
||||
$this->db->query("ALTER TABLE doctor_master ADD COLUMN app_pin VARCHAR(255) NULL");
|
||||
}
|
||||
if (! in_array('device_code', $fields, true)) {
|
||||
$this->db->query("ALTER TABLE doctor_master ADD COLUMN device_code VARCHAR(50) NULL");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -36,12 +39,28 @@ class DoctorModel
|
|||
$builder->select('d.*');
|
||||
}
|
||||
|
||||
return $builder->get()->getResult();
|
||||
$list = $builder->get()->getResult();
|
||||
foreach ($list as &$doc) {
|
||||
if (empty($doc->device_code)) {
|
||||
$newCode = (string) random_int(100000, 999999);
|
||||
$this->db->table('doctor_master')->where('id', $doc->id)->update(['device_code' => $newCode]);
|
||||
$doc->device_code = $newCode;
|
||||
}
|
||||
}
|
||||
unset($doc);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function getDoctorById(int $id): array
|
||||
{
|
||||
return $this->db->table('doctor_master')->where('id', $id)->get()->getResult();
|
||||
$rows = $this->db->table('doctor_master')->where('id', $id)->get()->getResult();
|
||||
if (! empty($rows) && empty($rows[0]->device_code)) {
|
||||
$newCode = (string) random_int(100000, 999999);
|
||||
$this->db->table('doctor_master')->where('id', $id)->update(['device_code' => $newCode]);
|
||||
$rows[0]->device_code = $newCode;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function insert(array $data): int
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ class NurseModel
|
|||
if (! in_array('app_pin', $fields, true)) {
|
||||
$this->db->query("ALTER TABLE nurse_master ADD COLUMN app_pin VARCHAR(255) NULL AFTER email");
|
||||
}
|
||||
if (! in_array('device_code', $fields, true)) {
|
||||
$this->db->query("ALTER TABLE nurse_master ADD COLUMN device_code VARCHAR(50) NULL AFTER app_pin");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -81,7 +84,17 @@ class NurseModel
|
|||
->groupEnd();
|
||||
}
|
||||
|
||||
return $builder->orderBy('n.name', 'ASC')->get()->getResultArray();
|
||||
$list = $builder->orderBy('n.name', 'ASC')->get()->getResultArray();
|
||||
foreach ($list as &$nurse) {
|
||||
if (empty($nurse['device_code'])) {
|
||||
$newCode = (string) random_int(100000, 999999);
|
||||
$this->db->table('nurse_master')->where('id', $nurse['id'])->update(['device_code' => $newCode]);
|
||||
$nurse['device_code'] = $newCode;
|
||||
}
|
||||
}
|
||||
unset($nurse);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function getActiveNurses(): array
|
||||
|
|
@ -96,6 +109,11 @@ class NurseModel
|
|||
public function getNurseById(int $id): ?array
|
||||
{
|
||||
$row = $this->db->table('nurse_master')->where('id', $id)->get()->getRowArray();
|
||||
if ($row && empty($row['device_code'])) {
|
||||
$newCode = (string) random_int(100000, 999999);
|
||||
$this->db->table('nurse_master')->where('id', $id)->update(['device_code' => $newCode]);
|
||||
$row['device_code'] = $newCode;
|
||||
}
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +121,9 @@ class NurseModel
|
|||
{
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
if (empty($data['device_code'])) {
|
||||
$data['device_code'] = (string) random_int(100000, 999999);
|
||||
}
|
||||
$this->db->table('nurse_master')->insert($data);
|
||||
return (int) $this->db->insertID();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,18 +26,28 @@
|
|||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold" for="username">Login ID / Username <span class="text-danger">*</span></label>
|
||||
<input class="form-control" id="username" name="username" type="text" maxlength="30" placeholder="e.g. dr.sharma or rajesh.k" value="<?= esc($formData['username'] ?? old('username')) ?>" required>
|
||||
<div class="form-text">3-30 characters, letters, numbers, and dot.</div>
|
||||
<input class="form-control" id="username" name="username" type="text" maxlength="30" placeholder="e.g. nur-637 or dr.sharma" value="<?= esc($formData['username'] ?? old('username')) ?>" required>
|
||||
<div class="form-text">3-30 characters, letters, numbers, dot (.), hyphen (-), underscore (_).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold" for="email">Email Address <span class="text-danger">*</span></label>
|
||||
<input class="form-control" id="email" name="email" type="email" placeholder="staff@hospital.com" value="<?= esc($formData['email'] ?? old('email')) ?>" required>
|
||||
<div class="form-text">Used for notifications and password recovery.</div>
|
||||
<input class="form-control" id="email" name="email" type="email" placeholder="staff@hospital.com (or leave blank for auto)" value="<?= esc($formData['email'] ?? old('email')) ?>">
|
||||
<div class="form-text">Used for notifications. Leave blank for auto-generated internal account.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold" for="password">Password <span class="text-danger">*</span></label>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="form-label fw-semibold mb-0" for="password">Password <span class="text-danger">*</span></label>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" id="btn_quick_pwd" style="font-size: 11px;">
|
||||
<i class="bi bi-magic me-1"></i>Set Staff@123
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="password" name="password" type="password" minlength="8" placeholder="Minimum 8 characters" required>
|
||||
<div class="form-text">At least 8 characters.</div>
|
||||
<button class="btn btn-outline-secondary" type="button" id="btn_toggle_pwd" title="Toggle Show/Hide Password">
|
||||
<i class="bi bi-eye" id="pwd_eye_icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">At least 8 characters. Minimum standard security.</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
|
|
@ -171,6 +181,28 @@
|
|||
$('#role').off('change.roleSelect').on('change.roleSelect', handleRoleChange);
|
||||
handleRoleChange();
|
||||
|
||||
// Quick password and toggle visibility
|
||||
$('#btn_quick_pwd').off('click').on('click', function() {
|
||||
$('#password').val('Staff@123').trigger('input');
|
||||
if (typeof notify === 'function') {
|
||||
notify('info', 'Password Set', 'Default password set to Staff@123');
|
||||
}
|
||||
});
|
||||
|
||||
$('#btn_toggle_pwd').off('click').on('click', function() {
|
||||
var pwdInput = document.getElementById('password');
|
||||
var icon = document.getElementById('pwd_eye_icon');
|
||||
if (pwdInput && icon) {
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-populate when selecting a Doctor
|
||||
$('#doctor_id').off('change.docSelect').on('change.docSelect', function() {
|
||||
var docId = parseInt($(this).val() || '0', 10);
|
||||
|
|
@ -183,16 +215,22 @@
|
|||
var fname = (doc.p_fname || '').trim();
|
||||
var lname = (doc.p_lname || '').trim();
|
||||
var fullName = (title + ' ' + fname + ' ' + lname).trim();
|
||||
var suggestedUser = 'dr.' + (fname ? fname.toLowerCase().replace(/[^a-z0-9._-]/g, '') : 'doc');
|
||||
|
||||
if (!$('#person_name').val()) $('#person_name').val(fullName);
|
||||
if (!$('#email').val() && doc.email1) $('#email').val(doc.email1);
|
||||
if (!$('#phone_no').val() && doc.mphone1) $('#phone_no').val(doc.mphone1);
|
||||
if (!$('#designation').val()) $('#designation').val('Doctor / Consultant');
|
||||
if (!$('#department').val()) $('#department').val('Clinical / OPD');
|
||||
if (!$('#username').val() && fname) {
|
||||
var suggestedUser = 'dr.' + fname.toLowerCase();
|
||||
$('#username').val(suggestedUser);
|
||||
}
|
||||
if (!$('#email').val()) {
|
||||
if (doc.email1) {
|
||||
$('#email').val(doc.email1);
|
||||
} else {
|
||||
$('#email').val(suggestedUser + '@hospital.local');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-populate when selecting a Nurse
|
||||
|
|
@ -203,14 +241,22 @@
|
|||
var nurse = nursesData.find(function(n) { return parseInt(n.id, 10) === nurseId; });
|
||||
if (!nurse) return;
|
||||
|
||||
var uName = (nurse.nurse_code ? nurse.nurse_code.toLowerCase().replace(/[^a-z0-9._-]/g, '') : '');
|
||||
|
||||
if (!$('#person_name').val() && nurse.name) $('#person_name').val(nurse.name);
|
||||
if (!$('#email').val() && nurse.email) $('#email').val(nurse.email);
|
||||
if (!$('#phone_no').val() && nurse.contact_no) $('#phone_no').val(nurse.contact_no);
|
||||
if (!$('#employee_code').val() && nurse.nurse_code) $('#employee_code').val(nurse.nurse_code);
|
||||
if (!$('#designation').val()) $('#designation').val(nurse.designation || 'Staff Nurse');
|
||||
if (!$('#department').val()) $('#department').val(nurse.department || 'Nursing');
|
||||
if (!$('#username').val() && nurse.nurse_code) {
|
||||
$('#username').val(nurse.nurse_code.toLowerCase());
|
||||
if (!$('#username').val() && uName) {
|
||||
$('#username').val(uName);
|
||||
}
|
||||
if (!$('#email').val()) {
|
||||
if (nurse.email) {
|
||||
$('#email').val(nurse.email);
|
||||
} else if (uName) {
|
||||
$('#email').val(uName + '@hospital.local');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -220,10 +266,44 @@
|
|||
$('#nurse_id').trigger('change.nurseSelect');
|
||||
<?php endif; ?>
|
||||
|
||||
// AJAX Form submission
|
||||
// AJAX Form submission with client-side validation
|
||||
$(form).off('submit.userCreate').on('submit.userCreate', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var u = ($('#username').val() || '').trim();
|
||||
var p = ($('#password').val() || '').trim();
|
||||
var e = ($('#email').val() || '').trim();
|
||||
var r = ($('#role').val() || '').trim();
|
||||
|
||||
if (!u || u.length < 3) {
|
||||
if (typeof notify === 'function') notify('warning', 'Validation', 'Please enter a valid Login ID (minimum 3 characters).');
|
||||
$('#username').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(u)) {
|
||||
if (typeof notify === 'function') notify('warning', 'Validation', 'Login ID can only contain letters, numbers, dot, hyphen, or underscore.');
|
||||
$('#username').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r) {
|
||||
if (typeof notify === 'function') notify('warning', 'Validation', 'Please select an assigned role.');
|
||||
$('#role').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!p || p.length < 8) {
|
||||
if (typeof notify === 'function') notify('warning', 'Validation', 'Password is required and must be at least 8 characters. Click "Set Staff@123" for quick fill.');
|
||||
$('#password').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e) {
|
||||
e = u.toLowerCase() + '@hospital.local';
|
||||
$('#email').val(e);
|
||||
}
|
||||
|
||||
$.post($(form).attr('action'), $(form).serialize())
|
||||
.done(function(html) {
|
||||
$('#maindiv').html(html);
|
||||
|
|
@ -232,7 +312,9 @@
|
|||
}
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
if (typeof notify === 'function') {
|
||||
notify('error', 'Create User', (xhr.responseJSON && xhr.responseJSON.error_text) || 'Request failed. Please check form validation.');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
<th>Name</th>
|
||||
<th>Phone Number</th>
|
||||
<th>Email</th>
|
||||
<th>Device Code</th>
|
||||
<th>User Account</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
|
|
@ -35,6 +36,18 @@
|
|||
<td><?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?></td>
|
||||
<td><?= esc($row->mphone1 ?? '') ?></td>
|
||||
<td><?= esc($row->email1 ?? '') ?></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="badge bg-warning-subtle text-dark border border-warning font-monospace fs-6 px-2 py-1">
|
||||
<i class="bi bi-shield-lock me-1 text-warning-emphasis"></i><?= esc($row->device_code ?? '-') ?>
|
||||
</span>
|
||||
<?php if (! empty($row->device_code)) : ?>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm p-0 px-1 btn-copy-code" data-code="<?= esc($row->device_code) ?>" title="Copy Device Code">
|
||||
<i class="bi bi-clipboard" style="font-size: 11px;"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (! empty($row->linked_username)) : ?>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle" title="Linked User Account">
|
||||
|
|
@ -47,8 +60,8 @@
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-dark btn-sm me-1 btn-qr-doctor" data-id="<?= (int)$row->id ?>" data-name="<?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?>">
|
||||
<i class="bi bi-qr-code"></i> QR
|
||||
<button type="button" class="btn btn-outline-dark btn-sm me-1 btn-qr-doctor" data-id="<?= (int)$row->id ?>" data-name="<?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?>" data-device="<?= esc($row->device_code ?? '') ?>">
|
||||
<i class="bi bi-qr-code"></i> App Access
|
||||
</button>
|
||||
<button onclick="load_form_div('<?= base_url('setting/admin/doctor/' . $row->id) ?>','maindiv','<?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?>');" type="button" class="btn btn-success btn-sm">Edit</button>
|
||||
</td>
|
||||
|
|
@ -56,7 +69,7 @@
|
|||
<?php endforeach ?>
|
||||
<?php else : ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">No doctors found.</td>
|
||||
<td colspan="6" class="text-center text-muted">No doctors found.</td>
|
||||
</tr>
|
||||
<?php endif ?>
|
||||
</tbody>
|
||||
|
|
@ -65,21 +78,42 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Doctor App QR Code -->
|
||||
<!-- Modal for Doctor App Pairing & QR Code -->
|
||||
<div class="modal fade" id="doctorQrModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content text-center shadow-lg border-0">
|
||||
<div class="modal-header bg-primary text-white py-2">
|
||||
<h5 class="modal-title fs-6 mb-0" id="docQrModalTitle"><i class="bi bi-qr-code-scan me-1"></i> Doctor App QR Code</h5>
|
||||
<h5 class="modal-title fs-6 mb-0" id="docQrModalTitle"><i class="bi bi-phone me-1"></i> DoctorCare Mobile App Access</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body py-3">
|
||||
<h6 class="fw-bold mb-1 text-dark" id="qr_doc_name">Doctor Name</h6>
|
||||
<span class="badge bg-primary mb-3">Doctor PWA Access</span>
|
||||
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
|
||||
<img id="qr_doc_code_img" src="" alt="Doctor App QR Code" style="width: 180px; height: 180px;" />
|
||||
<div class="mb-3">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle"><i class="bi bi-shield-check me-1"></i>Device Auth Secured</span>
|
||||
</div>
|
||||
<p class="small text-muted mb-0" style="font-size: 11px;">Scan with mobile phone to launch <strong>DoctorCare PWA</strong> for this doctor profile.</p>
|
||||
|
||||
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
|
||||
<img id="qr_doc_code_img" src="" alt="Doctor App QR Code" style="width: 170px; height: 170px;" />
|
||||
</div>
|
||||
|
||||
<div class="card bg-light border-0 text-start mx-auto p-2 mb-2" style="max-width: 340px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Doctor ID / Code:</small>
|
||||
<span class="fw-bold font-monospace text-primary" id="qr_card_doc_id">-</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Device Security Code:</small>
|
||||
<span class="badge bg-warning text-dark font-monospace fs-6 px-2 py-0" id="qr_card_doc_device">-</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">App Security PIN:</small>
|
||||
<span class="small text-secondary fst-italic">Configured in Master</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mb-0" style="font-size: 11px;">
|
||||
<i class="bi bi-info-circle me-1"></i> Scan to open the app on mobile, then enter the <strong>Doctor ID</strong>, <strong>Device Security Code</strong>, and <strong>PIN</strong> to pair this phone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer bg-light py-2 justify-content-center">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||
|
|
@ -97,10 +131,15 @@
|
|||
if (qrBtn) {
|
||||
var docId = qrBtn.getAttribute('data-id') || '0';
|
||||
var name = qrBtn.getAttribute('data-name') || '';
|
||||
var appUrl = doctorAppBaseUrl + '?doctor_id=' + encodeURIComponent(docId);
|
||||
var deviceCode = qrBtn.getAttribute('data-device') || '';
|
||||
|
||||
// Clean app URL - no query parameters leaking doctor identity
|
||||
var appUrl = doctorAppBaseUrl;
|
||||
var qrApiUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl);
|
||||
|
||||
document.getElementById('qr_doc_name').textContent = name;
|
||||
document.getElementById('qr_card_doc_id').textContent = 'DOC-' + docId;
|
||||
document.getElementById('qr_card_doc_device').textContent = deviceCode || 'N/A';
|
||||
document.getElementById('qr_doc_code_img').src = qrApiUrl;
|
||||
|
||||
var modalEl = document.getElementById('doctorQrModal');
|
||||
|
|
@ -115,6 +154,18 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var copyBtn = e.target.closest('.btn-copy-code');
|
||||
if (copyBtn) {
|
||||
var code = copyBtn.getAttribute('data-code') || '';
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code).then(function() {
|
||||
alert('Device Security Code copied: ' + code);
|
||||
}).catch(function() {
|
||||
prompt('Copy Device Security Code:', code);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -224,6 +224,28 @@ if (!empty($data) && !empty($data[0]) && !empty($data[0]->dob)) {
|
|||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<div class="p-2 bg-warning-subtle border border-warning rounded">
|
||||
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-key me-1"></i> Doctor Mobile App Security PIN</label>
|
||||
<input type="password" maxlength="6" class="form-control form-control-sm" id="doctor_app_pin" name="app_pin" placeholder="Set or update 4-6 digit PIN">
|
||||
<small class="text-muted" style="font-size: 10px;">Used by doctor to login/unlock DoctorCare PWA App</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-2 bg-light border rounded">
|
||||
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-shield-lock me-1"></i> Device Security Code</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" maxlength="10" class="form-control form-control-sm font-monospace fw-bold" id="doctor_device_code" name="device_code" value="<?= esc($data[0]->device_code ?? '') ?>" placeholder="6-digit security code">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_regen_doc_code" title="Generate New Code">
|
||||
<i class="bi bi-arrow-repeat"></i> New
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted" style="font-size: 10px;">Provided by HMS Admin for doctor's first-time mobile pairing</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
|
@ -500,5 +522,22 @@ if (!empty($data) && !empty($data[0]) && !empty($data[0]->dob)) {
|
|||
updateCsrf(data);
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
$('#btn_regen_doc_code').on('click', function() {
|
||||
var docId = parseInt($('#doc_id').val(), 10) || 0;
|
||||
if (docId > 0) {
|
||||
$.post('<?= base_url('setting/admin/doctor/regenerate-device-code') ?>', { id: docId }, function(resp) {
|
||||
if (resp && resp.ok && resp.device_code) {
|
||||
$('#doctor_device_code').val(resp.device_code);
|
||||
} else {
|
||||
$('#doctor_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
}
|
||||
}, 'json').fail(function() {
|
||||
$('#doctor_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
});
|
||||
} else {
|
||||
$('#doctor_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?php endif ?>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
<th style="width: 120px;">Designation</th>
|
||||
<th>Qualification</th>
|
||||
<th>Contact / Mobile</th>
|
||||
<th style="width: 130px;">Device Code</th>
|
||||
<th style="width: 120px;">User Account</th>
|
||||
<th style="width: 90px;">Status</th>
|
||||
<th style="width: 110px;" class="text-end pe-3">Actions</th>
|
||||
|
|
@ -76,6 +77,18 @@
|
|||
<td><span class="badge bg-secondary-subtle text-dark border"><?= esc($nurse['designation'] ?: 'Staff Nurse') ?></span></td>
|
||||
<td><?= esc($nurse['qualification'] ?: '-') ?></td>
|
||||
<td><?= esc($nurse['contact_no'] ?: '-') ?></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="badge bg-warning-subtle text-dark border border-warning font-monospace fs-6 px-2 py-1" id="device_code_badge_<?= (int)$nurse['id'] ?>">
|
||||
<i class="bi bi-shield-lock me-1 text-warning-emphasis"></i><?= esc($nurse['device_code'] ?? '-') ?>
|
||||
</span>
|
||||
<?php if (!empty($nurse['device_code'])): ?>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm p-0 px-1 btn-copy-code" data-code="<?= esc($nurse['device_code']) ?>" title="Copy Device Code">
|
||||
<i class="bi bi-clipboard" style="font-size: 11px;"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($nurse['linked_username'])): ?>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle" title="Linked User Account">
|
||||
|
|
@ -95,8 +108,8 @@
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm py-0 px-2 me-1 btn-qr-nurse" data-code="<?= esc($nurse['nurse_code']) ?>" data-name="<?= esc($nurse['name']) ?>">
|
||||
<i class="bi bi-qr-code"></i> QR
|
||||
<button type="button" class="btn btn-outline-dark btn-sm py-0 px-2 me-1 btn-qr-nurse" data-code="<?= esc($nurse['nurse_code']) ?>" data-name="<?= esc($nurse['name']) ?>" data-device="<?= esc($nurse['device_code'] ?? '') ?>">
|
||||
<i class="bi bi-qr-code"></i> App Access
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm py-0 px-2 btn-edit-nurse" data-id="<?= (int)$nurse['id'] ?>">
|
||||
<i class="bi bi-pencil-square"></i> Edit
|
||||
|
|
@ -201,12 +214,24 @@
|
|||
<input type="email" class="form-control form-control-sm" id="nurse_email" name="email" placeholder="Email address">
|
||||
</div>
|
||||
|
||||
<!-- App Security PIN Section -->
|
||||
<!-- App Security PIN & Device Code Section -->
|
||||
<div class="col-md-6 mt-2">
|
||||
<div class="p-2 bg-warning-subtle border border-warning rounded">
|
||||
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-key me-1"></i> Mobile App Security PIN</label>
|
||||
<input type="password" maxlength="6" class="form-control form-control-sm" id="nurse_app_pin" name="app_pin" placeholder="Set or update 4-6 digit PIN">
|
||||
<small class="text-muted" style="font-size: 10px;">Used by nurse to login to NursingCare PWA App</small>
|
||||
<small class="text-muted" style="font-size: 10px;">Used by nurse to login/unlock NursingCare PWA App</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-2">
|
||||
<div class="p-2 bg-light border rounded">
|
||||
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-shield-lock me-1"></i> Device Security Code</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" maxlength="10" class="form-control form-control-sm font-monospace fw-bold" id="nurse_device_code" name="device_code" placeholder="6-digit security code">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_regen_nurse_code" title="Generate New Code">
|
||||
<i class="bi bi-arrow-repeat"></i> New
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted" style="font-size: 10px;">Provided by HMS Admin for first-time mobile device pairing</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -222,21 +247,43 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Nurse App QR Code -->
|
||||
<!-- Modal for Nurse App Pairing & QR Code -->
|
||||
<div class="modal fade" id="nurseQrModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content text-center">
|
||||
<div class="modal-header bg-light py-2">
|
||||
<h5 class="modal-title text-primary fs-6 mb-0" id="qrModalTitle"><i class="bi bi-qr-code-scan me-1"></i> Nurse App QR Code</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content text-center shadow-lg border-0">
|
||||
<div class="modal-header bg-primary text-white py-2">
|
||||
<h5 class="modal-title fs-6 mb-0" id="qrModalTitle"><i class="bi bi-phone me-1"></i> NursingCare Mobile App Access</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body py-3">
|
||||
<h6 class="fw-bold mb-0 text-dark" id="qr_nurse_name">Staff Nurse</h6>
|
||||
<span class="badge bg-secondary mb-3" id="qr_nurse_code">NUR-001</span>
|
||||
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
|
||||
<img id="qr_code_img" src="" alt="Nurse App QR Code" style="width: 180px; height: 180px;" />
|
||||
<h6 class="fw-bold mb-1 text-dark" id="qr_nurse_name">Staff Nurse</h6>
|
||||
<div class="mb-3">
|
||||
<span class="badge bg-secondary me-1" id="qr_nurse_code">NUR-001</span>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle"><i class="bi bi-shield-check me-1"></i>Device Auth Secured</span>
|
||||
</div>
|
||||
<p class="small text-muted mb-0" style="font-size: 11px;">Scan with mobile phone to open <strong>NursingCare PWA</strong> for this nurse profile.</p>
|
||||
|
||||
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
|
||||
<img id="qr_code_img" src="" alt="Nurse App QR Code" style="width: 170px; height: 170px;" />
|
||||
</div>
|
||||
|
||||
<div class="card bg-light border-0 text-start mx-auto p-2 mb-2" style="max-width: 340px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Staff / User ID:</small>
|
||||
<span class="fw-bold font-monospace text-primary" id="qr_card_nurse_code">-</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Device Security Code:</small>
|
||||
<span class="badge bg-warning text-dark font-monospace fs-6 px-2 py-0" id="qr_card_device_code">-</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">App Security PIN:</small>
|
||||
<span class="small text-secondary fst-italic">Configured in Master</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mb-0" style="font-size: 11px;">
|
||||
<i class="bi bi-info-circle me-1"></i> Scan to open the app on mobile, then enter the <strong>Staff ID</strong>, <strong>Device Security Code</strong>, and <strong>PIN</strong> to pair this phone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer bg-light py-2 justify-content-center">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||
|
|
@ -249,6 +296,7 @@
|
|||
(function() {
|
||||
var saveUrl = '<?= base_url('setting/admin/nurse/save') ?>';
|
||||
var deleteUrlBase = '<?= base_url('setting/admin/nurse/delete') ?>';
|
||||
var regenUrl = '<?= base_url('setting/admin/nurse/regenerate-device-code') ?>';
|
||||
var appBaseUrl = '<?= base_url('app/nursing') ?>';
|
||||
|
||||
function resetNurseForm() {
|
||||
|
|
@ -263,6 +311,7 @@
|
|||
$('#nurse_contact_no').val('');
|
||||
$('#nurse_email').val('');
|
||||
$('#nurse_app_pin').val('');
|
||||
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
$('#nurse_department').val('Nursing');
|
||||
$('#nurse_is_active').val('1');
|
||||
$('#nurse_form_status').addClass('d-none').text('');
|
||||
|
|
@ -272,11 +321,16 @@
|
|||
$(document).on('click', '.btn-qr-nurse', function() {
|
||||
var code = $(this).data('code') || '';
|
||||
var name = $(this).data('name') || '';
|
||||
var appUrl = appBaseUrl + '?nurse_code=' + encodeURIComponent(code);
|
||||
var deviceCode = $(this).data('device') || '';
|
||||
|
||||
// Clean URL - No nurse credentials leaked in the public QR code
|
||||
var appUrl = appBaseUrl;
|
||||
var qrApiUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl);
|
||||
|
||||
$('#qr_nurse_name').text(name);
|
||||
$('#qr_nurse_code').text(code);
|
||||
$('#qr_card_nurse_code').text(code);
|
||||
$('#qr_card_device_code').text(deviceCode || 'N/A');
|
||||
$('#qr_code_img').attr('src', qrApiUrl);
|
||||
|
||||
var modalEl = document.getElementById('nurseQrModal');
|
||||
|
|
@ -287,6 +341,33 @@
|
|||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-copy-code', function() {
|
||||
var code = $(this).data('code') || '';
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code).then(function() {
|
||||
alert('Device Security Code copied: ' + code);
|
||||
}).catch(function() {
|
||||
prompt('Copy Device Security Code:', code);
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn_regen_nurse_code').on('click', function() {
|
||||
var nurseId = parseInt($('#nurse_id').val(), 10) || 0;
|
||||
if (nurseId > 0) {
|
||||
$.post(regenUrl + '/' + nurseId, {}, function(resp) {
|
||||
if (resp && resp.ok && resp.device_code) {
|
||||
$('#nurse_device_code').val(resp.device_code);
|
||||
} else {
|
||||
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
}
|
||||
}, 'json').fail(function() {
|
||||
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
});
|
||||
} else {
|
||||
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
|
||||
}
|
||||
});
|
||||
|
||||
$('#btn_add_nurse').on('click', function() {
|
||||
resetNurseForm();
|
||||
});
|
||||
|
|
@ -308,6 +389,7 @@
|
|||
$('#nurse_qualification').val(data.qualification || '');
|
||||
$('#nurse_contact_no').val(data.contact_no || '');
|
||||
$('#nurse_email').val(data.email || '');
|
||||
$('#nurse_device_code').val(data.device_code || Math.floor(100000 + Math.random() * 900000).toString());
|
||||
$('#nurse_department').val(data.department || 'Nursing');
|
||||
$('#nurse_is_active').val(data.is_active !== undefined ? data.is_active : '1');
|
||||
$('#nurse_form_status').addClass('d-none').text('');
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
<link href="assets/img/favicon.png" rel="alternate icon" type="image/png">
|
||||
<link href="assets/img/apple-touch-icon.png" rel="apple-touch-icon">
|
||||
|
||||
<meta name="csrf-token-name" content="<?= csrf_token() ?>">
|
||||
<meta name="csrf-token" content="<?= csrf_hash() ?>">
|
||||
<meta name="csrf-header" content="<?= config('Security')->headerName ?>">
|
||||
|
||||
<link href="https://fonts.gstatic.com" rel="preconnect">
|
||||
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i|Nunito:300,300i,400,400i,600,600i,700,700i|Poppins:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||
|
||||
|
|
@ -896,6 +900,7 @@
|
|||
<script src="assets/vendor/tinymce/tinymce.min.js"></script>
|
||||
<script src="assets/vendor/php-email-form/validate.js"></script>
|
||||
|
||||
<script src="assets/js/csrf-guard.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<?php
|
||||
$csrfName = csrf_token();
|
||||
$csrfHash = csrf_hash();
|
||||
$discharge = $discharge_info[0] ?? null;
|
||||
$ipd = $ipd_info ?? null;
|
||||
$canEditDischarge = (bool) ($can_edit_discharge ?? false);
|
||||
|
|
@ -168,8 +170,13 @@ $canManageIpdStatus = auth()->user()?->can('billing.ipd.status.manage') ?? false
|
|||
}
|
||||
|
||||
var ipdId = <?= $ipdId ?>;
|
||||
var csrfName = '<?= esc($csrfName) ?>';
|
||||
var csrfHash = '<?= esc($csrfHash) ?>';
|
||||
|
||||
function refreshDischargeTab(response) {
|
||||
if (response && response.csrf_hash) {
|
||||
csrfHash = response.csrf_hash;
|
||||
}
|
||||
if (response && Number(response.update || 0) === 1 && response.html) {
|
||||
$('#tab_discharge_content').html(response.html);
|
||||
if (typeof window.notify === 'function') {
|
||||
|
|
@ -185,10 +192,17 @@ $canManageIpdStatus = auth()->user()?->can('billing.ipd.status.manage') ?? false
|
|||
}
|
||||
|
||||
function post(url, payload) {
|
||||
payload = payload || {};
|
||||
if (typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
payload[csrfName] = csrfHash;
|
||||
}
|
||||
return $.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfHash
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,59 @@ if ($ageLabel === '') {
|
|||
var baseUrl = '<?php echo rtrim(base_url(), '/'); ?>/';
|
||||
var invoiceId = '<?php echo htmlspecialchars((string) ($invoice_id ?? $invoice->inv_id ?? $invoice->id ?? '0')); ?>';
|
||||
var labType = '<?php echo htmlspecialchars((string) ($lab_type ?? '5')); ?>';
|
||||
var csrfTokenName = '<?php echo esc(csrf_token()); ?>';
|
||||
var csrfHash = '<?php echo esc(csrf_hash()); ?>';
|
||||
|
||||
// Auto-inject CSRF token into all fetch and jQuery requests within this view
|
||||
(function() {
|
||||
if (window.jQuery) {
|
||||
window.jQuery.ajaxPrefilter(function(options, originalOptions, jqXHR) {
|
||||
var method = (options.type || 'GET').toUpperCase();
|
||||
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
||||
jqXHR.setRequestHeader('X-CSRF-TOKEN', csrfHash);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var originalFetch = window.fetch;
|
||||
window.fetch = function(url, options) {
|
||||
options = options || {};
|
||||
var method = (options.method || 'GET').toUpperCase();
|
||||
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
||||
options.headers = options.headers || {};
|
||||
if (options.headers instanceof Headers) {
|
||||
if (!options.headers.has('X-CSRF-TOKEN')) {
|
||||
options.headers.set('X-CSRF-TOKEN', csrfHash);
|
||||
}
|
||||
} else if (typeof options.headers === 'object') {
|
||||
if (!options.headers['X-CSRF-TOKEN']) {
|
||||
options.headers['X-CSRF-TOKEN'] = csrfHash;
|
||||
}
|
||||
}
|
||||
if (options.body instanceof FormData) {
|
||||
if (!options.body.has(csrfTokenName)) {
|
||||
options.body.append(csrfTokenName, csrfHash);
|
||||
}
|
||||
} else if (typeof options.body === 'string' && options.body.indexOf('=') !== -1) {
|
||||
try {
|
||||
var params = new URLSearchParams(options.body);
|
||||
if (!params.has(csrfTokenName)) {
|
||||
params.append(csrfTokenName, csrfHash);
|
||||
options.body = params.toString();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch.call(this, url, options).then(function(response) {
|
||||
var newHeader = response.headers.get('X-CSRF-TOKEN') || response.headers.get('x-csrf-token');
|
||||
if (newHeader) {
|
||||
csrfHash = newHeader;
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
console.log('Page loaded - Invoice ID:', invoiceId, 'Lab Type:', labType);
|
||||
|
||||
|
|
@ -313,6 +366,7 @@ function updateLabTiming() {
|
|||
const data = new FormData();
|
||||
data.append('invoice_id', invoiceId);
|
||||
data.append('lab_type', labType);
|
||||
data.append(csrfTokenName, csrfHash);
|
||||
if (collectedTime) data.append('collected_time', collectedTime);
|
||||
if (reportedTime) data.append('reported_time', reportedTime);
|
||||
|
||||
|
|
@ -359,6 +413,7 @@ function saveCollectionTime() {
|
|||
|
||||
const data = new FormData();
|
||||
data.append('invoice_id', invoiceId);
|
||||
data.append(csrfTokenName, csrfHash);
|
||||
|
||||
fetch(baseUrl + 'diagnosis/sample-collection/' + testId + '/' + labType, {
|
||||
method: 'POST',
|
||||
|
|
@ -1200,6 +1255,7 @@ function onChangeUpdate(cb, itemId) {
|
|||
function removeTest(reqId) {
|
||||
if (confirm('Are you sure you want to remove this test?')) {
|
||||
const formData = new FormData();
|
||||
formData.append(csrfTokenName, csrfHash);
|
||||
|
||||
fetch(baseUrl + 'diagnosis/remove-test/' + reqId, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -327,25 +327,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Staff User Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small text-secondary mb-1" id="hdr_apps_staff_label">2. SELECT NURSING STAFF PROFILE</label>
|
||||
<select class="form-select form-select-sm" id="hdr_apps_nurse_select">
|
||||
<option value="">-- General / All Staff Access --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: QR Code & Access Link -->
|
||||
<!-- Step 2: App Launch QR Code & Secure Intranet Link -->
|
||||
<div class="p-3 bg-light rounded border text-center">
|
||||
<div class="fw-bold small text-dark mb-1" id="hdr_apps_target_name">Nursing Care App</div>
|
||||
<span class="badge bg-secondary mb-2" id="hdr_apps_target_code">/app/nursing</span>
|
||||
|
||||
<div class="my-2">
|
||||
<img id="hdr_apps_qr_img" src="" alt="App QR Code" style="width: 160px; height: 160px; border-radius: 8px;" class="border p-1 bg-white shadow-sm" />
|
||||
<img id="hdr_apps_qr_img" src="" alt="App QR Code" style="width: 170px; height: 170px; border-radius: 8px;" class="border p-1 bg-white shadow-sm" />
|
||||
</div>
|
||||
<div class="alert alert-info py-2 px-3 small mb-2 text-start" style="font-size: 11px;">
|
||||
<i class="bi bi-shield-lock-fill me-1 text-primary"></i> <strong>Secure Staff Authentication:</strong> Staff will be prompted to enter their <strong>Staff ID / Username</strong>, <strong>PIN / Password</strong>, and one-time <strong>Device Security Code</strong> (set in Staff Master).
|
||||
</div>
|
||||
<p class="small text-muted mb-2" style="font-size: 11px;">
|
||||
<i class="bi bi-qr-code-scan me-1"></i> Scan with Phone Camera to launch app on Mobile, or click link below to open on Computer.
|
||||
</p>
|
||||
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control form-control-sm text-center" id="hdr_apps_url_input" readonly />
|
||||
|
|
@ -373,11 +365,7 @@
|
|||
}
|
||||
|
||||
function updateHdrAppDetails() {
|
||||
var sel = document.getElementById('hdr_apps_nurse_select');
|
||||
var code = sel ? sel.value : '';
|
||||
var appBaseUrl = getAppBaseUrl();
|
||||
var paramKey = (selectedApp === 'doctor') ? 'doctor_id=' : 'nurse_code=';
|
||||
var appUrl = (selectedApp === 'pharmacy') ? appBaseUrl : (appBaseUrl + (code ? '?' + paramKey + encodeURIComponent(code) : ''));
|
||||
var appUrl = getAppBaseUrl();
|
||||
var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl);
|
||||
|
||||
var qrImg = document.getElementById('hdr_apps_qr_img');
|
||||
|
|
@ -391,15 +379,10 @@
|
|||
if (launchLink) launchLink.href = appUrl;
|
||||
|
||||
var title = (selectedApp === 'doctor') ? 'DoctorCare EMR App' : ((selectedApp === 'pharmacy') ? 'Medical Store & Pharmacy POS' : 'Nursing Care App');
|
||||
var path = (selectedApp === 'pharmacy') ? '/MedicalStore' : ('/app/' + selectedApp);
|
||||
|
||||
if (code && sel && sel.options[sel.selectedIndex]) {
|
||||
var text = sel.options[sel.selectedIndex].text;
|
||||
if (targetName) targetName.textContent = title + ': ' + text;
|
||||
if (targetCode) targetCode.textContent = code;
|
||||
} else {
|
||||
if (targetName) targetName.textContent = title;
|
||||
if (targetCode) targetCode.textContent = (selectedApp === 'pharmacy') ? '/MedicalStore' : ('/app/' + selectedApp);
|
||||
}
|
||||
if (targetCode) targetCode.textContent = path;
|
||||
}
|
||||
|
||||
function showAppsModal() {
|
||||
|
|
@ -416,53 +399,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
function loadAppStaffList() {
|
||||
var sel = document.getElementById('hdr_apps_nurse_select');
|
||||
var label = document.getElementById('hdr_apps_staff_label');
|
||||
if (!sel) return;
|
||||
|
||||
if (selectedApp === 'pharmacy') {
|
||||
if (label) label.textContent = '2. PHARMACY COUNTER / STORE ACCESS';
|
||||
sel.innerHTML = '<option value="">-- All Pharmacy Counters (Auto-detect) --</option>';
|
||||
updateHdrAppDetails();
|
||||
return;
|
||||
} else if (selectedApp === 'doctor') {
|
||||
if (label) label.textContent = '2. SELECT DOCTOR PROFILE';
|
||||
fetch('<?= base_url('api/v1/doctor/list') ?>')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(resp) {
|
||||
sel.innerHTML = '<option value="">-- General / All Doctor Access --</option>';
|
||||
if (resp && resp.status === 1 && Array.isArray(resp.data)) {
|
||||
resp.data.forEach(function(d) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = d.id;
|
||||
opt.textContent = d.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
updateHdrAppDetails();
|
||||
});
|
||||
} else {
|
||||
if (label) label.textContent = '2. SELECT NURSING STAFF PROFILE';
|
||||
fetch('<?= base_url('api/v1/nursing/nurses') ?>')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(resp) {
|
||||
sel.innerHTML = '<option value="">-- General / All Staff Access --</option>';
|
||||
if (resp && resp.status === 1 && Array.isArray(resp.data)) {
|
||||
resp.data.forEach(function(n) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = n.nurse_code;
|
||||
opt.textContent = '[' + n.nurse_code + '] ' + n.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
updateHdrAppDetails();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openAppsModal() {
|
||||
loadAppStaffList();
|
||||
updateHdrAppDetails();
|
||||
showAppsModal();
|
||||
}
|
||||
|
||||
|
|
@ -485,7 +423,7 @@
|
|||
});
|
||||
appCard.classList.remove('border-secondary', 'bg-light', 'text-dark');
|
||||
appCard.classList.add('border-primary', 'bg-primary-subtle', 'text-primary');
|
||||
loadAppStaffList();
|
||||
updateHdrAppDetails();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -505,11 +443,5 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target && e.target.id === 'hdr_apps_nurse_select') {
|
||||
updateHdrAppDetails();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -14,6 +14,10 @@
|
|||
<link href="<?= base_url('assets/img/favicon.png') ?>" rel="alternate icon" type="image/png">
|
||||
<link href="<?= base_url('assets/img/apple-touch-icon.png') ?>" rel="apple-touch-icon">
|
||||
|
||||
<meta name="csrf-token-name" content="<?= csrf_token() ?>">
|
||||
<meta name="csrf-token" content="<?= csrf_hash() ?>">
|
||||
<meta name="csrf-header" content="<?= config('Security')->headerName ?>">
|
||||
|
||||
<!-- Google Fonts removed: offline app uses system fonts -->
|
||||
|
||||
<link href="<?= base_url('assets/vendor/bootstrap/css/bootstrap.min.css') ?>" rel="stylesheet">
|
||||
|
|
@ -149,6 +153,7 @@
|
|||
<a href="#" class="back-to-top d-flex align-items-center justify-content-center"><i class="bi bi-arrow-up-short"></i></a>
|
||||
|
||||
<script src="<?= base_url('assets/vendor/jquery/jquery-4.0.0.min.js') ?>"></script>
|
||||
<script src="<?= base_url('assets/js/csrf-guard.js') ?>"></script>
|
||||
<script src="<?= base_url('assets/vendor/inputmask/inputmask.min.js') ?>"></script>
|
||||
<script src="<?= base_url('assets/vendor/inputmask/jquery.inputmask.min.js') ?>"></script>
|
||||
<script src="<?= base_url('assets/vendor/jquery-ui/jquery-ui.min.js') ?>"></script>
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ echo "=== Deploying to server ==="
|
|||
# with "syntax error, unexpected ')'" parse errors from an incompatible PHP CLI.
|
||||
REMOTE_CMDS="cd $PROJECT && git config core.fileMode false && git pull origin main && PHP_BIN=\$(command -v php83 || command -v php) && \$PHP_BIN spark migrate --namespace App && PHP_BIN=\$PHP_BIN bash scripts/install_hms_cron.sh $PROJECT && echo DEPLOY_OK"
|
||||
|
||||
if command -v plink &>/dev/null; then
|
||||
if command -v plink &>/dev/null && [ -n "$HMS_SSH_PASS" ]; then
|
||||
plink -ssh -batch -pw "$HMS_SSH_PASS" $SERVER "$REMOTE_CMDS"
|
||||
elif ssh -q -o BatchMode=yes -o ConnectTimeout=3 hms-live exit &>/dev/null; then
|
||||
ssh -o StrictHostKeyChecking=no hms-live "$REMOTE_CMDS"
|
||||
else
|
||||
ssh -o StrictHostKeyChecking=no $SERVER "$REMOTE_CMDS"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -425,11 +425,23 @@
|
|||
|
||||
const [installPrompt, setInstallPrompt] = useState(null);
|
||||
|
||||
// Auth state
|
||||
// Auth & Device Security State
|
||||
const PAIRED_DOC_KEY = 'hms_doctor_paired_profile';
|
||||
const [authenticatedDoctor, setAuthenticatedDoctor] = useState(null);
|
||||
const [authDocId, setAuthDocId] = useState(0);
|
||||
const [pairedDoctor, setPairedDoctor] = useState(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(PAIRED_DOC_KEY);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [docIdInput, setDocIdInput] = useState('');
|
||||
const [deviceCodeInput, setDeviceCodeInput] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [showPairingForm, setShowPairingForm] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [isSettingNewPin, setIsSettingNewPin] = useState(false);
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
|
|
@ -464,24 +476,6 @@
|
|||
const DRUG_CHIPS = ['Paracetamol 500mg', 'Amoxicillin 500mg', 'Pantoprazole 40mg', 'Cetirizine 10mg', 'Ibuprofen 400mg', 'Azithromycin 500mg', 'Multivitamin'];
|
||||
const LAB_CHIPS = ['CBC', 'Serum Creatinine', 'LFT', 'KFT', 'Chest X-Ray PA', 'ECG', 'Urine RE', 'HbA1c', 'USG Abdomen'];
|
||||
|
||||
const fetchDoctors = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/doctor/list');
|
||||
const json = await res.json();
|
||||
if (json.status === 1 && json.data) {
|
||||
setDoctors(json.data);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const docIdParam = params.get('doctor_id');
|
||||
if (docIdParam) {
|
||||
setAuthDocId(docIdParam);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load doctors', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIpdPatients = async (docId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
|
@ -528,7 +522,6 @@
|
|||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
fetchDoctors();
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -543,20 +536,32 @@
|
|||
}, []);
|
||||
|
||||
const handleVerifyPin = async () => {
|
||||
if (!authDocId || authDocId === '0') {
|
||||
setPinError('Please select your Doctor Profile.');
|
||||
const isPairing = !pairedDoctor || showPairingForm;
|
||||
const targetDocId = isPairing ? docIdInput.trim() : (pairedDoctor.doc_id || '');
|
||||
if (!targetDocId) {
|
||||
setPinError('Please enter your Doctor ID or Username.');
|
||||
return;
|
||||
}
|
||||
if (isPairing && (!deviceCodeInput || deviceCodeInput.trim().length < 4)) {
|
||||
setPinError('Please enter the 6-digit Device Security Code from Doctor Master.');
|
||||
return;
|
||||
}
|
||||
if (!pinInput || pinInput.length < 4) {
|
||||
setPinError('Enter your 4 to 6 digit Security PIN.');
|
||||
setPinError('Enter your 4 to 6 digit Security PIN or Password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPinError('');
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append('doctor_id', authDocId);
|
||||
body.append('doctor_id', targetDocId);
|
||||
body.append('pin', pinInput);
|
||||
if (isPairing) {
|
||||
body.append('device_code', deviceCodeInput.trim());
|
||||
} else if (pairedDoctor && pairedDoctor.device_token) {
|
||||
body.append('device_token', pairedDoctor.device_token);
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/doctor/auth/verify-pin', {
|
||||
method: 'POST',
|
||||
|
|
@ -573,16 +578,32 @@
|
|||
expires_at: Date.now() + THIRTY_DAYS_MS
|
||||
};
|
||||
localStorage.setItem(DOC_DEVICE_AUTH_KEY, JSON.stringify(deviceAuthObj));
|
||||
|
||||
const pairedObj = {
|
||||
id: json.doctor.id,
|
||||
doc_id: json.doctor.id ? ('DOC-' + json.doctor.id) : targetDocId,
|
||||
name: json.doctor.name,
|
||||
device_token: json.device_token || ''
|
||||
};
|
||||
localStorage.setItem(PAIRED_DOC_KEY, JSON.stringify(pairedObj));
|
||||
setPairedDoctor(pairedObj);
|
||||
setShowPairingForm(false);
|
||||
setPinInput('');
|
||||
setDeviceCodeInput('');
|
||||
fetchIpdPatients(json.doctor.id);
|
||||
fetchOpdAppointments(json.doctor.id, opdDateMode);
|
||||
} else if (json.status === 2) {
|
||||
setIsSettingNewPin(true);
|
||||
} else {
|
||||
setPinError(json.message || 'Invalid Security PIN');
|
||||
if (json.need_device_code) {
|
||||
setShowPairingForm(true);
|
||||
}
|
||||
setPinError(json.message || 'Invalid Security PIN or Device Code');
|
||||
}
|
||||
} catch (err) {
|
||||
setPinError('Connection error verifying PIN.');
|
||||
setPinError('Connection error verifying credentials.');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -597,8 +618,9 @@
|
|||
}
|
||||
|
||||
try {
|
||||
const targetDocId = pairedDoctor ? pairedDoctor.id : (authenticatedDoctor ? authenticatedDoctor.id : 0);
|
||||
const body = new URLSearchParams();
|
||||
body.append('doctor_id', authDocId || (authenticatedDoctor ? authenticatedDoctor.id : 0));
|
||||
body.append('doctor_id', targetDocId);
|
||||
body.append('new_pin', newPin);
|
||||
|
||||
const res = await fetch('/api/v1/doctor/auth/set-pin', {
|
||||
|
|
@ -630,6 +652,20 @@
|
|||
setActiveOpd(null);
|
||||
};
|
||||
|
||||
const handleUnpairDevice = () => {
|
||||
if (confirm('Unpair this device? You will need the Doctor ID and Device Security Code to pair it again.')) {
|
||||
setAuthenticatedDoctor(null);
|
||||
setPairedDoctor(null);
|
||||
localStorage.removeItem(DOC_DEVICE_AUTH_KEY);
|
||||
localStorage.removeItem(PAIRED_DOC_KEY);
|
||||
setShowPairingForm(true);
|
||||
setPinInput('');
|
||||
setDocIdInput('');
|
||||
setDeviceCodeInput('');
|
||||
setPinError('');
|
||||
}
|
||||
};
|
||||
|
||||
const openIpdWorkspace = async (patient) => {
|
||||
setActivePatient(patient);
|
||||
clearCapturedImage();
|
||||
|
|
@ -895,9 +931,11 @@
|
|||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// RENDER: LOCK SCREEN (PIN AUTHENTICATION)
|
||||
// RENDER: LOCK SCREEN (DEVICE REGISTRATION & PIN AUTHENTICATION)
|
||||
// -------------------------------------------------------------
|
||||
if (!authenticatedDoctor) {
|
||||
const isQuickUnlock = Boolean(pairedDoctor && !showPairingForm);
|
||||
|
||||
return (
|
||||
<div className="lock-screen-overlay">
|
||||
<MobileAppsModal show={showAppsModal} onClose={() => setShowAppsModal(false)} currentApp="doctor" />
|
||||
|
|
@ -914,49 +952,169 @@
|
|||
</button>
|
||||
<img src="/assets/img/logo.png" alt="Hospital Logo" className="d-block mx-auto mb-2" style={{maxHeight: '40px', filter: 'brightness(0) invert(1)'}} onError={(e) => { e.target.style.display = 'none'; }} />
|
||||
<h5 className="fw-bold mb-1">Doctor EMR Mobile Portal</h5>
|
||||
<small className="opacity-75">30-Day Device Authentication Security</small>
|
||||
<div className="d-flex align-items-center justify-content-center gap-1 opacity-75 small">
|
||||
<i className="bi bi-shield-check text-success"></i>
|
||||
<span>Hospital Intranet & Device Protected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{pinError && <div className="alert alert-danger py-2 px-3 small mb-3">{pinError}</div>}
|
||||
|
||||
{!isSettingNewPin ? (
|
||||
isQuickUnlock ? (
|
||||
/* -------------------------------------------------------------
|
||||
PAIRED DEVICE QUICK UNLOCK
|
||||
------------------------------------------------------------- */
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-bold small">Select Doctor Profile</label>
|
||||
<select
|
||||
className="form-select form-select-lg fs-6"
|
||||
value={authDocId}
|
||||
onChange={(e) => setAuthDocId(e.target.value)}
|
||||
>
|
||||
<option value="0">-- Choose Doctor Profile --</option>
|
||||
{doctors.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-center mb-4">
|
||||
<div className="avatar-circle mx-auto mb-2" style={{width: '54px', height: '54px', fontSize: '18px'}}>
|
||||
{getInitials(pairedDoctor.name || pairedDoctor.doc_id)}
|
||||
</div>
|
||||
<h6 className="fw-bold text-dark mb-0">{pairedDoctor.name || 'Doctor'}</h6>
|
||||
<span className="badge bg-secondary-subtle text-dark border font-monospace mt-1">
|
||||
{pairedDoctor.doc_id}
|
||||
</span>
|
||||
<div className="text-muted small mt-1" style={{fontSize: '11px'}}>
|
||||
<i className="bi bi-phone-fill me-1 text-primary"></i> Trusted Hospital Device
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label fw-bold small">Enter App Security PIN</label>
|
||||
<label className="form-label fw-bold small text-muted">Enter PIN or Password to Unlock</label>
|
||||
<input
|
||||
type="password"
|
||||
maxLength="6"
|
||||
maxLength="20"
|
||||
className="form-control text-center fs-3 letter-spacing-2"
|
||||
placeholder="••••"
|
||||
value={pinInput}
|
||||
autoFocus
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
|
||||
/>
|
||||
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
|
||||
30-day verified session active
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm mb-2"
|
||||
onClick={handleVerifyPin}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
{isVerifying ? (
|
||||
<span><span className="spinner-border spinner-border-sm me-2"></span>Verifying...</span>
|
||||
) : (
|
||||
<span><i className="bi bi-unlock-fill me-2"></i> Unlock Doctor Portal</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center mt-3 pt-2 border-top">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
|
||||
onClick={() => { setShowPairingForm(true); setPinError(''); }}
|
||||
>
|
||||
<i className="bi bi-arrow-repeat me-1"></i> Switch Doctor / Re-Pair Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* -------------------------------------------------------------
|
||||
FIRST-TIME DEVICE REGISTRATION / PAIRING FORM
|
||||
------------------------------------------------------------- */
|
||||
<div>
|
||||
<div className="alert alert-primary py-2 px-3 small d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-shield-lock-fill fs-5 text-primary"></i>
|
||||
<div>
|
||||
<div className="fw-bold">First-Time Device Pairing</div>
|
||||
<div style={{fontSize: '11px'}}>Enter Doctor ID and Device Security Code from Doctor Master.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-bold small text-dark mb-1">
|
||||
Doctor ID or Username <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-light"><i className="bi bi-person-badge"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="e.g. DOC-123 or doctor username"
|
||||
value={docIdInput}
|
||||
onChange={(e) => setDocIdInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-bold small text-dark mb-1">
|
||||
Device Security Code <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-light"><i className="bi bi-key-fill text-warning"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength="10"
|
||||
className="form-control font-monospace fw-bold"
|
||||
placeholder="6-digit code from Admin"
|
||||
value={deviceCodeInput}
|
||||
onChange={(e) => setDeviceCodeInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<small className="text-muted" style={{fontSize: '11px'}}>
|
||||
Displayed on HMS Admin > Doctors
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label fw-bold small text-dark mb-1">
|
||||
App PIN or Password <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-light"><i className="bi bi-lock-fill"></i></span>
|
||||
<input
|
||||
type="password"
|
||||
maxLength="20"
|
||||
className="form-control"
|
||||
placeholder="••••"
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
|
||||
/>
|
||||
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
|
||||
Unlocks device access for 30 days
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm" onClick={handleVerifyPin}>
|
||||
<i className="bi bi-unlock-fill me-2"></i> Unlock Doctor Portal
|
||||
<button
|
||||
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm"
|
||||
onClick={handleVerifyPin}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
{isVerifying ? (
|
||||
<span><span className="spinner-border spinner-border-sm me-2"></span>Pairing Device...</span>
|
||||
) : (
|
||||
<span><i className="bi bi-shield-check me-2"></i> Register Device & Unlock</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{pairedDoctor && (
|
||||
<div className="text-center mt-3 pt-2 border-top">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
|
||||
onClick={() => { setShowPairingForm(false); setPinError(''); }}
|
||||
>
|
||||
<i className="bi bi-arrow-left me-1"></i> Back to Quick Unlock
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* -------------------------------------------------------------
|
||||
INITIAL PIN CREATION (IF DOCTOR DOES NOT HAVE A PIN YET)
|
||||
------------------------------------------------------------- */
|
||||
<div>
|
||||
<div className="alert alert-info py-2 small mb-3">
|
||||
Initial PIN Setup for Doctor Profile. Set a 4-6 digit Security PIN.
|
||||
|
|
|
|||
|
|
@ -513,11 +513,23 @@
|
|||
const [wellnessHistoryList, setWellnessHistoryList] = useState([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
// Auth State
|
||||
// Auth & Device Security State
|
||||
const PAIRED_NURSE_KEY = 'hms_nursing_paired_profile';
|
||||
const [authenticatedNurse, setAuthenticatedNurse] = useState(null);
|
||||
const [authNurseId, setAuthNurseId] = useState(0);
|
||||
const [pairedNurse, setPairedNurse] = useState(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(PAIRED_NURSE_KEY);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [staffIdInput, setStaffIdInput] = useState('');
|
||||
const [deviceCodeInput, setDeviceCodeInput] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [showPairingForm, setShowPairingForm] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [isSettingNewPin, setIsSettingNewPin] = useState(false);
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
|
|
@ -585,27 +597,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
const fetchNurses = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/nursing/nurses');
|
||||
const json = await res.json();
|
||||
if (json.status === 1 && json.data) {
|
||||
setNurses(json.data);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const codeParam = params.get('nurse_code');
|
||||
if (codeParam) {
|
||||
const matched = json.data.find(n => n.nurse_code === codeParam);
|
||||
if (matched) {
|
||||
setAuthNurseId(matched.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load nurses', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedAuth = localStorage.getItem(DEVICE_AUTH_KEY);
|
||||
if (savedAuth) {
|
||||
|
|
@ -619,26 +610,37 @@
|
|||
} catch (e) {}
|
||||
}
|
||||
fetchBeds();
|
||||
fetchNurses();
|
||||
fetchDoctors();
|
||||
fetchOpdAppointments('today', '0', 'all');
|
||||
}, []);
|
||||
|
||||
const handleVerifyPin = async () => {
|
||||
if (!authNurseId || authNurseId === '0') {
|
||||
setPinError('Please select your Nursing Staff Profile.');
|
||||
const isPairing = !pairedNurse || showPairingForm;
|
||||
const targetStaffId = isPairing ? staffIdInput.trim() : (pairedNurse.staff_id || '');
|
||||
if (!targetStaffId) {
|
||||
setPinError('Please enter your Staff ID or Username.');
|
||||
return;
|
||||
}
|
||||
if (isPairing && (!deviceCodeInput || deviceCodeInput.trim().length < 4)) {
|
||||
setPinError('Please enter the 6-digit Device Security Code from Nursing Staff Master.');
|
||||
return;
|
||||
}
|
||||
if (!pinInput || pinInput.length < 4) {
|
||||
setPinError('Enter your 4 to 6 digit Security PIN.');
|
||||
setPinError('Enter your 4 to 6 digit Security PIN or Password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPinError('');
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append('nurse_id', authNurseId);
|
||||
body.append('staff_id', targetStaffId);
|
||||
body.append('pin', pinInput);
|
||||
if (isPairing) {
|
||||
body.append('device_code', deviceCodeInput.trim());
|
||||
} else if (pairedNurse && pairedNurse.device_token) {
|
||||
body.append('device_token', pairedNurse.device_token);
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/nursing/auth/verify-pin', {
|
||||
method: 'POST',
|
||||
|
|
@ -654,16 +656,32 @@
|
|||
expires_at: Date.now() + THIRTY_DAYS_MS
|
||||
};
|
||||
localStorage.setItem(DEVICE_AUTH_KEY, JSON.stringify(deviceAuthObj));
|
||||
|
||||
const pairedObj = {
|
||||
id: json.nurse.id,
|
||||
staff_id: json.nurse.nurse_code || targetStaffId,
|
||||
name: json.nurse.name,
|
||||
device_token: json.device_token || ''
|
||||
};
|
||||
localStorage.setItem(PAIRED_NURSE_KEY, JSON.stringify(pairedObj));
|
||||
setPairedNurse(pairedObj);
|
||||
setShowPairingForm(false);
|
||||
setPinInput('');
|
||||
setDeviceCodeInput('');
|
||||
fetchBeds();
|
||||
fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter);
|
||||
} else if (json.status === 2) {
|
||||
setIsSettingNewPin(true);
|
||||
} else {
|
||||
setPinError(json.message || 'Incorrect Security PIN');
|
||||
if (json.need_device_code) {
|
||||
setShowPairingForm(true);
|
||||
}
|
||||
setPinError(json.message || 'Incorrect Security PIN or Device Code.');
|
||||
}
|
||||
} catch (err) {
|
||||
setPinError('Connection error verifying PIN.');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -678,8 +696,10 @@
|
|||
}
|
||||
|
||||
try {
|
||||
const targetNurseId = pairedNurse ? pairedNurse.id : (authenticatedNurse ? authenticatedNurse.id : 0);
|
||||
const body = new URLSearchParams();
|
||||
body.append('nurse_id', authNurseId || (authenticatedNurse ? authenticatedNurse.id : 0));
|
||||
body.append('nurse_id', targetNurseId);
|
||||
body.append('staff_id', staffIdInput.trim() || (pairedNurse ? pairedNurse.staff_id : ''));
|
||||
body.append('new_pin', newPin);
|
||||
|
||||
const res = await fetch('/api/v1/nursing/auth/set-pin', {
|
||||
|
|
@ -710,6 +730,20 @@
|
|||
setActiveBed(null);
|
||||
};
|
||||
|
||||
const handleUnpairDevice = () => {
|
||||
if (confirm('Unpair this device? You will need the Staff ID and Device Security Code to pair it again.')) {
|
||||
setAuthenticatedNurse(null);
|
||||
setPairedNurse(null);
|
||||
localStorage.removeItem(DEVICE_AUTH_KEY);
|
||||
localStorage.removeItem(PAIRED_NURSE_KEY);
|
||||
setShowPairingForm(true);
|
||||
setPinInput('');
|
||||
setStaffIdInput('');
|
||||
setDeviceCodeInput('');
|
||||
setPinError('');
|
||||
}
|
||||
};
|
||||
|
||||
const openWorkspace = async (bed) => {
|
||||
if (!bed.ipd_id || bed.status !== 'occupied') return;
|
||||
setActiveBed(bed);
|
||||
|
|
@ -1266,9 +1300,11 @@
|
|||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// LOCK SCREEN
|
||||
// LOCK SCREEN (DEVICE REGISTRATION & PIN AUTHENTICATION)
|
||||
// -------------------------------------------------------------
|
||||
if (!authenticatedNurse) {
|
||||
const isQuickUnlock = Boolean(pairedNurse && !showPairingForm);
|
||||
|
||||
return (
|
||||
<div className="lock-screen-overlay">
|
||||
<MobileAppsModal show={showAppsModal} onClose={() => setShowAppsModal(false)} currentApp="nursing" />
|
||||
|
|
@ -1285,49 +1321,169 @@
|
|||
</button>
|
||||
<img src="/assets/img/logo.png" alt="Hospital Logo" className="d-block mx-auto mb-2" style={{maxHeight: '40px', filter: 'brightness(0) invert(1)'}} onError={(e) => { e.target.style.display = 'none'; }} />
|
||||
<h5 className="fw-bold mb-1">Nursing Care Mobile App</h5>
|
||||
<small className="opacity-75">30-Day Device Security Authentication</small>
|
||||
<div className="d-flex align-items-center justify-content-center gap-1 opacity-75 small">
|
||||
<i className="bi bi-shield-check text-success"></i>
|
||||
<span>Hospital Intranet & Device Protected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{pinError && <div className="alert alert-danger py-2 px-3 small mb-3">{pinError}</div>}
|
||||
|
||||
{!isSettingNewPin ? (
|
||||
isQuickUnlock ? (
|
||||
/* -------------------------------------------------------------
|
||||
PAIRED DEVICE QUICK UNLOCK
|
||||
------------------------------------------------------------- */
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-bold small">Select Nursing Staff Profile</label>
|
||||
<select
|
||||
className="form-select form-select-lg fs-6"
|
||||
value={authNurseId}
|
||||
onChange={(e) => setAuthNurseId(e.target.value)}
|
||||
>
|
||||
<option value="0">-- Choose Nurse Profile --</option>
|
||||
{nurses.map(n => (
|
||||
<option key={n.id} value={n.id}>[{n.nurse_code}] {n.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-center mb-4">
|
||||
<div className="avatar-circle mx-auto mb-2" style={{width: '54px', height: '54px', fontSize: '18px'}}>
|
||||
{getInitials(pairedNurse.name || pairedNurse.staff_id)}
|
||||
</div>
|
||||
<h6 className="fw-bold text-dark mb-0">{pairedNurse.name || 'Staff Nurse'}</h6>
|
||||
<span className="badge bg-secondary-subtle text-dark border font-monospace mt-1">
|
||||
{pairedNurse.staff_id}
|
||||
</span>
|
||||
<div className="text-muted small mt-1" style={{fontSize: '11px'}}>
|
||||
<i className="bi bi-phone-fill me-1 text-primary"></i> Trusted Hospital Device
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label fw-bold small">Enter App Security PIN</label>
|
||||
<label className="form-label fw-bold small text-muted">Enter PIN or Password to Unlock</label>
|
||||
<input
|
||||
type="password"
|
||||
maxLength="6"
|
||||
className="form-control text-center fs-3"
|
||||
maxLength="20"
|
||||
className="form-control text-center fs-3 letter-spacing-2"
|
||||
placeholder="••••"
|
||||
value={pinInput}
|
||||
autoFocus
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
|
||||
/>
|
||||
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
|
||||
30-day verified session active
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-success btn-lg w-100 py-3 fw-bold shadow-sm mb-2"
|
||||
onClick={handleVerifyPin}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
{isVerifying ? (
|
||||
<span><span className="spinner-border spinner-border-sm me-2"></span>Verifying...</span>
|
||||
) : (
|
||||
<span><i className="bi bi-unlock-fill me-2"></i> Unlock Nursing App</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center mt-3 pt-2 border-top">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
|
||||
onClick={() => { setShowPairingForm(true); setPinError(''); }}
|
||||
>
|
||||
<i className="bi bi-arrow-repeat me-1"></i> Switch Account / Re-Pair Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* -------------------------------------------------------------
|
||||
FIRST-TIME DEVICE REGISTRATION / PAIRING FORM
|
||||
------------------------------------------------------------- */
|
||||
<div>
|
||||
<div className="alert alert-primary py-2 px-3 small d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-shield-lock-fill fs-5 text-primary"></i>
|
||||
<div>
|
||||
<div className="fw-bold">First-Time Device Pairing</div>
|
||||
<div style={{fontSize: '11px'}}>Enter Staff ID and Device Security Code from Nursing Staff Master.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-bold small text-dark mb-1">
|
||||
Staff ID or Username <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-light"><i className="bi bi-person-badge"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="e.g. NUR-637 or username"
|
||||
value={staffIdInput}
|
||||
onChange={(e) => setStaffIdInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-bold small text-dark mb-1">
|
||||
Device Security Code <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-light"><i className="bi bi-key-fill text-warning"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength="10"
|
||||
className="form-control font-monospace fw-bold"
|
||||
placeholder="6-digit code from Admin"
|
||||
value={deviceCodeInput}
|
||||
onChange={(e) => setDeviceCodeInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<small className="text-muted" style={{fontSize: '11px'}}>
|
||||
Displayed on HMS Admin > Nursing Staff Master
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label fw-bold small text-dark mb-1">
|
||||
App PIN or Password <span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-light"><i className="bi bi-lock-fill"></i></span>
|
||||
<input
|
||||
type="password"
|
||||
maxLength="20"
|
||||
className="form-control"
|
||||
placeholder="••••"
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
|
||||
/>
|
||||
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
|
||||
Unlocks device access for 30 days
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-success btn-lg w-100 py-3 fw-bold shadow-sm" onClick={handleVerifyPin}>
|
||||
<i className="bi bi-unlock-fill me-2"></i> Unlock Nursing App
|
||||
<button
|
||||
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm"
|
||||
onClick={handleVerifyPin}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
{isVerifying ? (
|
||||
<span><span className="spinner-border spinner-border-sm me-2"></span>Pairing Device...</span>
|
||||
) : (
|
||||
<span><i className="bi bi-shield-check me-2"></i> Register Device & Unlock</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{pairedNurse && (
|
||||
<div className="text-center mt-3 pt-2 border-top">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
|
||||
onClick={() => { setShowPairingForm(false); setPinError(''); }}
|
||||
>
|
||||
<i className="bi bi-arrow-left me-1"></i> Back to Quick Unlock
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* -------------------------------------------------------------
|
||||
INITIAL PIN CREATION (IF NURSE DOES NOT HAVE A PIN YET)
|
||||
------------------------------------------------------------- */
|
||||
<div>
|
||||
<div className="alert alert-info py-2 small mb-3">
|
||||
Initial PIN Setup for Nurse Profile. Set a 4-6 digit Security PIN.
|
||||
|
|
|
|||
336
public/assets/js/csrf-guard.js
Normal file
336
public/assets/js/csrf-guard.js
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* CSRF Guard - Universal Cross-Site Request Forgery Protection Handler
|
||||
*
|
||||
* Automatically protects ALL HTTP requests across the application:
|
||||
* 1. jQuery AJAX ($.ajax, $.post, $.get with payload)
|
||||
* 2. Fetch API (window.fetch)
|
||||
* 3. XMLHttpRequest (raw XHR, axios, third-party libraries)
|
||||
* 4. HTML Forms (native submit & programmatic form.submit())
|
||||
*/
|
||||
(function(window, document) {
|
||||
'use strict';
|
||||
|
||||
var DEFAULT_TOKEN_NAME = 'csrf_test_name';
|
||||
var DEFAULT_HEADER_NAME = 'X-CSRF-TOKEN';
|
||||
|
||||
var currentToken = '';
|
||||
var tokenName = DEFAULT_TOKEN_NAME;
|
||||
var headerName = DEFAULT_HEADER_NAME;
|
||||
|
||||
function getCookie(name) {
|
||||
if (!document.cookie) return null;
|
||||
var parts = document.cookie.split(';');
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var c = parts[i].trim();
|
||||
if (c.indexOf(name + '=') === 0) {
|
||||
return decodeURIComponent(c.substring(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMetaTags() {
|
||||
var metaToken = document.querySelector('meta[name="csrf-token"]') ||
|
||||
document.querySelector('meta[name="X-CSRF-TOKEN"]') ||
|
||||
document.querySelector('meta[name="csrf-hash"]');
|
||||
if (metaToken && metaToken.getAttribute('content')) {
|
||||
currentToken = metaToken.getAttribute('content');
|
||||
}
|
||||
|
||||
var metaName = document.querySelector('meta[name="csrf-token-name"]');
|
||||
if (metaName && metaName.getAttribute('content')) {
|
||||
tokenName = metaName.getAttribute('content');
|
||||
}
|
||||
|
||||
var metaHeader = document.querySelector('meta[name="csrf-header"]');
|
||||
if (metaHeader && metaHeader.getAttribute('content')) {
|
||||
headerName = metaHeader.getAttribute('content');
|
||||
}
|
||||
|
||||
if (!currentToken) {
|
||||
var cookieToken = getCookie('csrf_cookie_name') || getCookie('csrf_test_name');
|
||||
if (cookieToken) {
|
||||
currentToken = cookieToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readMetaTags();
|
||||
|
||||
function updateToken(newToken) {
|
||||
if (!newToken || typeof newToken !== 'string' || newToken === currentToken) {
|
||||
return;
|
||||
}
|
||||
currentToken = newToken;
|
||||
|
||||
// Update meta tag
|
||||
var metaToken = document.querySelector('meta[name="csrf-token"]') ||
|
||||
document.querySelector('meta[name="X-CSRF-TOKEN"]') ||
|
||||
document.querySelector('meta[name="csrf-hash"]');
|
||||
if (metaToken) {
|
||||
metaToken.setAttribute('content', newToken);
|
||||
}
|
||||
|
||||
// Update all existing hidden inputs across DOM
|
||||
var inputs = document.querySelectorAll('input[name="' + tokenName + '"]');
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
inputs[i].value = newToken;
|
||||
}
|
||||
|
||||
// Update global jQuery ajaxSetup if available
|
||||
if (window.jQuery && window.jQuery.ajaxSetup) {
|
||||
var headers = {};
|
||||
headers[headerName] = newToken;
|
||||
window.jQuery.ajaxSetup({ headers: headers });
|
||||
}
|
||||
}
|
||||
|
||||
var CsrfGuard = {
|
||||
getToken: function() {
|
||||
if (!currentToken) readMetaTags();
|
||||
return currentToken;
|
||||
},
|
||||
getTokenName: function() {
|
||||
return tokenName;
|
||||
},
|
||||
getHeaderName: function() {
|
||||
return headerName;
|
||||
},
|
||||
updateToken: updateToken,
|
||||
init: function(config) {
|
||||
if (config) {
|
||||
if (config.token) currentToken = config.token;
|
||||
if (config.tokenName) tokenName = config.tokenName;
|
||||
if (config.headerName) headerName = config.headerName;
|
||||
}
|
||||
readMetaTags();
|
||||
this.setupJQuery();
|
||||
},
|
||||
setupJQuery: function() {
|
||||
if (!window.jQuery) return;
|
||||
var $ = window.jQuery;
|
||||
|
||||
if ($._csrfGuardInstalled) return;
|
||||
$._csrfGuardInstalled = true;
|
||||
|
||||
var defaultHeaders = {};
|
||||
if (currentToken) {
|
||||
defaultHeaders[headerName] = currentToken;
|
||||
}
|
||||
$.ajaxSetup({
|
||||
headers: defaultHeaders
|
||||
});
|
||||
|
||||
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
|
||||
var method = (options.type || options.method || 'GET').toUpperCase();
|
||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
||||
return;
|
||||
}
|
||||
|
||||
var token = CsrfGuard.getToken();
|
||||
if (!token) return;
|
||||
|
||||
// Set header on every non-GET request
|
||||
jqXHR.setRequestHeader(headerName, token);
|
||||
|
||||
// Inject into payload data as well
|
||||
if (options.data === undefined || options.data === null) {
|
||||
options.data = encodeURIComponent(tokenName) + '=' + encodeURIComponent(token);
|
||||
} else if (typeof options.data === 'string') {
|
||||
if (options.data.indexOf(tokenName + '=') === -1) {
|
||||
options.data += (options.data.length > 0 ? '&' : '') +
|
||||
encodeURIComponent(tokenName) + '=' + encodeURIComponent(token);
|
||||
}
|
||||
} else if (window.FormData && options.data instanceof window.FormData) {
|
||||
if (!options.data.has(tokenName)) {
|
||||
options.data.append(tokenName, token);
|
||||
}
|
||||
} else if (typeof options.data === 'object') {
|
||||
options.data[tokenName] = token;
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ajaxComplete(function(event, xhr, settings) {
|
||||
var respToken = xhr.getResponseHeader(headerName) ||
|
||||
xhr.getResponseHeader('X-CSRF-TOKEN') ||
|
||||
xhr.getResponseHeader('x-csrf-token');
|
||||
if (respToken) {
|
||||
updateToken(respToken);
|
||||
}
|
||||
|
||||
try {
|
||||
if (xhr.responseJSON && xhr.responseJSON.csrf_hash) {
|
||||
updateToken(xhr.responseJSON.csrf_hash);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.CSRF_GUARD = CsrfGuard;
|
||||
window.getCsrfToken = CsrfGuard.getToken;
|
||||
window.getCsrfTokenName = CsrfGuard.getTokenName;
|
||||
window.getCsrfHeader = CsrfGuard.getHeaderName;
|
||||
window.updateCsrfToken = CsrfGuard.updateToken;
|
||||
|
||||
// 1. Hook HTML Form Submissions
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
if (!form || form.tagName !== 'FORM') return;
|
||||
|
||||
var method = (form.getAttribute('method') || 'GET').toUpperCase();
|
||||
if (method !== 'POST') return;
|
||||
|
||||
var token = CsrfGuard.getToken();
|
||||
if (!token) return;
|
||||
|
||||
var input = form.querySelector('input[name="' + tokenName + '"]');
|
||||
if (!input) {
|
||||
input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = tokenName;
|
||||
input.value = token;
|
||||
form.appendChild(input);
|
||||
} else {
|
||||
input.value = token;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Hook programmatic form.submit()
|
||||
if (window.HTMLFormElement && window.HTMLFormElement.prototype) {
|
||||
var originalSubmit = window.HTMLFormElement.prototype.submit;
|
||||
window.HTMLFormElement.prototype.submit = function() {
|
||||
var method = (this.getAttribute('method') || 'GET').toUpperCase();
|
||||
if (method === 'POST') {
|
||||
var token = CsrfGuard.getToken();
|
||||
if (token) {
|
||||
var input = this.querySelector('input[name="' + tokenName + '"]');
|
||||
if (!input) {
|
||||
input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = tokenName;
|
||||
input.value = token;
|
||||
this.appendChild(input);
|
||||
} else {
|
||||
input.value = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalSubmit.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Hook Fetch API
|
||||
if (window.fetch) {
|
||||
var originalFetch = window.fetch;
|
||||
window.fetch = function(resource, init) {
|
||||
init = init || {};
|
||||
var method = (init.method || 'GET').toUpperCase();
|
||||
|
||||
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
||||
var token = CsrfGuard.getToken();
|
||||
if (token) {
|
||||
// Inject Header
|
||||
if (!init.headers) {
|
||||
init.headers = {};
|
||||
}
|
||||
|
||||
if (window.Headers && init.headers instanceof Headers) {
|
||||
if (!init.headers.has(headerName)) {
|
||||
init.headers.set(headerName, token);
|
||||
}
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers.push([headerName, token]);
|
||||
} else {
|
||||
init.headers[headerName] = token;
|
||||
}
|
||||
|
||||
// Inject Body (FormData or URLSearchParams)
|
||||
if (window.FormData && init.body instanceof FormData) {
|
||||
if (!init.body.has(tokenName)) {
|
||||
init.body.append(tokenName, token);
|
||||
}
|
||||
} else if (typeof init.body === 'string') {
|
||||
var ct = '';
|
||||
if (init.headers) {
|
||||
if (window.Headers && init.headers instanceof Headers) {
|
||||
ct = init.headers.get('Content-Type') || '';
|
||||
} else {
|
||||
ct = init.headers['Content-Type'] || init.headers['content-type'] || '';
|
||||
}
|
||||
}
|
||||
if (ct.indexOf('application/x-www-form-urlencoded') !== -1 && init.body.indexOf(tokenName + '=') === -1) {
|
||||
init.body += (init.body.length ? '&' : '') +
|
||||
encodeURIComponent(tokenName) + '=' + encodeURIComponent(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch.apply(this, [resource, init]).then(function(response) {
|
||||
var newToken = response.headers.get(headerName) ||
|
||||
response.headers.get('X-CSRF-TOKEN') ||
|
||||
response.headers.get('x-csrf-token');
|
||||
if (newToken) {
|
||||
updateToken(newToken);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Hook Raw XMLHttpRequest
|
||||
if (window.XMLHttpRequest) {
|
||||
var originalOpen = XMLHttpRequest.prototype.open;
|
||||
var originalSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this._csrfMethod = (method || 'GET').toUpperCase();
|
||||
return originalOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
if (this._csrfMethod && this._csrfMethod !== 'GET' && this._csrfMethod !== 'HEAD' && this._csrfMethod !== 'OPTIONS') {
|
||||
var token = CsrfGuard.getToken();
|
||||
if (token) {
|
||||
try {
|
||||
this.setRequestHeader(headerName, token);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var onReadyHandler = function() {
|
||||
if (self.readyState === 4) {
|
||||
try {
|
||||
var newToken = self.getResponseHeader(headerName) ||
|
||||
self.getResponseHeader('X-CSRF-TOKEN') ||
|
||||
self.getResponseHeader('x-csrf-token');
|
||||
if (newToken) {
|
||||
updateToken(newToken);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
this.addEventListener('readystatechange', onReadyHandler, false);
|
||||
|
||||
return originalSend.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Try setting up jQuery now, or poll briefly until jQuery is loaded
|
||||
CsrfGuard.setupJQuery();
|
||||
if (!window.jQuery) {
|
||||
var attempts = 0;
|
||||
var checkJq = setInterval(function() {
|
||||
attempts++;
|
||||
if (window.jQuery) {
|
||||
CsrfGuard.setupJQuery();
|
||||
clearInterval(checkJq);
|
||||
} else if (attempts > 50) {
|
||||
clearInterval(checkJq);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
})(window, document);
|
||||
Loading…
Reference in New Issue
Block a user