3 Commits

Author SHA1 Message Date
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
10 changed files with 236 additions and 168 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.3.0", "version": "1.4.0",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {
@@ -27,9 +27,8 @@
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 }, { "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
{ "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": "AP Groups", "route_name": "unifi.ap-groups.index", "icon": "rectangle-stack", "permission": "unifi.manage", "sort_order": 6 }, { "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": 7 }, { "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 8 },
{ "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

@@ -14,16 +14,21 @@ class SyncPpskSchedules extends Command
public function handle(UnifiApiClient $unifi): int public function handle(UnifiApiClient $unifi): int
{ {
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) { // Always run, even when global ppsk_scheduling is disabled — in
return self::SUCCESS; // that case the target state for every PPSK is "active" (always
} // on). That way disabling the global setting actually restores
// any held PPSKs to active without operators having to do
// anything else, and null-schedule PPSKs always end up active.
// Schedules in the DB are preserved regardless of toggle state,
// so re-enabling resumes the per-PPSK schedule.
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = \App\Support\Timezone::current(); $tz = \App\Support\Timezone::current();
$now = now($tz); $now = now($tz);
$day = $now->dayOfWeek; // 0=Sun … 6=Sat $day = $now->dayOfWeek; // 0=Sun … 6=Sat
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 047 $slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 047
$ppsks = UnifiPpsk::whereNotNull('schedule')->get(); $ppsks = UnifiPpsk::all();
if ($ppsks->isEmpty()) { if ($ppsks->isEmpty()) {
return self::SUCCESS; return self::SUCCESS;
@@ -42,7 +47,12 @@ class SyncPpskSchedules extends Command
} }
foreach ($ppsks as $ppsk) { foreach ($ppsks as $ppsk) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true); // Default to "always on". Only consult the schedule if
// global scheduling is enabled AND this PPSK has one.
$shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
}
if ($shouldBeOn && $ppsk->state === 'held') { if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan); $this->enablePpsk($ppsk, $unifi, $networksByVlan);

View File

@@ -1,95 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class ApGroupController extends Controller
{
public function index(UnifiApiClient $unifi)
{
// Always pull fresh from the controller on this page so the
// operator never edits against a stale snapshot. getApGroups()
// and getWlans() aren't cached, but getDevices() (which feeds
// the AP picker) is — bust it explicitly.
Cache::forget('unifi:devices');
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,82 @@
<?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');
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' => User::orderBy('name')->get(['id', 'name', 'email']),
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
]);
}
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

@@ -13,25 +13,9 @@ class WifiController extends Controller
{ {
public function index(UnifiApiClient $unifi) public function index(UnifiApiClient $unifi)
{ {
// Always pull fresh device data on this page so AP-group / SSID
// edits never go out against a stale snapshot. getWlans() and
// getApGroups() aren't cached, but getDevices() is.
\Illuminate\Support\Facades\Cache::forget('unifi:devices');
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 = [];
@@ -42,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(),
]); ]);
} }
} }
@@ -103,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']);
@@ -419,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,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,10 @@
<?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\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 +32,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 +43,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 +71,13 @@ 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::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// 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');