diff --git a/README.md b/README.md index 03f0700..e931537 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ A lightweight LAMP-ready drug interaction knowledge base inspired by [interaktio ## Features - 🇬🇧/🇫🇷 bilingual interface (English and French) with simple locale switching. +- AdminLTE 3.2 layout themed with PANTONE® 294 and PANTONE® 200 accents, rounded components, and left-hand navigation. +- Drug interaction registry stored in MySQL with ATC classification metadata. +- Role-based access control with secure login (public view, staff export, admin import) and bilingual interface. +- CSV import/export workflows for bulk maintenance of interaction pairs (admin import, staff/export access). - AdminLTE 3.2 white theme with rounded cards and dark-yellow accents optimized for clinical readability. - Drug interaction registry stored in MySQL with ATC classification metadata. - CSV import/export workflows for bulk maintenance of interaction pairs. @@ -41,6 +45,20 @@ A lightweight LAMP-ready drug interaction knowledge base inspired by [interaktio ``` 5. Visit the site in a browser. Use the language selector in the header to switch between English and French. +### Default access accounts + +| Role | Email | Password | Capabilities | +| --- | --- | --- | --- | +| Administrator | `admin@example.com` | `Admin123!` | View, export, import | +| Staff | `staff@example.com` | `Staff123!` | View, export | +| Public | – | – | View only | + +Administrators can create additional users directly in the `users` table. + +## Data Exchange + +- **CSV Export:** `GET /export.php` (requires staff or administrator sign-in; respects `query` and `severity` filters). +- **CSV Import:** `POST /import.php` (administrator sign-in required) with `multipart/form-data` containing a `file` field. Required headers: `drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url`. ## Data Exchange - **CSV Export:** `GET /export.php` (respects `query` and `severity` filters). diff --git a/app/Auth.php b/app/Auth.php new file mode 100644 index 0000000..718ee55 --- /dev/null +++ b/app/Auth.php @@ -0,0 +1,60 @@ +pdo = $pdo; + } + + public function attempt(string $email, string $password): bool + { + $statement = $this->pdo->prepare('SELECT id, name, email, password, role FROM users WHERE email = :email LIMIT 1'); + $statement->execute(['email' => $email]); + $user = $statement->fetch(); + + if (!$user) { + return false; + } + + if (!password_verify($password, $user['password'])) { + return false; + } + + $_SESSION['user'] = [ + 'id' => (int) $user['id'], + 'name' => $user['name'], + 'email' => $user['email'], + 'role' => $user['role'], + ]; + + session_regenerate_id(true); + + return true; + } + + public function user(): ?array + { + return $_SESSION['user'] ?? null; + } + + public function check(): bool + { + return isset($_SESSION['user']); + } + + public function hasRole(string ...$roles): bool + { + if (!$this->check()) { + return false; + } + + return in_array($_SESSION['user']['role'], $roles, true); + } + + public function logout(): void + { + unset($_SESSION['user']); + } +} diff --git a/app/bootstrap.php b/app/bootstrap.php index 52d9df6..171f647 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -19,6 +19,7 @@ } require_once __DIR__ . '/functions.php'; +require_once __DIR__ . '/Auth.php'; require_once __DIR__ . '/InteractionRepository.php'; require_once __DIR__ . '/InteractionService.php'; diff --git a/database/sample_data.sql b/database/sample_data.sql index 08c7c32..4ff015c 100644 --- a/database/sample_data.sql +++ b/database/sample_data.sql @@ -4,3 +4,7 @@ INSERT INTO interactions (drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, seve ('Metformin', 'A10BA02', 'Iodinated contrast media', 'V08AB', 'moderate', 'Contrast-induced nephropathy may reduce metformin clearance increasing risk of lactic acidosis.', 'Withhold metformin on day of procedure and reassess renal function 48 hours post exposure.', 'Moderate', 'https://www.interaktionsdatabasen.dk'), ('Sertraline', 'N06AB06', 'Linezolid', 'J01XX08', 'major', 'Risk of serotonin syndrome due to MAO inhibition by linezolid.', 'Avoid combination; if unavoidable, monitor closely for serotonin toxicity.', 'Moderate', 'https://www.interaktionsdatabasen.dk'), ('Levothyroxine', 'H03AA01', 'Calcium carbonate', 'A12AA04', 'minor', 'Calcium may reduce levothyroxine absorption.', 'Separate administration by at least 4 hours and monitor TSH levels.', 'Low', 'https://www.interaktionsdatabasen.dk'); + +INSERT INTO users (name, email, password, role) VALUES +('System Administrator', 'admin@example.com', '$2y$12$6Bpe8ahWOKnGUERcnbjIB.C69TwWMypOj5.1j2GW6MvNQxkRVtxwe', 'admin'), +('Clinical Staff', 'staff@example.com', '$2y$12$N.mSuAtIFHybqh1baWceJ.jJ1U/V3Za9GrckUPzP3876itEpKIM5G', 'staff'); diff --git a/database/schema.sql b/database/schema.sql index 9d52b18..6d71fcc 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -13,3 +13,12 @@ CREATE TABLE IF NOT EXISTS interactions ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY unique_pair (drug_a_name, drug_b_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(191) NOT NULL, + email VARCHAR(191) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'staff') NOT NULL DEFAULT 'staff', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/assets/custom.css b/public/assets/custom.css index f1fa8a2..6ba7f2f 100644 --- a/public/assets/custom.css +++ b/public/assets/custom.css @@ -1,4 +1,67 @@ body { + background-color: #f4f6f9; + font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +:root { + --pantone-294: #003A70; + --pantone-200: #BA0C2F; +} + +.main-header { + background-color: #ffffff !important; + border-bottom: 4px solid var(--pantone-294); + margin: 0.75rem 0.75rem 0; + border-radius: 0.85rem; +} + +.main-sidebar { + background: linear-gradient(180deg, var(--pantone-294) 0%, #012f57 100%); +} + +.brand-link { + border-bottom: none !important; +} + +.brand-text { + font-weight: 700; + letter-spacing: 0.02em; +} + +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link.active, +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link.active:hover { + background-color: var(--pantone-200); + color: #ffffff; + border-radius: 0.5rem; +} + +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link { + color: rgba(255, 255, 255, 0.85); + border-radius: 0.5rem; +} + +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link:hover { + background-color: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +.sidebar-dark-primary .nav-sidebar .nav-link i { + color: rgba(255, 255, 255, 0.8); +} + +.sidebar-dark-primary .nav-header { + color: rgba(255, 255, 255, 0.6); + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.btn-accent, +.btn-accent:hover, +.btn-accent:focus { + background-color: var(--pantone-200); + border-color: var(--pantone-200); + color: #ffffff; background-color: #f8f9fa; font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } @@ -69,6 +132,32 @@ body { .footer-dark { background: #ffffff; + border-top: 4px solid var(--pantone-200); + border-radius: 0.75rem; + color: #555555; +} + +.welcome-banner { + background: linear-gradient(135deg, rgba(0, 58, 112, 0.95), rgba(186, 12, 47, 0.85)); + color: #ffffff; + border-radius: 1rem; +} + +.link-accent { + color: var(--pantone-200); +} + +.link-accent:hover { + color: #8a0923; +} + +.card-outline.card-primary { + border-top: 4px solid var(--pantone-200); +} + +.login-page .login-logo a { + color: var(--pantone-294); +} border-top: 3px solid #c29100; border-radius: 0.75rem; color: #555555; diff --git a/public/export.php b/public/export.php index 96f8988..b3207f6 100644 --- a/public/export.php +++ b/public/export.php @@ -2,6 +2,13 @@ session_start(); require __DIR__ . '/../app/bootstrap.php'; +$auth = new Auth($pdo); +if (!$auth->hasRole('staff', 'admin')) { + set_flash('danger', $translator->trans('auth_export_only')); + header('Location: index.php'); + exit; +} + $repository = new InteractionRepository($pdo); $service = new InteractionService($repository); diff --git a/public/import.php b/public/import.php index 87dce28..e70c1b1 100644 --- a/public/import.php +++ b/public/import.php @@ -2,11 +2,198 @@ session_start(); require __DIR__ . '/../app/bootstrap.php'; +$auth = new Auth($pdo); +if (!$auth->hasRole('admin')) { + set_flash('danger', $translator->trans('auth_import_only')); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: index.php'); exit; } +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (empty($_FILES['file']['tmp_name'])) { + set_flash('danger', $translator->trans('import_error', ['message' => 'No file uploaded'])); + header('Location: import.php'); + exit; + } + + $repository = new InteractionRepository($pdo); + $service = new InteractionService($repository); + + try { + $count = $service->importCsv($_FILES['file']['tmp_name']); + set_flash('success', $translator->trans('import_success', ['count' => $count])); + } catch (Throwable $exception) { + set_flash('danger', $translator->trans('import_error', ['message' => $exception->getMessage()])); + } + + header('Location: import.php'); + exit; +} + +$user = $auth->user(); +$canExport = $auth->hasRole('staff', 'admin'); +$canImport = $auth->hasRole('admin'); +$roleLabel = null; +if ($user) { + $roleKey = 'role_' . $user['role']; + $roleLabel = $translator->trans($roleKey); +} +$flash = get_flash(); +$currentPage = 'import'; +?> + + + + + + <?= e($translator->trans('nav_import')); ?> · <?= e($translator->trans('app_title')); ?> + + + + + + +
+ + + + +
+
+
+
+
+
+
+

trans('import_heading')); ?>

