8 Commits

Author SHA1 Message Date
0490a1220b feat(access): only return granted users; add search endpoint
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>
2026-05-24 16:51:00 -04:00
4b73b53dd6 chore(nav): drop Webhooks nav row — moved into Settings tabs
Webhooks now lives as a tab alongside Connection / Tasks / Logs /
Access in the Settings page. The standalone Webhooks page still
exists at /app/network/webhooks but no longer appears in the sidebar.

v1.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:43:30 -04:00
75943fbe2b feat(logs): structured cron run history + read endpoint
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>
2026-05-24 16:05:36 -04:00
a33f2885ff feat(access): per-page user/group grants, snap-in-local
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>
2026-05-23 16:47:57 -04:00
a4397c5178 chore: remove AP Groups surfaces (legacy API auth incompatible)
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>
2026-05-23 16:35:32 -04:00
fc4f5370ae fix(ppsk): null schedule = always on; disabled global toggle restores all
Previously SyncPpskSchedules returned early when the global setting
was disabled, leaving any PPSK that had been held by a prior sync
stuck in 'held' state. It also only iterated whereNotNull(schedule),
so null-schedule PPSKs ("always on") were never drift-corrected back
to active either.

Now the command always runs and computes a per-PPSK target state:
  - global ppsk_scheduling disabled  → target = active (always)
  - global enabled  + null schedule  → target = active (always)
  - global enabled  + has schedule   → follow the schedule's slot

PPSKs that drift from the target get enabled/disabled accordingly.
Schedules in unifi_ppsks.schedule are preserved across global toggles
either way — disabling the setting doesn't touch them, so re-enabling
resumes the operator's per-PPSK schedules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:09:40 -04:00
e59f193ffc feat(nav): surface AP Groups page; always-fresh device data on edit pages
The AP Groups page (ApGroups.vue + ApGroupController + UnifiApiClient
CRUD methods) has been built but never declared in composer.json's
pages list, so it was hidden from the menu. Added it at sort_order=6,
between WiFi Networks and Portal.

