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 <noreply@anthropic.com>
New home card 'unifi.guest_wifi': shows the guest SSID, the current
rotated password (unifi.password_rotation.last_password), and a WPA
join QR built from the WIFI: string. Polls app/unifi/guest-card/data
(auth) every 60s so it updates the moment the rotation engine changes
the password — the QR regenerates client-side to match. Self-hides
until enabled with an SSID and a rotated password; grant-gated via
Admin -> Settings -> Dashboard Cards. New settings (WiFi rotation
section): enable + network name + card title.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
unifi_page_grants gains role + default grantee types and can_view
(deny-by-default "Everyone else" row); enforcement moves from the
RouteMatched listener — where request->user() is always null and the
check silently failed open — into route-appended middleware with the
permission-holder pass. Pages-access endpoints gain role search +
default-row handling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* UnifiPageGrant::userCanAccess no longer falls back to "open" when a
page has no grants saved. Pages now require an explicit grant for
every non-super-admin user — either a direct user grant or via a
group they belong to. Matches the new dashboard-wide access model.
* Route enforcement returns 404 (was 403) so ungranted users can't even
confirm the page exists.
* New /settings/pages-access/groups/search endpoint mirrors the
user typeahead. Groups are no longer all listed by default — only
super-admin groups (locked-on) and groups with at least one existing
grant show up in the matrix. Operators add more via search.
v1.7.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* RotatePasswords now stores the active wordlist entry as
unifi.password_rotation.last_password whenever a whole-SSID rotation
succeeds. Per-PPSK rotation continues to store passwords on each
PPSK row as before.
* Settings → Tasks tab surfaces the current password in bold beneath
the wordlist textarea so operators can quickly check what's live.
* New JSON endpoint GET /api/unifi/wifi/current-password returns
{"password": "...", "rotated_at": "..."}. Protected by a token stored
in unifi.api_token — pass as Authorization: Bearer <token> or
?token=<token>. 401 on bad/missing token, 503 if no token is
configured, 404 if no rotation has happened yet.
* Settings page lets super-admins Generate / Regenerate / Clear the
token. Generated tokens are 48-char hex from bin2hex(random_bytes(24)).
* The endpoint lives outside the web/auth middleware so external
signage / kiosks can hit it without a session cookie.
v1.6.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* The model+validation referenced tracked_clients and templates columns
but they were never in the unifi_webhook_configs migration. Any save
attempt that included those keys 500'd with "Unknown column".
Added an additive migration (idempotent) that adds both as nullable
json columns.
* New POST /settings/webhooks/test-url endpoint takes a url+secret in
the body and fires the standard test payload. Lets operators validate
their endpoint before saving the row — useful when first wiring up
Google Chat, Slack, etc.
v1.5.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Password rotation was short-circuiting any run that had no whole-SSID
wlan_ids configured, even if there were PPSKs with rotate_password=true
in the database. The PPSK rotation block lived after the early-return,
so per-PPSK rotation never fired. Now we only skip when there's nothing
at all to rotate (neither wlan_ids nor PPSK opt-ins).
* Webhook routes moved from /app/network/webhooks to
/app/network/settings/webhooks so the URL reflects that this is a
settings tab. Route names unchanged.
v1.5.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing every user in the system on the access page didn't scale —
schools have thousands of user rows. Now:
- index() only returns users that already have a UnifiPageGrant
somewhere. Groups stay fully listed (few of them).
- new searchUsers(q) endpoint returns up to 20 typeahead matches
against name or email (min 2 chars).
v1.5.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds unifi_cron_runs table (one row per scheduled-task execution) and
UnifiCronRun::record() wrapper that captures start/finish/status and
exceptions. The three scheduled commands now write through it:
- reboot-all-aps → rebooted/failed AP names per run
- rotate-passwords → rotated SSIDs + PPSKs, failures (when actually
rotating; the "is it due" early-return is silent
so we don't flood the log with no-op rows every
minute)
- sync-ppsk-schedules → enabled/disabled PPSKs (silent when there's
no work)
UnifiCronLogsController returns the most-recent 200 runs as JSON,
filterable by command + status. Behind permission:unifi.settings; no
super-admin required — read-only history is fine for any operator
who can see settings.
v1.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A snap-in-owned access mechanism. Adds:
- unifi_page_grants table (nav_item_id, grantee_type, grantee_id)
with cascadeOnDelete from nav_items so uninstalling the snap-in
wipes its grant rows automatically
- UnifiPageGrant model + ::userCanAccess(user, navItem) helper
- UnifiPagesAccessController (index + update), super-admin only
- RouteMatched listener in UnifiServiceProvider that 403s any
unifi.* route if the matched nav_item has grants and the user
isn't a super-admin / granted user / member of a granted group
Semantics: a page with NO grants stays open per the existing
permission middleware (no behaviour change). The moment grants are
added, ONLY super-admins and listed users/groups can see/open the
page. Super-admins always pass; their access can't be removed.
v1.4.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UniFi's /rest/apgroup endpoints (and per-SSID ap_group_ids writes via
/rest/wlanconf) require session-cookie auth — they don't accept the
X-API-Key header. The Integration API doesn't expose AP groups at all.
So with the current deployment running on API-key auth, every AP-group
operation returned 400 api.err.InvalidObject. Removing the dead code
rather than carrying a feature that can't function.
* Deleted ApGroupController, ApGroups.vue, the /ap-groups/* routes,
and getApGroups/createApGroup/updateApGroup/deleteApGroup from
UnifiApiClient.
* Removed the per-SSID AP-group assignment from Wifi.vue + the
updateApGroups action + /wifi/{wlanId}/ap-groups route + the
ap_group_ids field from the mapWlan output.
* Removed the AP Groups nav entry from composer.json.
If a future deploy adds local-admin username+password auth, AP groups
can be reintroduced — the UnifiApiClient::buildRequest() session-cookie
path is intact.
v1.3.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add password rotation: RotatePasswords console command + migration + service updates
- Add PPSK management: UnifiPpsk model, migration, SyncPpskSchedules console
- Add VLAN groups and AP groups: VlanGroupController, ApGroupController, model, migration
- Add RebootAllAps console command
- Add in_alert column to device states
- Wire new features through service provider, routes, and existing controllers/services
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>