diff --git a/api/answer_options.php b/api/answer_options.php index a9e5bdf..4c48690 100644 --- a/api/answer_options.php +++ b/api/answer_options.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $method = $_SERVER['REQUEST_METHOD']; switch ($method) { diff --git a/api/assignments.php b/api/assignments.php index d96da50..cc36db4 100644 --- a/api/assignments.php +++ b/api/assignments.php @@ -4,8 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); -require_role(['admin', 'supervisor'], $tokenRec); +$tokenRec = require_valid_token_web(); $callerRole = $tokenRec['role']; $callerEntityID = $tokenRec['entityID'] ?? ''; diff --git a/api/export.php b/api/export.php index 691b12f..61fd1a6 100644 --- a/api/export.php +++ b/api/export.php @@ -2,7 +2,7 @@ require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../db_init.php'; -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); if ($_SERVER['REQUEST_METHOD'] !== 'GET') { header('Content-Type: application/json; charset=UTF-8'); diff --git a/api/index.php b/api/index.php index 37852ee..f63c10b 100644 --- a/api/index.php +++ b/api/index.php @@ -11,7 +11,7 @@ require_once __DIR__ . '/../lib/validate.php'; // CORS header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); -header('Access-Control-Allow-Headers: Content-Type, Authorization'); +header('Access-Control-Allow-Headers: Content-Type, Authorization, X-QDB-Client'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); diff --git a/api/questionnaires.php b/api/questionnaires.php index 831312f..6bff3da 100644 --- a/api/questionnaires.php +++ b/api/questionnaires.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $method = $_SERVER['REQUEST_METHOD']; switch ($method) { diff --git a/api/questions.php b/api/questions.php index 18f6a35..d5c9461 100644 --- a/api/questions.php +++ b/api/questions.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $method = $_SERVER['REQUEST_METHOD']; switch ($method) { diff --git a/api/results.php b/api/results.php index fd49a90..05074bf 100644 --- a/api/results.php +++ b/api/results.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); diff --git a/api/translations.php b/api/translations.php index b5d202b..ebd39ff 100644 --- a/api/translations.php +++ b/api/translations.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $method = $_SERVER['REQUEST_METHOD']; $validTypes = ['question', 'answer_option', 'string', 'language']; diff --git a/api/users.php b/api/users.php index 68d5473..44465bf 100644 --- a/api/users.php +++ b/api/users.php @@ -4,8 +4,7 @@ require_once __DIR__ . '/../db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); -require_role(['admin', 'supervisor'], $tokenRec); +$tokenRec = require_valid_token_web(); $callerRole = $tokenRec['role']; $callerEntityID = $tokenRec['entityID'] ?? ''; diff --git a/assign_client.php b/assign_client.php index 1225d03..9f51535 100644 --- a/assign_client.php +++ b/assign_client.php @@ -5,8 +5,7 @@ require_once __DIR__ . '/db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); -require_role(['admin', 'supervisor'], $tokenRec); +$tokenRec = require_valid_token_web(); $role = $tokenRec['role']; $entityID = $tokenRec['entityID'] ?? ''; diff --git a/backup.php b/backup.php index c716137..cf203b2 100644 --- a/backup.php +++ b/backup.php @@ -4,7 +4,7 @@ require_once __DIR__ . '/db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); require_role(['admin'], $tokenRec); $dbPath = QDB_PATH; diff --git a/change_password.php b/change_password.php index fb400aa..b17c661 100644 --- a/change_password.php +++ b/change_password.php @@ -65,6 +65,16 @@ try { exit; } + if (($user['role'] ?? '') === 'coach') { + qdb_discard($tmpDb, $lockFp); + http_response_code(403); + echo json_encode([ + "success" => false, + "message" => "Coach accounts can only sign in through the mobile app.", + ]); + exit; + } + if (!password_verify($oldPassword, $user['passwordHash'])) { qdb_discard($tmpDb, $lockFp); http_response_code(401); diff --git a/common.php b/common.php index b48df17..b638688 100644 --- a/common.php +++ b/common.php @@ -178,6 +178,35 @@ function require_role(array $allowed, array $tokenRecord): void { } } +/** Roles that may use the management website (not the mobile app). */ +function qdb_web_roles(): array { + return ['admin', 'supervisor']; +} + +/** True when the request originates from the management website (see website/js/api.js). */ +function qdb_is_web_client_request(): bool { + $h = $_SERVER['HTTP_X_QDB_CLIENT'] ?? ''; + return strtolower(trim($h)) === 'web'; +} + +/** Reject coach sign-in from the website; coaches use the mobile app only. */ +function qdb_reject_coach_web_login(string $role): void { + if ($role === 'coach' && qdb_is_web_client_request()) { + json_error( + 'FORBIDDEN', + 'Coach accounts can only sign in through the mobile app.', + 403 + ); + } +} + +/** Valid bearer token plus admin/supervisor role (website API). */ +function require_valid_token_web(): array { + $rec = require_valid_token(); + require_role(qdb_web_roles(), $rec); + return $rec; +} + /** * Build a SQL WHERE clause fragment that restricts client visibility by role. * Returns [string $clause, array $params]. diff --git a/db_download.php b/db_download.php index a0e979b..617e2ab 100644 --- a/db_download.php +++ b/db_download.php @@ -2,7 +2,7 @@ // /var/www/html/db_download.php require_once __DIR__ . '/db_init.php'; -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $dbPath = QDB_PATH; if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; } diff --git a/db_view.php b/db_view.php index 20fa0a5..4896167 100644 --- a/db_view.php +++ b/db_view.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/db_init.php'; header('Content-Type: text/html; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $dbPath = QDB_PATH; if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; } diff --git a/db_view_ordered.php b/db_view_ordered.php index 3e467b3..21b08f1 100644 --- a/db_view_ordered.php +++ b/db_view_ordered.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/db_init.php'; header('Content-Type: text/html; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $ORDER_JSON = __DIR__ . '/header_order.json'; $LABELS_JSON = __DIR__ . '/questions_en.json'; diff --git a/downloadFull.php b/downloadFull.php index 36c394b..e28b7d9 100755 --- a/downloadFull.php +++ b/downloadFull.php @@ -2,7 +2,7 @@ // /var/www/html/downloadFull.php require_once __DIR__ . '/db_init.php'; -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); $token = $tokenRec['_token']; $dbPath = QDB_PATH; diff --git a/handlers/answer_options.php b/handlers/answer_options.php index 433f327..d823409 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -1,6 +1,6 @@ false, + "message" => "Coach accounts can only sign in through the mobile app.", + ]); + exit; + } + if ((int)$user['mustChangePassword'] === 1) { $tempToken = bin2hex(random_bytes(32)); token_add($tempToken, 10 * 60, [ diff --git a/manage_users.php b/manage_users.php index 1a09f21..c00b9c8 100644 --- a/manage_users.php +++ b/manage_users.php @@ -9,7 +9,7 @@ require_once __DIR__ . '/db_init.php'; header('Content-Type: application/json; charset=UTF-8'); -$tokenRec = require_valid_token(); +$tokenRec = require_valid_token_web(); require_role(['admin'], $tokenRec); // ----------------------------------------------------------------------- diff --git a/website/js/api.js b/website/js/api.js index 88bd13a..5746f41 100644 --- a/website/js/api.js +++ b/website/js/api.js @@ -7,6 +7,7 @@ function getToken() { async function apiFetch(endpoint, options = {}) { const token = getToken(); const headers = options.headers || {}; + headers['X-QDB-Client'] = 'web'; if (token) headers['Authorization'] = `Bearer ${token}`; if (!(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json; charset=UTF-8'; diff --git a/website/js/app.js b/website/js/app.js index de12b32..a0dc7ca 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -19,9 +19,26 @@ export function getRole() { export function getUser() { return localStorage.getItem('qdb_user') || ''; } +const WEBSITE_ROLES = ['admin', 'supervisor']; + +export function canAccessWebsite() { + return WEBSITE_ROLES.includes(getRole()); +} + export function canEdit() { - const r = getRole(); - return r === 'admin' || r === 'supervisor'; + return canAccessWebsite(); +} + +function clearSession() { + localStorage.removeItem('qdb_token'); + localStorage.removeItem('qdb_user'); + localStorage.removeItem('qdb_role'); +} + +function rejectCoachSession() { + clearSession(); + showToast('Coach accounts must use the mobile app.', 'error'); + navigate('#/login'); } export function showToast(message, type = 'info') { @@ -40,6 +57,7 @@ export function showToast(message, type = 'info') { function authGuard(handler) { return (params) => { if (!isLoggedIn()) { navigate('#/login'); return; } + if (!canAccessWebsite()) { rejectCoachSession(); return; } return handler(params); }; } @@ -95,12 +113,14 @@ addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage)); document.addEventListener('DOMContentLoaded', () => { document.getElementById('logoutBtn').addEventListener('click', (e) => { e.preventDefault(); - localStorage.removeItem('qdb_token'); - localStorage.removeItem('qdb_user'); - localStorage.removeItem('qdb_role'); + clearSession(); navigate('#/login'); }); + if (isLoggedIn() && !canAccessWebsite()) { + clearSession(); + } + window.addEventListener('hashchange', updateNav); updateNav(); startRouter(); diff --git a/website/js/pages/login.js b/website/js/pages/login.js index 5af56c1..7a25d89 100644 --- a/website/js/pages/login.js +++ b/website/js/pages/login.js @@ -55,7 +55,10 @@ export function loginPage() { try { const res = await fetch('../api/auth/login', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-QDB-Client': 'web', + }, body: JSON.stringify({ username, password }), }); const json = await res.json(); @@ -65,6 +68,11 @@ export function loginPage() { return; } const d = json.data; + if (d.role === 'coach') { + errEl.textContent = 'Coach accounts can only sign in through the mobile app.'; + errEl.style.display = ''; + return; + } if (d.mustChangePassword) { pendingUsername = username; pendingTempToken = d.token; @@ -95,7 +103,10 @@ export function loginPage() { } const oldPw = document.getElementById('password').value; try { - const headers = { 'Content-Type': 'application/json' }; + const headers = { + 'Content-Type': 'application/json', + 'X-QDB-Client': 'web', + }; if (pendingTempToken) { headers['Authorization'] = `Bearer ${pendingTempToken}`; } @@ -111,6 +122,11 @@ export function loginPage() { return; } const d = json.data; + if (d.role === 'coach') { + errEl.textContent = 'Coach accounts can only sign in through the mobile app.'; + errEl.style.display = ''; + return; + } localStorage.setItem('qdb_token', d.token); localStorage.setItem('qdb_user', d.user); localStorage.setItem('qdb_role', d.role);