The WiFi Networks page already has per-SSID AP-group assignment via
the existing updateApGroups route — that wires into UniFi's standard
ap_group_ids field on wlanconf. (UniFi doesn't expose per-AP-only
assignment separately; the convention is "make a one-AP group for
this AP and assign the SSID to it.")

For the "always pull from UniFi on load" guarantee:
- getWlans() and getApGroups() are already uncached — fresh on every
  page load
- getDevices() (feeds the AP picker for group membership) is cached
  for unifi.cache_ttl seconds; both ApGroupController::index and
  WifiController::index now Cache::forget('unifi:devices') before
  reading so the device list is always fresh

v1.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:06:49 -04:00
f7672771e0 refactor: read timezone from shell-level site_timezone
Drops unifi.timezone from the settings form (now lives in
Admin → Settings on the shell). Schedulers (PPSK sync, password
rotation) now read \App\Support\Timezone::current() — same fallback
chain as the rest of the platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:26:50 -04:00
16 changed files with 575 additions and 268 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "dashboard/unifi", "name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.0.0", "version": "1.5.2",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {
@@ -28,7 +28,6 @@
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 }, { "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 }, { "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 }, { "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 } { "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
], ],
"permissions": [ "permissions": [

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Per-page access grants for unifi pages. A user can access a unifi
* page if ANY of these hold:
* - is_super_admin (always)
* - user has the page's required_permission (existing nav_items column)
* - user is in the page's required_group_id (existing column)
* - a row here grants them as a user, or via a group they're in
*
* Snap-in-local table — disappears with the snap-in if uninstalled.
*/
public function up(): void
{
Schema::create('unifi_page_grants', function (Blueprint $table) {
$table->id();
$table->foreignId('nav_item_id')->constrained('nav_items')->cascadeOnDelete();
$table->enum('grantee_type', ['user', 'group']);
$table->unsignedBigInteger('grantee_id');
$table->foreignId('granted_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['nav_item_id', 'grantee_type', 'grantee_id'], 'unifi_page_grants_unique');
$table->index(['grantee_type', 'grantee_id']);
});
}
public function down(): void
{
Schema::dropIfExists('unifi_page_grants');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Structured log of every unifi scheduled-task execution: AP reboots,
* password rotations, PPSK schedule syncs. One row per run.
* Surfaced in the Logs tab of the Unifi settings page.
*/
public function up(): void
{
Schema::create('unifi_cron_runs', function (Blueprint $table) {
$table->id();
$table->string('command', 64)->index(); // 'reboot-all-aps' | 'rotate-passwords' | 'sync-ppsk-schedules'
$table->enum('triggered_by', ['schedule', 'manual']);
$table->foreignId('triggered_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('started_at')->index();
$table->timestamp('finished_at')->nullable();
$table->string('status', 16); // 'running' | 'succeeded' | 'partial' | 'failed' | 'skipped'
$table->longText('details')->nullable(); // JSON: counts, per-item actions, error summary
});
}
public function down(): void
{
Schema::dropIfExists('unifi_cron_runs');
}
};

View File

@@ -2,41 +2,40 @@
namespace Dashboard\Unifi\Console; namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Services\UnifiApiClient; use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class RebootAllAps extends Command class RebootAllAps extends Command
{ {
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot}'; protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot} {--triggered-by=schedule}';
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts'; protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
public function handle(UnifiApiClient $unifi): int public function handle(UnifiApiClient $unifi): int
{ {
try { $run = UnifiCronRun::record(
'reboot-all-aps',
$this->option('triggered-by') ?: 'schedule',
null,
function () use ($unifi) {
$aps = $unifi->getAccessPoints(); $aps = $unifi->getAccessPoints();
} catch (\Throwable $e) {
$this->error('Failed to fetch APs: ' . $e->getMessage());
return self::FAILURE;
}
if (empty($aps)) { if (empty($aps)) {
$this->warn('No access points found.'); $this->warn('No access points found.');
return self::SUCCESS; return ['status' => 'skipped', 'reason' => 'no APs found'];
} }
$delay = max(0, (int) $this->option('delay')); $delay = max(0, (int) $this->option('delay'));
$rebooted = [];
$failed = [];
// Pre-mark all APs as planned reboots before sending any commands
foreach ($aps as $ap) { foreach ($aps as $ap) {
$mac = strtolower($ap['mac']); $mac = strtolower($ap['mac']);
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20)); Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
$this->line("Marked planned reboot: {$ap['name']} ({$mac})"); $this->line("Marked planned reboot: {$ap['name']} ({$mac})");
} }
$this->newLine(); $this->newLine();
$ok = 0;
$fail = 0;
foreach ($aps as $ap) { foreach ($aps as $ap) {
$mac = strtolower($ap['mac']); $mac = strtolower($ap['mac']);
@@ -44,19 +43,27 @@ class RebootAllAps extends Command
try { try {
$unifi->rebootDevice($mac); $unifi->rebootDevice($mac);
$this->info("Rebooted: {$name} ({$mac})"); $this->info("Rebooted: {$name} ({$mac})");
$ok++; $rebooted[] = $name;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error("Failed to reboot {$name}: {$e->getMessage()}"); $this->error("Failed to reboot {$name}: {$e->getMessage()}");
$fail++; $failed[] = ['name' => $name, 'error' => $e->getMessage()];
} }
if ($delay > 0 && $ok + $fail < count($aps)) { if ($delay > 0 && count($rebooted) + count($failed) < count($aps)) {
sleep($delay); sleep($delay);
} }
} }
$this->newLine(); return [
$this->info("Done. {$ok} rebooted, {$fail} failed."); 'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
return $fail > 0 ? self::FAILURE : self::SUCCESS; 'rebooted' => $rebooted,
'failed' => $failed,
'total' => count($aps),
];
}
);
$this->info("Done. Status: {$run->status}.");
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
} }
} }

View File

@@ -3,27 +3,38 @@
namespace Dashboard\Unifi\Console; namespace Dashboard\Unifi\Console;
use App\Models\Setting; use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Models\UnifiPpsk; use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient; use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class RotatePasswords extends Command class RotatePasswords extends Command
{ {
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule}'; protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule} {--triggered-by=schedule}';
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule'; protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
public function handle(UnifiApiClient $unifi): int public function handle(UnifiApiClient $unifi): int
{ {
if (! Setting::get('unifi.password_rotation.enabled')) { if (! Setting::get('unifi.password_rotation.enabled')) {
// Don't log anything — the scheduler runs this every minute
// and we'd flood the logs with "rotation disabled" rows.
return self::SUCCESS; return self::SUCCESS;
} }
if (! $this->option('force') && ! $this->isDue()) {
// Same reasoning — only log when we actually do something.
return self::SUCCESS;
}
$force = $this->option('force');
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]'); $wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true); $wlanIds = json_decode($wlanIdsJson, true);
if (empty($wlanIds) || ! is_array($wlanIds)) { if (empty($wlanIds) || ! is_array($wlanIds)) {
return self::SUCCESS; return ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
} }
$wordlist = Setting::get('unifi.password_rotation.wordlist', ''); $wordlist = Setting::get('unifi.password_rotation.wordlist', '');
@@ -31,48 +42,55 @@ class RotatePasswords extends Command
if (empty($passwords)) { if (empty($passwords)) {
$this->warn('Password rotation: no passwords in wordlist — skipped.'); $this->warn('Password rotation: no passwords in wordlist — skipped.');
return self::SUCCESS; return ['status' => 'skipped', 'reason' => 'empty wordlist'];
}
if (! $this->option('force') && ! $this->isDue()) {
return self::SUCCESS;
} }
$password = $passwords[array_rand($passwords)]; $password = $passwords[array_rand($passwords)];
$rotated = 0; $rotated = [];
$failedWlans = [];
foreach ($wlanIds as $wlanId) { foreach ($wlanIds as $wlanId) {
try { try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]); $unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated++; $rotated[] = $wlanId;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}"); $this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
} }
} }
if ($rotated > 0) { if ($rotated) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String()); Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
$this->info("Rotated password for {$rotated} SSID(s)."); $this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
} }
// ── Rotate PPSK passwords ──────────────────────────────────────────── $rotatedPpsks = [];
$rotatedPpsks = 0; $failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) { foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
// Each PPSK gets its own independently-chosen password from the wordlist
$newPass = $passwords[array_rand($passwords)]; $newPass = $passwords[array_rand($passwords)];
try { try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]);
$rotatedPpsks++; $rotatedPpsks[] = $ppsk->name;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}"); $this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
} }
} }
if ($rotatedPpsks > 0) {
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
}
return self::SUCCESS; $hasFailures = count($failedWlans) + count($failedPpsks) > 0;
$hasSuccess = count($rotated) + count($rotatedPpsks) > 0;
return [
'status' => $hasFailures ? ($hasSuccess ? 'partial' : 'failed') : 'succeeded',
'rotated_wlans' => $rotated,
'failed_wlans' => $failedWlans,
'rotated_ppsks' => $rotatedPpsks,
'failed_ppsks' => $failedPpsks,
];
});
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
} }
private function isDue(): bool private function isDue(): bool
@@ -81,7 +99,7 @@ class RotatePasswords extends Command
$hour = (int) Setting::get('unifi.password_rotation.hour', 2); $hour = (int) Setting::get('unifi.password_rotation.hour', 2);
$minute = (int) Setting::get('unifi.password_rotation.minute', 0); $minute = (int) Setting::get('unifi.password_rotation.minute', 0);
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0); $dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
$tz = Setting::get('unifi.timezone', 'UTC'); $tz = \App\Support\Timezone::current();
$now = now($tz); $now = now($tz);
if ($now->hour !== $hour || $now->minute !== $minute) { if ($now->hour !== $hour || $now->minute !== $minute) {

View File

@@ -3,33 +3,33 @@
namespace Dashboard\Unifi\Console; namespace Dashboard\Unifi\Console;
use App\Models\Setting; use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Models\UnifiPpsk; use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient; use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class SyncPpskSchedules extends Command class SyncPpskSchedules extends Command
{ {
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled}'; protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled} {--triggered-by=schedule}';
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling'; protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
public function handle(UnifiApiClient $unifi): int public function handle(UnifiApiClient $unifi): int
{ {
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) { $ppsks = UnifiPpsk::all();
return self::SUCCESS;
}
$tz = Setting::get('unifi.timezone', 'UTC');
$now = now($tz);
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 047
$ppsks = UnifiPpsk::whereNotNull('schedule')->get();
if ($ppsks->isEmpty()) { if ($ppsks->isEmpty()) {
// Don't bother logging — no work, no audit value.
return self::SUCCESS; return self::SUCCESS;
} }
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable $triggeredBy = $this->option('triggered-by') ?: 'schedule';
$run = UnifiCronRun::record('sync-ppsk-schedules', $triggeredBy, null, function () use ($unifi, $ppsks) {
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = \App\Support\Timezone::current();
$now = now($tz);
$day = $now->dayOfWeek;
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0);
$networksByVlan = []; $networksByVlan = [];
try { try {
foreach ($unifi->getNetworkConfs() as $n) { foreach ($unifi->getNetworkConfs() as $n) {
@@ -41,17 +41,44 @@ class SyncPpskSchedules extends Command
$this->warn("Could not fetch network configs: {$e->getMessage()}"); $this->warn("Could not fetch network configs: {$e->getMessage()}");
} }
foreach ($ppsks as $ppsk) { $enabled = [];
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true); $disabled = [];
$errors = [];
foreach ($ppsks as $ppsk) {
$shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
}
try {
if ($shouldBeOn && $ppsk->state === 'held') { if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan); $this->enablePpsk($ppsk, $unifi, $networksByVlan);
$enabled[] = $ppsk->name;
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) { } elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
$this->disablePpsk($ppsk, $unifi); $this->disablePpsk($ppsk, $unifi);
$disabled[] = $ppsk->name;
}
} catch (\Throwable $e) {
$errors[] = ['ppsk' => $ppsk->name, 'error' => $e->getMessage()];
} }
} }
return self::SUCCESS; $hasActions = count($enabled) + count($disabled) > 0;
$status = count($errors) > 0
? ($hasActions ? 'partial' : 'failed')
: ($hasActions ? 'succeeded' : 'skipped');
return [
'status' => $status,
'global_enabled' => $globalEnabled,
'enabled_ppsks' => $enabled,
'disabled_ppsks' => $disabled,
'errors' => $errors,
];
});
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
} }
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void

