feat(auth): add Nurse PIN Security Authentication, App QR Codes, and Lock Screen for NursingCare PWA

This commit is contained in:
DevSoftTech Server 2026-08-28 18:45:53 +05:30
parent 6f199d9743
commit 7b0b1c3415
6 changed files with 416 additions and 35 deletions

View File

@ -1571,6 +1571,8 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct
// Nursing PWA APIs
$routes->get('nursing/beds', 'NursingApi::beds');
$routes->get('nursing/nurses', 'NursingApi::nurses');
$routes->post('nursing/auth/verify-pin', 'NursingApi::verifyPin');
$routes->post('nursing/auth/set-pin', 'NursingApi::setPin');
$routes->get('nursing/workspace/(:num)', 'NursingApi::workspace/$1');
$routes->post('nursing/entry/save/(:num)', 'NursingApi::saveEntry/$1');
});

View File

@ -78,6 +78,96 @@ class NursingApi extends BaseController
]);
}
/**
* POST api/v1/nursing/auth/verify-pin
*/
public function verifyPin()
{
$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'] ?? ''));
if ($nurseId <= 0 && $nurseCode !== '') {
$nurseRow = db_connect()->table('nurse_master')->where('nurse_code', $nurseCode)->get()->getRowArray();
} else {
$nurseRow = $this->nurseModel->getNurseById($nurseId);
}
if (! $nurseRow) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Nurse profile not found']);
}
$hashedPin = (string) ($nurseRow['app_pin'] ?? '');
if ($hashedPin === '') {
return $this->response->setJSON([
'status' => 2,
'message' => 'PIN not set yet. Please set a 4-6 digit PIN.',
'nurse' => [
'id' => (int) $nurseRow['id'],
'nurse_code' => $nurseRow['nurse_code'],
'name' => $nurseRow['name'],
'designation' => $nurseRow['designation'] ?? 'Staff Nurse',
],
]);
}
$isValid = password_verify($pin, $hashedPin) || ($pin === $hashedPin);
if (! $isValid) {
return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Incorrect Security PIN']);
}
return $this->response->setJSON([
'status' => 1,
'message' => 'Authentication successful',
'nurse' => [
'id' => (int) $nurseRow['id'],
'nurse_code' => $nurseRow['nurse_code'],
'name' => $nurseRow['name'],
'designation' => $nurseRow['designation'] ?? 'Staff Nurse',
],
]);
}
/**
* POST api/v1/nursing/auth/set-pin
*/
public function setPin()
{
$post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []);
$nurseId = (int) ($post['nurse_id'] ?? 0);
$newPin = trim((string) ($post['new_pin'] ?? ''));
$oldPin = trim((string) ($post['old_pin'] ?? ''));
if ($nurseId <= 0 || strlen($newPin) < 4) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Valid Nurse ID and 4-6 digit PIN are required']);
}
$nurseRow = $this->nurseModel->getNurseById($nurseId);
if (! $nurseRow) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Nurse profile not found']);
}
$hashedPin = (string) ($nurseRow['app_pin'] ?? '');
if ($hashedPin !== '' && $oldPin !== '') {
$isValid = password_verify($oldPin, $hashedPin) || ($oldPin === $hashedPin);
if (! $isValid) {
return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Current PIN is incorrect']);
}
}
$this->nurseModel->updateNurse($nurseId, [
'app_pin' => password_hash($newPin, PASSWORD_DEFAULT),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Security PIN updated successfully',
]);
}
/**
* GET api/v1/nursing/workspace/(:num)
*/

View File

