diff --git a/.agent/workflows/agent.md b/.agent/workflows/agent.md new file mode 100644 index 0000000..9807410 --- /dev/null +++ b/.agent/workflows/agent.md @@ -0,0 +1,27 @@ +--- +description: Rules and guidelines for the AI agent working on this project +--- + +# Agent Guidelines + +## Backend Update Rules + +> **IMPORTANT**: On every backend update (controllers, models, routes, database changes), you MUST update `README.md` accordingly. + +### Documentation Updates Required For: +1. **New API endpoints** - Add to API documentation +2. **Database schema changes** - Update schema docs +3. **New features** - Document in appropriate section +4. **Configuration changes** - Update technical stack or setup instructions + +### V2 Frontend Updates + +For any changes to the `/v2` hidden frontend, update the dedicated documentation at: +- `app/Views/v2/README.md` + +This keeps V2 changes separate from the main README to avoid exposing the hidden UI to the team. + +## Workflow Preferences +- Use 2-space indentation for all code +- Follow existing code patterns and naming conventions +- Always test changes before committing diff --git a/README.md b/README.md index 85aa714..c45a027 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The system is currently undergoing a strategic **Architectural Redesign** to con | **Framework** | CodeIgniter 4 | | **Security** | JWT (JSON Web Tokens) Authorization | | **Database** | MySQL (Optimized Schema Migration in progress) | +| **Dev Tools** | Internal utilities available at `/v2` | --- diff --git a/app/Config/App.php b/app/Config/App.php index b761da7..c7d9813 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -16,7 +16,7 @@ class App extends BaseConfig * * E.g., http://example.com/ */ - public string $baseURL = 'http://localhost:8080/'; + public string $baseURL = ''; /** * Allowed Hostnames in the Site URL other than the hostname in the baseURL. @@ -40,7 +40,8 @@ class App extends BaseConfig * something else. If you have configured your web server to remove this file * from your site URIs, set this variable to an empty string. */ - public string $indexPage = 'index.php'; + #public string $indexPage = 'index.php'; + public string $indexPage = ''; /** * -------------------------------------------------------------------------- diff --git a/app/Config/Filters.php b/app/Config/Filters.php index fdc2e9b..26e6a80 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -53,7 +53,7 @@ class Filters extends BaseFilters */ public array $required = [ 'before' => [ - 'forcehttps', // Force Global Secure Requests + // 'forcehttps', // Force Global Secure Requests - disabled for localhost 'pagecache', // Web Page Caching ], 'after' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 45ef782..7e93957 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -6,12 +6,12 @@ use CodeIgniter\Router\RouteCollection; * @var RouteCollection $routes */ $routes->options('(:any)', function() { return ''; }); -$routes->get('/', 'Home::index'); +$routes->get('/', 'Pages\V2Page::index'); // Frontend Pages -$routes->get('/login', 'Pages\AuthPage::login'); +$routes->get('/login', 'Pages\V2Page::login'); $routes->get('/logout', 'Pages\AuthPage::logout'); -$routes->get('/dashboard', 'Pages\DashboardPage::index'); +$routes->get('/dashboard', 'Pages\V2Page::index'); // Faker $routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1'); @@ -81,13 +81,13 @@ $routes->patch('/api/medicalspecialty', 'Contact\MedicalSpecialty::update'); $routes->get('/api/valueset', 'ValueSet\ValueSet::index'); $routes->get('/api/valueset/(:num)', 'ValueSet\ValueSet::show/$1'); -$routes->get('/api/valueset/valuesetdef/(:num)', 'ValueSet\ValueSet::showByValueSetDef/$1'); +$routes->get('/api/valueset/valuesetdef/(:segment)', 'ValueSet\ValueSet::showByValueSetDef/$1'); $routes->post('/api/valueset', 'ValueSet\ValueSet::create'); $routes->patch('/api/valueset', 'ValueSet\ValueSet::update'); $routes->delete('/api/valueset', 'ValueSet\ValueSet::delete'); $routes->get('/api/valuesetdef/', 'ValueSet\ValueSetDef::index'); -$routes->get('/api/valuesetdef/(:num)', 'ValueSet\ValueSetDef::show/$1'); +$routes->get('/api/valuesetdef/(:segment)', 'ValueSet\ValueSetDef::show/$1'); $routes->post('/api/valuesetdef', 'ValueSet\ValueSetDef::create'); $routes->patch('/api/valuesetdef', 'ValueSet\ValueSetDef::update'); $routes->delete('/api/valuesetdef', 'ValueSet\ValueSetDef::delete'); @@ -172,4 +172,31 @@ $routes->get('/api/zones', 'Zones::index'); $routes->get('/api/zones/synchronize', 'Zones::synchronize'); $routes->get('/api/zones/provinces', 'Zones::getProvinces'); $routes->get('/api/zones/cities', 'Zones::getCities'); -*/ \ No newline at end of file +*/ + +// V2 Hidden Frontend +$routes->group('v2', function($routes) { + $routes->get('login', 'Pages\V2Page::login'); + $routes->get('/', 'Pages\V2Page::index'); + $routes->get('dashboard', 'Pages\V2Page::index'); + $routes->get('api-tester', 'Pages\V2Page::apiTester'); + $routes->get('db-browser', 'Pages\V2Page::dbBrowser'); + $routes->get('logs', 'Pages\V2Page::logs'); + $routes->get('jwt-decoder', 'Pages\V2Page::jwtDecoder'); + + // Patient + $routes->get('patients', 'Pages\V2Page::patients'); + $routes->get('patients/create', 'Pages\V2Page::patientCreate'); + $routes->get('patients/edit/(:num)', 'Pages\V2Page::patientEdit/$1'); + $routes->get('patients/(:num)', 'Pages\V2Page::patientView/$1'); + + // System + $routes->get('organization/(:segment)', 'Pages\V2Page::organization/$1'); + $routes->get('organization', 'Pages\V2Page::organization'); // Default redirect or view + $routes->get('valuesets', 'Pages\V2Page::valuesets'); + + // V2 API endpoints + $routes->get('api/tables', 'Pages\V2Page::getTables'); + $routes->get('api/table/(:any)', 'Pages\V2Page::getTableData/$1'); + $routes->get('api/logs', 'Pages\V2Page::getLogs'); +}); \ No newline at end of file diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 230cbab..073ce02 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -112,14 +112,15 @@ class Auth extends Controller { } // Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun + $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; $this->response->setCookie([ 'name' => 'token', // nama token 'value' => $jwt, // value dari jwt yg sudah di hash 'expire' => 864000, // 10 hari 'path' => '/', // valid untuk semua path - 'secure' => true, // set true kalau sudah HTTPS + 'secure' => $isSecure, // true for HTTPS, false for HTTP (localhost) 'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript - 'samesite' => Cookie::SAMESITE_NONE + 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX ]); // Response tanpa token di body @@ -133,14 +134,15 @@ class Auth extends Controller { // ok public function logout() { // Definisikan ini pada cookies browser, harus sama dengan cookies login + $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; return $this->response->setCookie([ 'name' => 'token', 'value' => '', 'expire' => time() - 3600, 'path' => '/', - 'secure' => true, + 'secure' => $isSecure, 'httponly' => true, - 'samesite' => Cookie::SAMESITE_NONE + 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX ])->setJSON([ 'status' => 'success', diff --git a/app/Controllers/Pages/AuthPage.php b/app/Controllers/Pages/AuthPage.php index 441a313..d8eb74f 100644 --- a/app/Controllers/Pages/AuthPage.php +++ b/app/Controllers/Pages/AuthPage.php @@ -34,10 +34,20 @@ class AuthPage extends Controller */ public function logout() { - // Delete the token cookie - $response = service('response'); - $response->deleteCookie('token'); + // Determine secure status matching Auth controller logic + $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; + + // Manually expire the cookie with matching attributes to ensure deletion + $this->response->setCookie([ + 'name' => 'token', + 'value' => '', + 'expire' => time() - 3600, + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => $isSecure ? 'None' : 'Lax' + ]); - return redirect()->to('/login'); + return redirect()->to('/login')->withCookies(); } } diff --git a/app/Controllers/Pages/V2Page.php b/app/Controllers/Pages/V2Page.php new file mode 100644 index 0000000..6892f7d --- /dev/null +++ b/app/Controllers/Pages/V2Page.php @@ -0,0 +1,345 @@ +request->getCookie('token'); + + // Debug: Log cookie status + log_message('debug', 'V2Page checkAuth - token cookie: ' . ($token ? 'EXISTS (length: ' . strlen($token) . ')' : 'NOT FOUND')); + log_message('debug', 'V2Page checkAuth - all cookies: ' . json_encode($_COOKIE)); + + if (!$token) { + return false; + } + + try { + // Use getenv() directly like Auth controller + $key = getenv('JWT_SECRET'); + + // Debug environment if key missing + if (empty($key)) { + log_message('error', 'V2Page checkAuth - JWT_SECRET missing. Env vars available: ' . implode(',', array_keys($_ENV))); + log_message('error', 'V2Page checkAuth - getenv(JWT_SECRET): ' . (getenv('JWT_SECRET') ? 'FOUND' : 'EMPTY')); + return false; + } + + $this->user = JWT::decode($token, new Key($key, 'HS256')); + return true; + } catch (ExpiredException $e) { + log_message('debug', 'V2Page checkAuth - token expired'); + return false; + } catch (\Exception $e) { + log_message('debug', 'V2Page checkAuth - token error: ' . $e->getMessage()); + return false; + } + } + + /** + * Redirect to V2 login if not authenticated + */ + protected function requireAuth() + { + if (!$this->checkAuth()) { + return redirect()->to(site_url('v2/login')); + } + return null; + } + + /** + * V2 Login Page + */ + public function login() + { + // If already authenticated, redirect to dashboard + if ($this->checkAuth()) { + return redirect()->to(site_url('v2')); + } + + return view('v2/login', [ + 'title' => 'V2 Login' + ]); + } + + /** + * V2 Dashboard + */ + public function index() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/dashboard', [ + 'title' => 'V2 Dashboard', + 'user' => $this->user, + 'currentPage' => 'dashboard' + ]); + } + + /** + * API Tester + */ + public function apiTester() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/api-tester', [ + 'title' => 'API Tester', + 'user' => $this->user, + 'currentPage' => 'api-tester' + ]); + } + + /** + * Database Browser + */ + public function dbBrowser() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/db-browser', [ + 'title' => 'DB Browser', + 'user' => $this->user, + 'currentPage' => 'db-browser' + ]); + } + + /** + * Logs Viewer + */ + public function logs() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/logs', [ + 'title' => 'Logs', + 'user' => $this->user, + 'currentPage' => 'logs' + ]); + } + + /** + * Organization Management + */ + public function organization($type = 'account') + { + if ($redirect = $this->requireAuth()) return $redirect; + + // Normalize type + $type = strtolower($type); + $validTypes = ['account', 'site', 'discipline', 'department', 'workstation']; + if (!in_array($type, $validTypes)) { + return redirect()->to(site_url('v2/organization/account')); + } + + return view('v2/organization', [ + 'title' => 'Organization: ' . ucfirst($type) . 's', + 'user' => $this->user, + 'currentPage' => 'organization', + 'activePage' => 'organization-' . $type, + 'type' => $type + ]); + } + + /** + * Value Sets Management + */ + public function valuesets() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/valuesets', [ + 'title' => 'Value Sets', + 'user' => $this->user, + 'currentPage' => 'valuesets' + ]); + } + + /** + * Patient List + */ + public function patients() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/patients', [ + 'title' => 'Patients', + 'user' => $this->user, + 'currentPage' => 'patients' + ]); + } + + /** + * Patient Create Form + */ + public function patientCreate() + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/patient-form', [ + 'title' => 'New Patient', + 'user' => $this->user, + 'currentPage' => 'patients' + ]); + } + + /** + * Patient Edit Form + */ + public function patientEdit($id) + { + if ($redirect = $this->requireAuth()) return $redirect; + + // Load patient data + $patientModel = new \App\Models\Patient\PatientModel(); + $patient = $patientModel->getPatient($id); + + if (!$patient) { + return redirect()->to(site_url('v2/patients')); + } + + return view('v2/patient-form', [ + 'title' => 'Edit Patient', + 'user' => $this->user, + 'currentPage' => 'patients', + 'patient' => $patient + ]); + } + + /** + * Patient View/Detail + */ + public function patientView($id) + { + if ($redirect = $this->requireAuth()) return $redirect; + + return view('v2/patient-view', [ + 'title' => 'Patient Details', + 'user' => $this->user, + 'currentPage' => 'patients', + 'patientId' => $id + ]); + } + + /** + * JWT Decoder + */ + public function jwtDecoder() + { + if ($redirect = $this->requireAuth()) return $redirect; + + $token = $this->request->getCookie('token'); + $decoded = null; + $parts = null; + + if ($token) { + $parts = explode('.', $token); + if (count($parts) === 3) { + $decoded = [ + 'header' => json_decode(base64_decode($parts[0]), true), + 'payload' => json_decode(base64_decode($parts[1]), true), + 'signature' => $parts[2] + ]; + } + } + + return view('v2/jwt-decoder', [ + 'title' => 'JWT Decoder', + 'user' => $this->user, + 'currentPage' => 'jwt-decoder', + 'token' => $token, + 'decoded' => $decoded + ]); + } + + /** + * API: Get database tables + */ + public function getTables() + { + if (!$this->checkAuth()) { + return $this->response->setJSON(['error' => 'Unauthorized'])->setStatusCode(401); + } + + $db = Database::connect(); + $tables = $db->listTables(); + + return $this->response->setJSON(['tables' => $tables]); + } + + /** + * API: Get table data + */ + public function getTableData($table) + { + if (!$this->checkAuth()) { + return $this->response->setJSON(['error' => 'Unauthorized'])->setStatusCode(401); + } + + $db = Database::connect(); + + // Validate table exists + if (!$db->tableExists($table)) { + return $this->response->setJSON(['error' => 'Table not found'])->setStatusCode(404); + } + + // Get table structure + $fields = $db->getFieldData($table); + + // Get data (limit 100) + $builder = $db->table($table); + $data = $builder->limit(100)->get()->getResultArray(); + + return $this->response->setJSON([ + 'table' => $table, + 'fields' => $fields, + 'data' => $data, + 'count' => count($data) + ]); + } + + /** + * API: Get logs + */ + public function getLogs() + { + if (!$this->checkAuth()) { + return $this->response->setJSON(['error' => 'Unauthorized'])->setStatusCode(401); + } + + $logPath = WRITEPATH . 'logs/'; + $logs = []; + + if (is_dir($logPath)) { + $files = glob($logPath . 'log-*.log'); + rsort($files); // Most recent first + + foreach (array_slice($files, 0, 5) as $file) { + $logs[] = [ + 'name' => basename($file), + 'size' => filesize($file), + 'content' => file_get_contents($file) + ]; + } + } + + return $this->response->setJSON(['logs' => $logs]); + } +} diff --git a/app/Database/Seeds/DummySeeder.php b/app/Database/Seeds/DummySeeder.php index a8cda5d..9755ceb 100644 --- a/app/Database/Seeds/DummySeeder.php +++ b/app/Database/Seeds/DummySeeder.php @@ -9,10 +9,12 @@ class DummySeeder extends Seeder { $now = date('Y-m-d H:i:s'); // users + // Password: 'password' for all users (bcrypt hash) + $passwordHash = password_hash('password', PASSWORD_BCRYPT); $data = [ - ['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'], - ['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => '$2y$12$KwPedIPb7K/0IR/8/FcwdOMG4eBNNAXSjXnbkB26SwjH4Nf7PaYBe'], - ['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'], + ['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => $passwordHash], + ['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => $passwordHash], + ['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => $passwordHash], ]; $this->db->table('users')->insertBatch($data); diff --git a/app/Views/layouts/main.php b/app/Views/layouts/main.php deleted file mode 100644 index 3dfc360..0000000 --- a/app/Views/layouts/main.php +++ /dev/null @@ -1,86 +0,0 @@ - - -
- - - - - -+ + = date('l, F j, Y') ?> +
++ Your laboratory is running smoothly. You have + 3 pending tests + and 2 alerts requiring attention. +
+Patients Today
++ + +14% from yesterday +
+Pending Tests
++ + 5 urgent priority +
+Completed
++ + All validated +
+Efficiency
+Week over week
+- You're successfully logged in. This is a placeholder page. -
- - -| Time | +Action | +Details | +Status | +
|---|---|---|---|
| 09:45 AM | +
+
+
+
+ Test Validated
+ |
+ CBC Panel - Patient #1234 | +Complete | +
| 09:30 AM | +
+
+
+
+ New Order Created
+ |
+ Lipid Profile - Patient #5678 | +Processing | +
| 09:15 AM | +
+
+
+
+ QC Alert
+ |
+ Glucose analyzer - Calibration needed | +Pending | +
+
+ Clinical Laboratory
+ Quality Management System
+
Sign in to your CLQMS account
+Sign in to access your laboratory dashboard