View File

@@ -1,88 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Inertia\Inertia;
class ApGroupController extends Controller
{
public function index(UnifiApiClient $unifi)
{
try {
$groups = collect($unifi->getApGroups())->map(fn ($g) => [
'id' => $g['_id'],
'name' => $g['name'] ?? 'Unnamed',
'device_macs' => $g['device_macs'] ?? [],
'is_default' => $g['attr_no_delete'] ?? false,
])->values();
$devices = collect($unifi->getAccessPoints())->map(fn ($d) => [
'mac' => strtolower($d['mac']),
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
'model' => $d['model'] ?? '',
'state' => $d['state'] ?? 0,
])->values();
return Inertia::render('Unifi/ApGroups', [
'groups' => $groups,
'devices' => $devices,
]);
} catch (\Throwable $e) {
return Inertia::render('Unifi/ApGroups', [
'groups' => [], 'devices' => [], 'error' => $e->getMessage(),
]);
}
}
public function store(Request $request, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'device_macs' => 'present|array',
'device_macs.*' => 'string',
]);
try {
$result = $unifi->createApGroup([
'name' => $data['name'],
'device_macs' => array_values(array_map('strtolower', $data['device_macs'])),
]);
return back()->with('success', 'AP group created.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function update(Request $request, string $groupId, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'sometimes|string|max:100',
'device_macs' => 'sometimes|array',
'device_macs.*' => 'string',
]);
if (isset($data['device_macs'])) {
$data['device_macs'] = array_values(array_map('strtolower', $data['device_macs']));
}
try {
$unifi->updateApGroup($groupId, $data);
return back()->with('success', 'AP group updated.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function destroy(string $groupId, UnifiApiClient $unifi)
{
try {
$unifi->deleteApGroup($groupId);
return back()->with('success', 'AP group deleted.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\UnifiCronRun;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UnifiCronLogsController extends Controller
{
public function index(Request $request)
{
$filters = $request->only(['command', 'status']);
$runs = UnifiCronRun::query()
->with('triggeredByUser:id,name,email')
->when($filters['command'] ?? null, fn ($q, $c) => $q->where('command', $c))
->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
->orderByDesc('started_at')
->limit(200)
->get();
return response()->json([
'runs' => $runs->map(fn ($r) => [
'id' => $r->id,
'command' => $r->command,
'triggered_by' => $r->triggered_by,
'triggered_user' => $r->triggeredByUser ? [
'id' => $r->triggeredByUser->id,
'name' => $r->triggeredByUser->name,
'email' => $r->triggeredByUser->email,
] : null,
'started_at' => $r->started_at?->toIso8601String(),
'finished_at' => $r->finished_at?->toIso8601String(),
'duration_ms' => $r->finished_at && $r->started_at
? (int) $r->finished_at->diffInMilliseconds($r->started_at)
: null,
'status' => $r->status,
'details' => $r->details,
])->values(),
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\DashboardApp;
use App\Models\Group;
use App\Models\NavItem;
use App\Models\User;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
/**
* Super-admin-only endpoints for managing per-page access on unifi
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
*/
class UnifiPagesAccessController extends Controller
{
public function index()
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app) {
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
}
$pages = NavItem::where('app_id', $app->id)
->where('is_folder', false)
->whereNotNull('route_name')
->orderBy('sort_order')
->get(['id', 'label', 'route_name']);
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
->get()
->groupBy('nav_item_id');
// Only return users that ALREADY have grants. The full users list
// can be enormous (thousands of rows); the operator adds more via
// the searchUsers endpoint as needed.
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
return response()->json([
'pages' => $pages->map(fn ($p) => [
'id' => $p->id,
'label' => $p->label,
'route_name' => $p->route_name,
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
])->values(),
'users' => $users,
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
]);
}
/**
* Typeahead-style search for users to add to the access matrix.
* Returns up to 20 matches against name or email. Empty query returns
* an empty array — caller must enter at least 2 chars.
*/
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['users' => []]);
}
$users = User::where(function ($w) use ($q) {
$w->where('name', 'like', '%' . $q . '%')
->orWhere('email', 'like', '%' . $q . '%');
})
->orderBy('name')
->limit(20)
->get(['id', 'name', 'email']);
return response()->json(['users' => $users]);
}
public function update(Request $request, NavItem $navItem)
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app || $navItem->app_id !== $app->id) {
return response()->json(['error' => 'Not a unifi page.'], 422);
}
$data = $request->validate([
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
]);
$grantedBy = $request->user()?->id;
DB::transaction(function () use ($navItem, $data, $grantedBy) {
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
$rows = [];
$now = now();
foreach ($data['user_ids'] as $uid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
foreach ($data['group_ids'] as $gid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
if ($rows) UnifiPageGrant::insert($rows);
});
return response()->json(['ok' => true]);
}
}

View File

@@ -19,7 +19,6 @@ class UnifiSettingsController extends Controller
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30), 'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30), 'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
'retentionDays' => (int) Setting::get('unifi.retention_days', 30), 'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
'timezone' => Setting::get('unifi.timezone', 'UTC'),
'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false), 'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false),
'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'), 'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'),
'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0), 'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0),
@@ -45,7 +44,6 @@ class UnifiSettingsController extends Controller
'poll_interval' => 'nullable|integer|min:5|max:300', 'poll_interval' => 'nullable|integer|min:5|max:300',
'cache_ttl' => 'nullable|integer|min:5|max:300', 'cache_ttl' => 'nullable|integer|min:5|max:300',
'retention_days' => 'nullable|integer|min:1|max:365', 'retention_days' => 'nullable|integer|min:1|max:365',
'timezone' => 'nullable|string|timezone',
'auto_reboot_enabled' => 'boolean', 'auto_reboot_enabled' => 'boolean',
'auto_reboot_frequency' => 'in:daily,weekly', 'auto_reboot_frequency' => 'in:daily,weekly',
'auto_reboot_dow' => 'nullable|integer|min:0|max:6', 'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
@@ -70,7 +68,6 @@ class UnifiSettingsController extends Controller
if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30); if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30);
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30); if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30); if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
if ($request->has('timezone')) Setting::set('unifi.timezone', $request->timezone ?? 'UTC');
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : ''); Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily')); Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));