@ -53,6 +53,8 @@ class Nurse extends BaseController
$nurseCode = 'NUR-' . str_pad((string) (rand(100, 999)), 3, '0', STR_PAD_LEFT);
}
$appPin = trim((string) ($this->request->getPost('app_pin') ?? ''));
$payload = [
'nurse_code' => $nurseCode,
'name' => $name,
@ -67,6 +69,10 @@ class Nurse extends BaseController
'is_active' => $isActive,
];
if ($appPin !== '') {
$payload['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT);
}
if ($id > 0) {
$this->nurseModel->updateNurse($id, $payload);
$savedId = $id;

View File

@ -52,6 +52,9 @@ class NurseModel
if (! in_array('designation', $fields, true)) {
$this->db->query("ALTER TABLE nurse_master ADD COLUMN designation VARCHAR(100) NULL AFTER gender");
}
if (! in_array('app_pin', $fields, true)) {
$this->db->query("ALTER TABLE nurse_master ADD COLUMN app_pin VARCHAR(255) NULL AFTER email");
}
return true;
}

View File

@ -83,6 +83,9 @@
<?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>
<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
</button>
@ -185,6 +188,15 @@
<label class="form-label fw-bold small mb-1">Email Address</label>
<input type="email" class="form-control form-control-sm" id="nurse_email" name="email" placeholder="Email address">
</div>
<!-- App Security PIN 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>
</div>
</div>
</div>
</div>
<div class="modal-footer bg-light py-2">
@ -198,11 +210,34 @@
</div>
</div>
<!-- Modal for Nurse App 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>
<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;" />
</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>
<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>
</div>
</div>
</div>
</div>
<script>
(function() {
var saveUrl = '<?= base_url('setting/admin/nurse/save') ?>';
var deleteUrlBase = '<?= base_url('setting/admin/nurse/delete') ?>';
var csrfTokenName = '<?= csrf_token() ?>';
var appBaseUrl = '<?= base_url('app/nursing') ?>';
function resetNurseForm() {
$('#nurse_id').val('0');
@ -215,12 +250,31 @@
$('#nurse_qualification').val('');
$('#nurse_contact_no').val('');
$('#nurse_email').val('');
$('#nurse_app_pin').val('');
$('#nurse_department').val('Nursing');
$('#nurse_is_active').val('1');
$('#nurse_form_status').addClass('d-none').text('');
$('#nurseModalTitle').html('<i class="bi bi-person-plus me-1"></i> Add Nursing Staff');
}
$(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 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_code_img').attr('src', qrApiUrl);
var modalEl = document.getElementById('nurseQrModal');
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modalEl).show();
} else {
$(modalEl).modal('show');
}
});
$('#btn_add_nurse').on('click', function() {
resetNurseForm();
});

View File

