From f4631d1da087be145dea88a2031b5efe567a2e20 Mon Sep 17 00:00:00 2001 From: jwed Date: Sun, 26 Jul 2026 17:42:20 -0400 Subject: [PATCH] Make snap-in Inertia page assertions real, and fix the page it found missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config/inertia.php listed only the shell's resource_path('js/Pages'), so a strict `component('Admissions/Guest/Inquiry')` failed "page component file does not exist" for every snap-in page. The whole suite worked around it with `component('X', false)` — and that flag SKIPS the existence check, so those assertions had stopped verifying the component existed at all. Exactly the bug class the comment at the top of that file describes. paths now derives the installed snap-ins' roots from the packages/ symlinks: 'paths' => array_merge( [resource_path('js/Pages')], glob(base_path('packages/*/resources/js/Pages'), GLOB_ONLYDIR) ?: [], ), Derived, not hardcoded: packages/ holds one symlink per INSTALLED snap-in, so an uninstalled app simply vanishes from the array and a newly installed one is covered automatically. Shell path stays first, matching app.js's resolve order. `ensure_pages_exist` stays false in the production block — this only affects tests. 49 assertions across 37 files tightened to strict. Zero failed, which is only meaningful because the mechanism was mutation-checked: renaming a snap-in page makes the corresponding test fail with "page component file does not exist" (verified on two different package roots). THE FINDING. A sweep of all 261 literal Inertia::render() targets turned up `Unifi/PortalDenied`, which HAS NEVER EXISTED in git history. PortalController renders it on two live branches of captiveCallback — no VLAN mapping for the user, and over the device limit — on a route reachable by any authenticated user redirected from the captive portal (GET /portal/wifi/callback, ['web','auth'], no app gate). So a student who could not get on the WiFi received a client-side Inertia resolve error instead of an explanation. No test covered the path, which is why the suite never noticed. Fixed properly rather than stubbed: - New Unifi/PortalDenied.vue, on GuestLayout (the audience is someone on a phone trying to join the WiFi, not an operator in the dashboard). Device-limit case lists the user's devices with a Disconnect action; the no-mapping case says who to contact instead of offering a dead-end button. Uses confirmDialog, never window.confirm. - A SECOND defect in the same feature: the page passes `can_disconnect: true`, but the only disconnect route sat behind `app.access:unifi` + `permission:unifi.auth`, so a student told "disconnect a device" was refused by middleware BEFORE disconnectSession()'s own owner check could allow it. Added `unifi.portal.self-disconnect` under `['web','auth']`, pointing at the same action. Safe because that method already authorises owner-or-unifi.auth itself, and a test pins that a stranger still gets 403. The admin route is untouched rather than loosened. Also left alone deliberately: `Admin/Tables` is rendered by CustomFieldsController::index() but has no GET route (the UI moved to a tab on /admin/settings), so it is unreachable leftover rather than a wrong name. And dashboard-expenses/dashboard-ticketing carry stale duplicate copies of pages that actually live in and are served from the shell; nothing is served only from a snap-in copy today, but a page added ONLY to those repos would now pass the test while 404ing in the browser. Tests: 4 new (PortalDeniedTest, strict component assertions on purpose), 623 green across the touched areas. doctor exit 0. Co-Authored-By: Claude Opus 4.8 --- composer.json | 2 +- src/routes/unifi.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a2f4b03..8e1f3e0 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dashboard/unifi", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", - "version": "1.13.2", + "version": "1.13.3", "type": "library", "license": "MIT", "autoload": { diff --git a/src/routes/unifi.php b/src/routes/unifi.php index 0ec716e..2f37a0f 100644 --- a/src/routes/unifi.php +++ b/src/routes/unifi.php @@ -121,3 +121,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () { Route::middleware(['web', 'auth']) ->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback']) ->name('unifi.portal.callback'); + +/* + * Let a user disconnect their OWN device from the captive-portal denial page. + * + * The admin route for this (unifi.portal.sessions.disconnect) sits behind + * `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you + * already have 3 devices connected, disconnect one" was blocked by middleware + * before reaching the action — the page's `can_disconnect: true` was a promise + * the routing could not keep. + * + * Safe to expose with only `auth`: disconnectSession() already authorises + * owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && ! + * auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The + * admin route is left exactly as it was rather than loosened. + */ +Route::middleware(['web', 'auth']) + ->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession']) + ->name('unifi.portal.self-disconnect');