View File

@@ -16,17 +16,6 @@ class WifiController extends Controller
try { try {
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values(); $wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
try {
$apGroups = collect($unifi->getApGroups())->map(fn ($g) => [
'id' => $g['_id'],
'name' => $g['attr_no_delete'] ?? false ? 'Default' : ($g['name'] ?? 'Unnamed'),
'device_macs' => $g['device_macs'] ?? [],
'is_default' => $g['attr_no_delete'] ?? false,
])->values();
} catch (\Throwable $e) {
$apGroups = collect(); // AP groups not supported by this controller
}
$raw = Setting::get('unifi.ssid_groups', '{}'); $raw = Setting::get('unifi.ssid_groups', '{}');
$groups = json_decode($raw, true); $groups = json_decode($raw, true);
if (! is_array($groups) || array_is_list($groups)) $groups = []; if (! is_array($groups) || array_is_list($groups)) $groups = [];
@@ -37,13 +26,12 @@ class WifiController extends Controller
return Inertia::render('Unifi/Wifi', [ return Inertia::render('Unifi/Wifi', [
'wlans' => $wlans, 'wlans' => $wlans,
'groups' => $groups, 'groups' => $groups,
'apGroups' => $apGroups,
'rotateWlanIds' => $rotateWlanIds, 'rotateWlanIds' => $rotateWlanIds,
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false), 'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return Inertia::render('Unifi/Wifi', [ return Inertia::render('Unifi/Wifi', [
'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(), 'wlans' => [], 'groups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
]); ]);
} }
} }
@@ -98,21 +86,6 @@ class WifiController extends Controller
} }
} }
/**
* Update AP group assignments for a single WLAN (not synced to group siblings).
*/
public function updateApGroups(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$request->validate(['ap_group_ids' => 'required|array']);
try {
$unifi->updateWlan($wlanId, ['ap_group_ids' => $request->ap_group_ids]);
return back()->with('success', 'AP groups updated.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi) public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
{ {
$request->validate(['enabled' => 'required|boolean']); $request->validate(['enabled' => 'required|boolean']);
@@ -414,7 +387,6 @@ class WifiController extends Controller
'hide_ssid' => $w['hide_ssid'] ?? false, 'hide_ssid' => $w['hide_ssid'] ?? false,
'passphrase' => $w['x_passphrase'] ?? '', 'passphrase' => $w['x_passphrase'] ?? '',
'band' => $this->detectBand($w), 'band' => $this->detectBand($w),
'ap_group_ids' => $w['ap_group_ids'] ?? [],
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false, 'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny', 'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false) 'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)

View File

@@ -0,0 +1,79 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class UnifiCronRun extends Model
{
protected $table = 'unifi_cron_runs';
public $timestamps = false;
protected $fillable = [
'command',
'triggered_by',
'triggered_by_user_id',
'started_at',
'finished_at',
'status',
'details',
];
protected $casts = [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'details' => 'array',
];
public function triggeredByUser()
{
return $this->belongsTo(\App\Models\User::class, 'triggered_by_user_id');
}
/**
* Wraps a unit of cron work, recording start/finish/status and any
* exception. Returns whatever the work returns; the resulting
* UnifiCronRun row is returned via the $run reference param.
*/
public static function record(string $command, string $triggeredBy, ?int $userId, callable $work): self
{
$run = static::create([
'command' => $command,
'triggered_by' => $triggeredBy,
'triggered_by_user_id' => $userId,
'started_at' => now(),
'status' => 'running',
]);
try {
$details = $work($run);
// Caller can return a status string ("skipped", "partial",
// etc.) by sticking it under the 'status' key in details.
// Default = succeeded.
$status = is_array($details) && isset($details['status'])
? $details['status']
: 'succeeded';
$run->update([
'finished_at' => now(),
'status' => $status,
'details' => is_array($details) ? array_diff_key($details, ['status' => null]) : null,
]);
} catch (\Throwable $e) {
$run->update([
'finished_at' => now(),
'status' => 'failed',
'details' => [
'error' => $e->getMessage(),
'class' => $e::class,
'file' => $e->getFile() . ':' . $e->getLine(),
],
]);
throw $e;
}
return $run->refresh();
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Dashboard\Unifi\Models;
use App\Models\NavItem;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UnifiPageGrant extends Model
{
protected $table = 'unifi_page_grants';
protected $fillable = [
'nav_item_id',
'grantee_type',
'grantee_id',
'granted_by_user_id',
];
public function navItem(): BelongsTo
{
return $this->belongsTo(NavItem::class);
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_user_id');
}
/**
* True iff $user is allowed to access $navItem under this grant model.
* Super-admins always pass.
* If there are NO grants for the page, falls back to "open" (anyone
* who can reach the route can access — same as before grants existed).
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$hasGrants = static::where('nav_item_id', $navItem->id)->exists();
if (! $hasGrants) return true;
$groupIds = $user->groups()->pluck('groups.id');
return static::where('nav_item_id', $navItem->id)
->where(function ($q) use ($user, $groupIds) {
$q->where(function ($u) use ($user) {
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
})->orWhere(function ($g) use ($groupIds) {
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
});
})
->exists();
}
}

View File

@@ -312,28 +312,6 @@ class UnifiApiClient
return $this->put("/rest/wlanconf/{$wlanId}", $data); return $this->put("/rest/wlanconf/{$wlanId}", $data);
} }
// ── AP Groups ─────────────────────────────────────────────────────────────
public function getApGroups(): array
{
return $this->get('/rest/apgroups');
}
public function createApGroup(array $data): array
{
return $this->post('/rest/apgroups', $data);
}
public function updateApGroup(string $groupId, array $data): array
{
return $this->put("/rest/apgroups/{$groupId}", $data);
}
public function deleteApGroup(string $groupId): void
{
$this->delete("/rest/apgroups/{$groupId}");
}
// ── PPSK ───────────────────────────────────────────────────────────────── // ── PPSK ─────────────────────────────────────────────────────────────────
/** /**

View File

@@ -2,6 +2,11 @@
namespace Dashboard\Unifi; namespace Dashboard\Unifi;
use App\Models\DashboardApp;
use App\Models\NavItem;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class UnifiServiceProvider extends ServiceProvider class UnifiServiceProvider extends ServiceProvider
@@ -20,6 +25,34 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php'); $this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Per-page access enforcement for unifi routes. If a unifi page has
// any UnifiPageGrant rows, only super-admins and granted users/
// groups can hit it; otherwise (no grants) it's open per the existing
// permission middleware. Super-admins always bypass.
Event::listen(RouteMatched::class, function (RouteMatched $event) {
$routeName = $event->route->getName();
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
$user = $event->request->user();
if (! $user || $user->is_super_admin) return;
try {
$appId = DashboardApp::where('slug', 'unifi')->value('id');
$item = NavItem::where('route_name', $routeName)
->where('app_id', $appId)
->first();
if (! $item) return;
if (! UnifiPageGrant::userCanAccess($user, $item)) {
abort(403, 'You do not have access to this page.');
}
} catch (\Throwable) {
// unifi_page_grants table may not exist yet on a fresh
// install before this snap-in's migrations have run —
// fail open in that narrow window.
}
});
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([ $this->commands([
Console\CheckWebhooks::class, Console\CheckWebhooks::class,

View File

@@ -1,10 +1,11 @@
<?php <?php
use Dashboard\Unifi\Http\Controllers\ApGroupController;
use Dashboard\Unifi\Http\Controllers\ClientController; use Dashboard\Unifi\Http\Controllers\ClientController;
use Dashboard\Unifi\Http\Controllers\DeviceController; use Dashboard\Unifi\Http\Controllers\DeviceController;
use Dashboard\Unifi\Http\Controllers\PortalController; use Dashboard\Unifi\Http\Controllers\PortalController;
use Dashboard\Unifi\Http\Controllers\StatsController; use Dashboard\Unifi\Http\Controllers\StatsController;
use Dashboard\Unifi\Http\Controllers\UnifiCronLogsController;
use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController; use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\VlanGroupController; use Dashboard\Unifi\Http\Controllers\VlanGroupController;
use Dashboard\Unifi\Http\Controllers\WebhookController; use Dashboard\Unifi\Http\Controllers\WebhookController;
@@ -32,7 +33,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// WiFi networks // WiFi networks
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi'); Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update'); Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
Route::put('/wifi/{wlanId}/ap-groups', [WifiController::class, 'updateApGroups']) ->name('wifi.ap-groups');
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle'); Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups'); Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
@@ -44,12 +44,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule'); Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule');
Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation'); Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation');
// AP Groups
Route::get('/ap-groups', [ApGroupController::class, 'index']) ->name('ap-groups.index');
Route::post('/ap-groups', [ApGroupController::class, 'store']) ->name('ap-groups.store');
Route::put('/ap-groups/{groupId}', [ApGroupController::class, 'update']) ->name('ap-groups.update');
Route::delete('/ap-groups/{groupId}', [ApGroupController::class, 'destroy']) ->name('ap-groups.destroy');
// Devices // Devices
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot'); Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick'); Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
@@ -78,6 +72,17 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test'); Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites'); Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
// Page Access — super-admin only. Lists unifi pages and lets
// operators assign per-page user/group grants.
Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
// Webhooks // Webhooks
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index'); Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store'); Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');