diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 2da5aae..a1373ff 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -941,11 +941,12 @@ $routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::e /*management navigation bar*/ -$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth']); -$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth']); -$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth']); -$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth']); -$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth']); +$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth:administrator']); +$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth:administrator']); +$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth:administrator']); +$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth:administrator']); +$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth:administrator']); +$routes->post('nav-builder/role-access', 'View\NavBuilderController::roleAccess', ['filter' => 'auth:administrator']); // Teacher book distribution is the only inventory path teachers may access directly. diff --git a/app/Controllers/View/NavBuilderController.php b/app/Controllers/View/NavBuilderController.php index 6493f21..283fa0a 100644 --- a/app/Controllers/View/NavBuilderController.php +++ b/app/Controllers/View/NavBuilderController.php @@ -22,33 +22,14 @@ class NavBuilderController extends BaseController protected function ensureAdmin(): void { - $sessionRole = session()->get('role'); // could be a string or array in your app - $roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole]; - $roleNames = array_values(array_filter(array_map('strval', $roleNames))); + $session = session(); + $roles = array_filter(array_merge( + (array) $session->get('roles'), + (array) $session->get('role') + )); - if (empty($roleNames)) { - throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); - } - - $db = \Config\Database::connect(); - - // Map role names -> ids - $roleIdRows = $db->table('roles')->select('id')->whereIn('name', $roleNames)->get()->getResultArray(); - $roleIds = array_map('intval', array_column($roleIdRows, 'id')); - if (empty($roleIds)) { - throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); - } - - // Is this route allowed for any of the user's roles? - $allowed = $db->table('role_nav_items AS rni') - ->select('1') - ->join('nav_items AS ni', 'ni.id = rni.nav_item_id') - ->where('ni.url', 'nav-builder') // IMPORTANT: your current route path - ->whereIn('rni.role_id', $roleIds) - ->get(1)->getFirstRow(); - - if (!$allowed) { - // You can show a nicer "Access Denied" view if you prefer + $normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles); + if (!in_array('administrator', $normalizedRoles, true)) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); } } @@ -106,6 +87,15 @@ public function save() if ($menuParentId !== null) { $parent = $this->items->select('id')->where('id', $menuParentId)->first(); if (!$parent) { + if ($this->wantsJson()) { + return $this->response + ->setStatusCode(422) + ->setJSON([ + 'ok' => false, + 'message' => 'Selected parent does not exist.', + 'csrf' => $this->csrfPayload(), + ]); + } return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput(); } } @@ -139,9 +129,33 @@ public function save() } $this->service->clearCache(); + if ($this->wantsJson()) { + return $this->response->setJSON([ + 'ok' => true, + 'message' => 'Menu saved.', + 'id' => $id, + 'csrf' => $this->csrfPayload(), + 'payload' => $this->buildNavPayload(), + ]); + } + return redirect()->back()->with('success', 'Menu saved.'); } + private function wantsJson(): bool + { + return $this->request->isAJAX() + || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json'); + } + + private function csrfPayload(): array + { + return [ + 'name' => csrf_token(), + 'hash' => csrf_hash(), + ]; + } + public function delete($id) { @@ -158,12 +172,182 @@ public function save() { $this->ensureAdmin(); + $structure = $this->request->getPost('structure') ?? []; + if (is_array($structure) && !empty($structure)) { + $updates = $this->normalizeStructureUpdates($structure); + foreach ($updates as $row) { + $this->items->update($row['id'], [ + 'menu_parent_id' => $row['menu_parent_id'], + 'sort_order' => $row['sort_order'], + ]); + } + + $this->service->clearCache(); + return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]); + } + + // Backward-compatible payload used by the previous builder. $orders = $this->request->getPost('orders') ?? []; foreach ($orders as $id => $order) { $this->items->update((int) $id, ['sort_order' => (int) $order]); } + $this->service->clearCache(); - return $this->response->setJSON(['ok' => true]); + return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]); + } + + public function roleAccess() + { + $this->ensureAdmin(); + + $action = strtolower(trim((string) $this->request->getPost('action'))); + $navItemId = (int) $this->request->getPost('nav_item_id'); + $sourceRoleId = (int) $this->request->getPost('source_role_id'); + $targetRoleId = (int) $this->request->getPost('target_role_id'); + + if ($navItemId <= 0 || !$this->items->select('id')->find($navItemId)) { + return $this->response + ->setStatusCode(422) + ->setJSON(['ok' => false, 'message' => 'Selected page does not exist.', 'csrf' => $this->csrfPayload()]); + } + + if ($action === 'remove') { + if (!$this->roleExists($sourceRoleId)) { + return $this->response + ->setStatusCode(422) + ->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]); + } + + $this->maps + ->where('nav_item_id', $navItemId) + ->where('role_id', $sourceRoleId) + ->delete(); + } elseif ($action === 'move') { + if (!$this->roleExists($sourceRoleId) || !$this->roleExists($targetRoleId)) { + return $this->response + ->setStatusCode(422) + ->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]); + } + + if ($sourceRoleId !== $targetRoleId) { + $this->maps + ->where('nav_item_id', $navItemId) + ->where('role_id', $sourceRoleId) + ->delete(); + + $exists = $this->maps + ->where('nav_item_id', $navItemId) + ->where('role_id', $targetRoleId) + ->first(); + + if (!$exists) { + $this->maps->insert([ + 'role_id' => $targetRoleId, + 'nav_item_id' => $navItemId, + ]); + } + } + } else { + return $this->response + ->setStatusCode(422) + ->setJSON(['ok' => false, 'message' => 'Unsupported role access action.', 'csrf' => $this->csrfPayload()]); + } + + $this->service->clearCache(); + + return $this->response->setJSON([ + 'ok' => true, + 'message' => 'Role access updated.', + 'csrf' => $this->csrfPayload(), + 'payload' => $this->buildNavPayload(), + ]); + } + + private function roleExists(int $roleId): bool + { + if ($roleId <= 0) { + return false; + } + + $db = \Config\Database::connect(); + return (bool) $db->table('roles') + ->select('id') + ->where('id', $roleId) + ->get(1) + ->getFirstRow(); + } + + private function normalizeStructureUpdates(array $structure): array + { + $existingRows = $this->items->select('id, menu_parent_id')->findAll(); + $existingIds = array_map('intval', array_column($existingRows, 'id')); + $existingIdLookup = array_fill_keys($existingIds, true); + + $updates = []; + foreach ($structure as $row) { + if (!is_array($row)) { + continue; + } + + $id = (int) ($row['id'] ?? 0); + if ($id <= 0 || !isset($existingIdLookup[$id])) { + continue; + } + + $parentId = $row['parent_id'] ?? null; + $parentId = ($parentId === '' || $parentId === null) ? null : (int) $parentId; + if ($parentId !== null && (!isset($existingIdLookup[$parentId]) || $parentId === $id)) { + $parentId = null; + } + + $updates[] = [ + 'id' => $id, + 'menu_parent_id' => $parentId, + 'sort_order' => max(0, (int) ($row['sort_order'] ?? 0)), + ]; + } + + $updatesById = []; + foreach ($updates as $row) { + $updatesById[$row['id']] = $row; + } + + foreach ($updatesById as $id => &$row) { + if ($row['menu_parent_id'] !== null && $this->wouldCreateCycle($id, $row['menu_parent_id'], $updatesById, $existingRows)) { + $row['menu_parent_id'] = null; + } + } + unset($row); + + return array_values($updatesById); + } + + private function wouldCreateCycle(int $id, int $parentId, array $updatesById, array $existingRows): bool + { + $parentById = []; + foreach ($existingRows as $row) { + $parentById[(int) ($row['id'] ?? 0)] = isset($row['menu_parent_id']) && (int) $row['menu_parent_id'] !== 0 + ? (int) $row['menu_parent_id'] + : null; + } + foreach ($updatesById as $row) { + $parentById[$row['id']] = $row['menu_parent_id']; + } + + $seen = []; + $current = $parentId; + while ($current !== null) { + if ($current === $id) { + return true; + } + if (isset($seen[$current])) { + return true; + } + $seen[$current] = true; + $current = $parentById[$current] ?? null; + } + + return false; } protected function distinctRoles(): array @@ -207,7 +391,7 @@ public function save() } unset($node); - $this->sortTreeAlpha($tree); + $this->sortTreeByOrder($tree); $flatAlpha = $all; usort($flatAlpha, fn($a, $b) => strnatcasecmp( @@ -256,9 +440,59 @@ public function save() 'items' => $flattened, 'roles' => $roles, 'parentOptions' => $parentOptions, + 'routeOptions' => $this->routeOptions(), ]; } + private function routeOptions(): array + { + $routes = service('routes'); + $getRoutes = $routes->getRoutes('GET', false); + if (empty($getRoutes)) { + $routes = $routes->loadRoutes(); + $getRoutes = $routes->getRoutes('GET', false); + } + $options = []; + + foreach (array_keys($getRoutes) as $route) { + $route = trim((string) $route, '/'); + if ($route === '' || $this->shouldHideRouteOption($route)) { + continue; + } + + $options[] = [ + 'value' => $route, + 'label' => $this->routeLabel($route), + 'needs_params' => str_contains($route, '(') || str_contains($route, '[') || str_contains($route, '{'), + ]; + } + + usort($options, static function ($a, $b) { + return strnatcasecmp($a['label'] ?? '', $b['label'] ?? ''); + }); + + return $options; + } + + private function shouldHideRouteOption(string $route): bool + { + if (str_starts_with($route, 'api/') || str_starts_with($route, 'docs/') || str_starts_with($route, 'debugbar/')) { + return true; + } + + return (bool) preg_match('#(^|/)(csrf-token|file|attachment|download|delete)(/|$)#i', $route); + } + + private function routeLabel(string $route): string + { + $label = preg_replace('#\(\?:[^)]+\)|\(\[\^/\]\+\)|\(\[0-9\]\+\)|\(:[a-z_]+\)|\{[^}]+\}#i', '{value}', $route) ?? $route; + $label = str_replace(['_', '-'], ' ', $label); + $label = preg_replace('#/+#', ' / ', $label) ?? $label; + $label = preg_replace('/\s+/', ' ', $label) ?? $label; + + return ucwords(trim($label)); + } + private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array { foreach ($nodes as $node) { @@ -300,4 +534,28 @@ public function save() return $rows; } + + private function sortTreeByOrder(array &$nodes): void + { + usort($nodes, function ($a, $b) { + $order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0)); + if ($order !== 0) { + return $order; + } + + $label = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')); + if ($label !== 0) { + return $label; + } + + return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0)); + }); + + foreach ($nodes as &$node) { + if (!empty($node['children'])) { + $this->sortTreeByOrder($node['children']); + } + } + unset($node); + } } diff --git a/app/Services/NavbarService.php b/app/Services/NavbarService.php index 0030c01..ede7593 100644 --- a/app/Services/NavbarService.php +++ b/app/Services/NavbarService.php @@ -29,9 +29,7 @@ class NavbarService $allowedIds = $this->mapModel->getNavItemIdsForRoles($roles); if (empty($allowedIds)) return []; - // Load all enabled items, then filter. - // The displayed sidebar is alphabetized after the tree is built so - // new menu items fall into place without relying on manual sort_order. + // Load all enabled items, then filter. The builder controls this order. $rows = $this->navModel->where('is_enabled', 1) ->orderBy('sort_order', 'ASC') ->orderBy('label', 'ASC') @@ -59,22 +57,34 @@ class NavbarService } unset($node); - $this->sortTreeAlphabetically($tree); + $this->sortTreeByOrder($tree); $this->cache->save($cacheKey, $tree, 300); // 5 minutes return $tree; } - private function sortTreeAlphabetically(array &$nodes): void + private function sortTreeByOrder(array &$nodes): void { - usort($nodes, fn ($a, $b) => strnatcasecmp( - $this->labelSortKey((string) ($a['label'] ?? '')), - $this->labelSortKey((string) ($b['label'] ?? '')) - )); + usort($nodes, function ($a, $b) { + $order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0)); + if ($order !== 0) { + return $order; + } + + $label = strnatcasecmp( + $this->labelSortKey((string) ($a['label'] ?? '')), + $this->labelSortKey((string) ($b['label'] ?? '')) + ); + if ($label !== 0) { + return $label; + } + + return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0)); + }); foreach ($nodes as &$node) { if (!empty($node['children']) && is_array($node['children'])) { - $this->sortTreeAlphabetically($node['children']); + $this->sortTreeByOrder($node['children']); } } unset($node); diff --git a/app/Views/nav_builder/index.php b/app/Views/nav_builder/index.php index 6c4715c..dce83a5 100644 --- a/app/Views/nav_builder/index.php +++ b/app/Views/nav_builder/index.php @@ -1,66 +1,278 @@ = $this->extend('layout/management_layout') ?> = $this->section('content') ?> - - - - - -