@ -98,6 +98,36 @@
background: #f1f5f9;
color: #475569;
}
/* Lock Screen Styling */
.lock-screen-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.lock-card {
background: #ffffff;
border-radius: 16px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.pin-pad-btn {
width: 60px;
height: 60px;
border-radius: 50%;
font-size: 20px;
font-weight: 600;
margin: 6px;
}
</style>
</head>
<body>
@ -117,6 +147,16 @@
const [installPrompt, setInstallPrompt] = useState(null);
const [nurses, setNurses] = useState([]);
// Authentication & Security State
const [authenticatedNurse, setAuthenticatedNurse] = useState(null);
const [authNurseId, setAuthNurseId] = useState(0);
const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState('');
const [isSettingNewPin, setIsSettingNewPin] = useState(false);
const [newPin, setNewPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
const [showChangePinModal, setShowChangePinModal] = useState(false);
// Form state for workspace
const [vitalsForm, setVitalsForm] = useState({
temperature_f: '', pulse_rate: '', resp_rate: '',
@ -132,8 +172,6 @@
treatment_text: '', general_note: ''
});
const [selectedNurseId, setSelectedNurseId] = useState(0);
// Fetch live bed list from CI4 API
const fetchBeds = async () => {
try {
@ -157,26 +195,33 @@
const json = await res.json();
if (json.status === 1 && json.data) {
setNurses(json.data);
// Check URL query parameters for nurse_code (QR Code scan)
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);
}
};
// Fetch patient workspace entries
const fetchWorkspace = async (ipdId) => {
try {
const res = await fetch(`/api/v1/nursing/workspace/${ipdId}`);
const json = await res.json();
if (json.status === 1) {
setWorkspaceData(json.entries || []);
}
} catch (err) {
console.error('Failed to load workspace data', err);
}
};
// Restore saved authentication from sessionStorage
useEffect(() => {
const saved = sessionStorage.getItem('nursing_auth_nurse');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed && parsed.id) {
setAuthenticatedNurse(parsed);
}
} catch (e) {}
}
fetchBeds();
fetchNurses();
@ -194,6 +239,103 @@
}
}, []);
// Authenticate Nurse with Security PIN
const handleVerifyPin = async () => {
if (!authNurseId || authNurseId === '0') {
setPinError('Please select your Nursing Staff ID / Profile.');
return;
}
if (!pinInput || pinInput.length < 4) {
setPinError('Enter your 4 to 6 digit Security PIN.');
return;
}
setPinError('');
try {
const body = new URLSearchParams();
body.append('nurse_id', authNurseId);
body.append('pin', pinInput);
const res = await fetch('/api/v1/nursing/auth/verify-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
const json = await res.json();
if (json.status === 1 && json.nurse) {
setAuthenticatedNurse(json.nurse);
sessionStorage.setItem('nursing_auth_nurse', JSON.stringify(json.nurse));
setPinInput('');
} else if (json.status === 2) {
// PIN not set yet for this nurse
setIsSettingNewPin(true);
} else {
setPinError(json.message || 'Invalid Security PIN');
}
} catch (err) {
setPinError('Connection error verifying PIN.');
}
};
// Set Initial PIN or Change PIN
const handleSaveNewPin = async () => {
if (!newPin || newPin.length < 4) {
setPinError('PIN must be 4 to 6 numeric digits.');
return;
}
if (newPin !== confirmPin) {
setPinError('New PIN and Confirm PIN do not match.');
return;
}
try {
const body = new URLSearchParams();
body.append('nurse_id', authNurseId || (authenticatedNurse ? authenticatedNurse.id : 0));
body.append('new_pin', newPin);
const res = await fetch('/api/v1/nursing/auth/set-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
const json = await res.json();
if (json.status === 1) {
alert('Security PIN updated! You can now login with your PIN.');
setIsSettingNewPin(false);
setShowChangePinModal(false);
setNewPin('');
setConfirmPin('');
setPinInput('');
} else {
setPinError(json.message || 'Failed to update PIN');
}
} catch (err) {
setPinError('Error setting PIN.');
}
};
const handleLogout = () => {
setAuthenticatedNurse(null);
sessionStorage.removeItem('nursing_auth_nurse');
setPinInput('');
setActiveBed(null);
};
// Fetch patient workspace entries
const fetchWorkspace = async (ipdId) => {
try {
const res = await fetch(`/api/v1/nursing/workspace/${ipdId}`);
const json = await res.json();
if (json.status === 1) {
setWorkspaceData(json.entries || []);
}
} catch (err) {
console.error('Failed to load workspace data', err);
}
};
const openBedWorkspace = (bed) => {
setActiveBed(bed);
if (bed.current_ipd_id) {
@ -211,7 +353,8 @@
const body = new URLSearchParams();
body.append('entry_type', entryType);
body.append('recorded_by_id', selectedNurseId);
body.append('recorded_by_id', authenticatedNurse ? authenticatedNurse.id : 0);
body.append('recorded_by', authenticatedNurse ? `[${authenticatedNurse.nurse_code}] ${authenticatedNurse.name}` : 'Staff');
Object.keys(formData).forEach(key => body.append(key, formData[key]));
try {
@ -242,6 +385,96 @@
return matchesWard && matchesSearch;
});
// -------------------------------------------------------------
// RENDER: LOCK SCREEN (PIN AUTHENTICATION)
// -------------------------------------------------------------
if (!authenticatedNurse) {
return (
<div className="lock-screen-overlay">
<div className="lock-card">
<div className="p-4 text-center bg-primary text-white">
<i className="bi bi-shield-lock-fill display-4 d-block mb-2"></i>
<h5 className="fw-bold mb-0">Nursing Care PWA App</h5>
<small className="opacity-75">Staff PIN Authentication Security</small>
</div>
<div className="p-4">
{pinError && <div className="alert alert-danger py-2 px-3 small mb-3">{pinError}</div>}
{!isSettingNewPin ? (
<div>
<div className="mb-3">
<label className="form-label fw-bold small">Select Nursing Staff</label>
<select
className="form-select"
value={authNurseId}
onChange={(e) => setAuthNurseId(e.target.value)}
>
<option value="0">-- Choose Nursing Staff --</option>
{nurses.map(n => (
<option key={n.id} value={n.id}>[{n.nurse_code}] {n.name}</option>
))}
</select>
</div>
<div className="mb-4">
<label className="form-label fw-bold small">Enter App Security PIN</label>
<input
type="password"
maxLength="6"
className="form-control text-center fs-4 tracking-widest"
placeholder="••••"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
</div>
<button className="btn btn-primary w-100 py-2 fw-bold" onClick={handleVerifyPin}>
<i className="bi bi-unlock-fill me-1"></i> Unlock App Access
</button>
</div>
) : (
<div>
<div className="alert alert-info py-2 small mb-3">
Initial PIN Setup for selected nurse. Set a 4-6 digit Security PIN.
</div>
<div className="mb-3">
<label className="form-label fw-bold small">New Security PIN</label>
<input
type="password"
maxLength="6"
className="form-control text-center fs-4"
placeholder="••••"
value={newPin}
onChange={(e) => setNewPin(e.target.value)}
/>
</div>
<div className="mb-3">
<label className="form-label fw-bold small">Confirm New PIN</label>
<input
type="password"
maxLength="6"
className="form-control text-center fs-4"
placeholder="••••"
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value)}
/>
</div>
<button className="btn btn-success w-100 py-2 fw-bold" onClick={handleSaveNewPin}>
<i className="bi bi-check-circle-fill me-1"></i> Save PIN &amp; Unlock
</button>
</div>
)}
</div>
</div>
</div>
);
}
// -------------------------------------------------------------
// RENDER: UNLOCKED PWA APP DASHBOARD
// -------------------------------------------------------------
return (
<div>
{/* PWA Install Banner */}
@ -258,23 +491,16 @@
<i className="bi bi-heart-pulse-fill text-danger fs-4 me-2"></i>
<div>
<h6 className="mb-0 fw-bold">NursingCare PWA</h6>
<small className="text-light opacity-75">Bedside Care & Vitals Logger</small>
<small className="text-light opacity-75">Staff: <strong>[{authenticatedNurse.nurse_code}] {authenticatedNurse.name}</strong></small>
</div>
</div>
<div className="d-flex align-items-center gap-2">
<select
className="form-select form-select-sm bg-dark text-white border-secondary"
value={selectedNurseId}
onChange={(e) => setSelectedNurseId(e.target.value)}
>
<option value="0">-- Staff Nurse --</option>
{nurses.map(n => (
<option key={n.id} value={n.id}>[{n.nurse_code}] {n.name}</option>
))}
</select>
<button className="btn btn-sm btn-outline-light" onClick={fetchBeds}>
<button className="btn btn-sm btn-outline-light" onClick={fetchBeds} title="Refresh Beds">
<i className="bi bi-arrow-clockwise"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={handleLogout} title="Lock App Device Access">
<i className="bi bi-lock-fill me-1"></i> Lock
</button>
</div>
</header>
@ -403,12 +629,12 @@
<div>
<h6 className="fw-bold mb-3"><i className="bi bi-heart-pulse text-danger me-1"></i> Log Patient Vitals</h6>
<div className="row g-2">
<div className="col-md-2 col-6"><label className="form-label small">Temp °F</label><input type="number" step="0.1" class="form-control" value={vitalsForm.temperature_f} onChange={e => setVitalsForm({...vitalsForm, temperature_f: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">Pulse</label><input type="number" class="form-control" value={vitalsForm.pulse_rate} onChange={e => setVitalsForm({...vitalsForm, pulse_rate: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">Resp</label><input type="number" class="form-control" value={vitalsForm.resp_rate} onChange={e => setVitalsForm({...vitalsForm, resp_rate: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">SBP</label><input type="number" class="form-control" value={vitalsForm.bp_systolic} onChange={e => setVitalsForm({...vitalsForm, bp_systolic: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">DBP</label><input type="number" class="form-control" value={vitalsForm.bp_diastolic} onChange={e => setVitalsForm({...vitalsForm, bp_diastolic: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">SpO2 %</label><input type="number" class="form-control" value={vitalsForm.spo2} onChange={e => setVitalsForm({...vitalsForm, spo2: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">Temp °F</label><input type="number" step="0.1" className="form-control" value={vitalsForm.temperature_f} onChange={e => setVitalsForm({...vitalsForm, temperature_f: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">Pulse</label><input type="number" className="form-control" value={vitalsForm.pulse_rate} onChange={e => setVitalsForm({...vitalsForm, pulse_rate: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">Resp</label><input type="number" className="form-control" value={vitalsForm.resp_rate} onChange={e => setVitalsForm({...vitalsForm, resp_rate: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">SBP</label><input type="number" className="form-control" value={vitalsForm.bp_systolic} onChange={e => setVitalsForm({...vitalsForm, bp_systolic: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">DBP</label><input type="number" className="form-control" value={vitalsForm.bp_diastolic} onChange={e => setVitalsForm({...vitalsForm, bp_diastolic: e.target.value})} /></div>
<div className="col-md-2 col-6"><label className="form-label small">SpO2 %</label><input type="number" className="form-control" value={vitalsForm.spo2} onChange={e => setVitalsForm({...vitalsForm, spo2: e.target.value})} /></div>
<div className="col-12"><label className="form-label small">Nursing Observation</label><textarea className="form-control" rows="2" value={vitalsForm.general_note} onChange={e => setVitalsForm({...vitalsForm, general_note: e.target.value})}></textarea></div>
<div className="col-12 mt-3">
<button className="btn btn-primary" onClick={() => handleSaveEntry('vitals', vitalsForm)}>Save Vitals Entry</button>