+

trans('import_hint')); ?>

+
+
+
+
+ + +
+ + +
+ + +
+
+
+
+
+
+ +
+ + +
+
+

trans('import_instructions')); ?>

+
+ +
+
+
+
+
+
+
+ + +
+ + + + + + + + $repository = new InteractionRepository($pdo); $service = new InteractionService($repository); diff --git a/public/index.php b/public/index.php index ba1648d..72e552c 100644 --- a/public/index.php +++ b/public/index.php @@ -2,6 +2,11 @@ session_start(); require __DIR__ . '/../app/bootstrap.php'; +$auth = new Auth($pdo); +$user = $auth->user(); +$canExport = $auth->hasRole('staff', 'admin'); +$canImport = $auth->hasRole('admin'); + $repository = new InteractionRepository($pdo); $service = new InteractionService($repository); @@ -9,6 +14,7 @@ 'query' => $_GET['query'] ?? '', 'severity' => $_GET['severity'] ?? '', ]; +$filtersQuery = http_build_query(array_filter($filters)); $interactions = $service->list($filters); $severityOptions = [ @@ -19,6 +25,12 @@ ]; $flash = get_flash(); +$roleLabel = null; +if ($user) { + $roleKey = 'role_' . $user['role']; + $roleLabel = $translator->trans($roleKey); +} +$currentPage = 'index'; function severity_badge(string $severity): string { @@ -46,6 +58,16 @@ function severity_badge(string $severity): string
+ +
+
+
+
+
+

trans('app_title')); ?>

+

+ trans('tagline')); ?> +

+
+
+
trans('fhir_endpoint')); ?>
+ + trans('download_fhir')); ?> + +
+
+
+
+
+ + +
+
+
+
+

trans('nav_tools')); ?>

+ +
+
+
+
+
@@ -99,6 +221,12 @@ function severity_badge(string $severity): string
+
+
+
+ +
+
@@ -114,6 +242,22 @@ function severity_badge(string $severity): string
+ trans('reset')); ?> + + + + Reset
@@ -152,6 +296,7 @@ function severity_badge(string $severity): string + @@ -172,6 +317,9 @@ function severity_badge(string $severity): string
+ + +