Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71807c8815 | |||
| 462a1a3611 | |||
| ee27bee716 | |||
| 429cd44ac5 | |||
| 274f210337 | |||
| c0f12ce931 | |||
| dd4e0ca564 | |||
| f533208b37 | |||
| bb74edf4c1 | |||
| e5cc075938 | |||
| 4ec4a293c0 | |||
| 720e94c54a |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dashboard/unifi",
|
||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||
"version": "1.9.0",
|
||||
"version": "1.13.1",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Brings unifi_page_grants to parity with adm_access_grants /
|
||||
* directory_access_grants: widens grantee_type to include 'default' and
|
||||
* 'role', allows a NULL grantee_id (used by the per-page default row),
|
||||
* and adds a can_view column. The default row's can_view carries the
|
||||
* deny/allow fallback for users not matched by a more specific grant
|
||||
* (deny by default — no row means deny).
|
||||
*
|
||||
* Existing user/group rows keep working: adding can_view with a default
|
||||
* of true backfills them as explicit allow grants.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('unifi_page_grants')) return;
|
||||
|
||||
// Widen the enum and allow NULL grantee_id (default rows). MySQL
|
||||
// enum changes require raw DDL.
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('default','user','group','role') NOT NULL");
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NULL");
|
||||
|
||||
if (! Schema::hasColumn('unifi_page_grants', 'can_view')) {
|
||||
Schema::table('unifi_page_grants', function (Blueprint $table) {
|
||||
$table->boolean('can_view')->default(true)->after('grantee_id');
|
||||
});
|
||||
|
||||
// Belt and braces: make sure every pre-existing user/group row
|
||||
// is an explicit allow grant.
|
||||
DB::table('unifi_page_grants')->update(['can_view' => true]);
|
||||
}
|
||||
|
||||
// The unique index on (nav_item_id, grantee_type, grantee_id)
|
||||
// already exists from the create migration
|
||||
// ('unifi_page_grants_unique') and still applies — grantee_id is
|
||||
// NULL for default rows, and MySQL treats NULLs as distinct, so
|
||||
// app code enforces one default row per nav_item.
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('unifi_page_grants')) return;
|
||||
|
||||
if (Schema::hasColumn('unifi_page_grants', 'can_view')) {
|
||||
Schema::table('unifi_page_grants', function (Blueprint $table) {
|
||||
$table->dropColumn('can_view');
|
||||
});
|
||||
}
|
||||
|
||||
DB::table('unifi_page_grants')->whereIn('grantee_type', ['default', 'role'])->delete();
|
||||
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('user','group') NOT NULL");
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NOT NULL");
|
||||
}
|
||||
};
|
||||
@@ -80,25 +80,23 @@ class RotatePasswords extends Command
|
||||
$newPass = $passwords[array_rand($passwords)];
|
||||
try {
|
||||
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
||||
// Embedded PPSK: update inside the parent WLAN object.
|
||||
// Synthetic ID is derived from the new passphrase, so update it too.
|
||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
|
||||
// Embedded PPSK: update inside the parent WLAN object,
|
||||
// matched by name (synthetic id changes with the
|
||||
// passphrase, so it's not a stable matcher).
|
||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
|
||||
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
||||
$oldPass = $ppsk->x_passphrase;
|
||||
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
||||
|
||||
// Sibling WLANs (same SSID name on a different band):
|
||||
// their embedded PPSK with the same name also needs
|
||||
// to rotate to the same new password so the SSID's
|
||||
// 2.4/5GHz halves stay in sync.
|
||||
foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) {
|
||||
// Update every grouped sibling (user-defined SSID
|
||||
// groups take precedence; same-name fallback for
|
||||
// installs that haven't grouped manually).
|
||||
foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
|
||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||
->where('name', $ppsk->name)
|
||||
->where('state', 'active')
|
||||
->first();
|
||||
$siblingOldPass = $sibling?->x_passphrase ?? $oldPass;
|
||||
try {
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass);
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
|
||||
if ($sibling) {
|
||||
$sibling->update([
|
||||
'x_passphrase' => $newPass,
|
||||
@@ -106,10 +104,37 @@ class RotatePasswords extends Command
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'not found')) {
|
||||
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'ppsk_name' => $ppsk->name,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
||||
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the new passphrase actually applied
|
||||
// on every grouped WLAN. UniFi can 200 an update
|
||||
// that doesn't stick (cluster sync race, etc).
|
||||
// Anything we expected to rotate that didn't is a
|
||||
// failure — surface it in the cron log.
|
||||
$allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id));
|
||||
foreach ($allWlanIds as $checkWlanId) {
|
||||
$result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass);
|
||||
if ($result['ok']) continue;
|
||||
|
||||
// 'not_found' on a sibling = PPSK isn't on that band — ignore
|
||||
// (consistent with the skip in the update loop).
|
||||
if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue;
|
||||
|
||||
$failedPpsks[] = [
|
||||
'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')',
|
||||
'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||
$ppsk->update(['x_passphrase' => $newPass]);
|
||||
|
||||
@@ -36,6 +36,7 @@ class StatsController extends Controller
|
||||
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'status' => $wan['status'] ?? 'unknown',
|
||||
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
|
||||
'rx_rate' => $wan['rx_bytes-r'] ?? 0,
|
||||
@@ -44,7 +45,19 @@ class StatsController extends Controller
|
||||
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['status' => 'error'], 500);
|
||||
// The UniFi controller is unreachable. Degrade gracefully: return 200 with a
|
||||
// friendly "unavailable" payload so the dashboard widget shows an unavailable
|
||||
// state instead of the poll silently failing on a 500.
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'status' => 'unavailable',
|
||||
'message' => 'UniFi controller is unreachable.',
|
||||
'tx_rate' => 0,
|
||||
'rx_rate' => 0,
|
||||
'isp' => null,
|
||||
'wan_ip' => null,
|
||||
'latency' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,12 +309,21 @@ class StatsController extends Controller
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'labels' => $series['labels'],
|
||||
'traffic_rx' => $rx,
|
||||
'traffic_tx' => $tx,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
// UniFi controller unreachable — degrade gracefully (200 with empty series)
|
||||
// so the chart shows an empty/unavailable state rather than erroring on a 500.
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'message' => 'UniFi controller is unreachable.',
|
||||
'labels' => [],
|
||||
'traffic_rx' => [],
|
||||
'traffic_tx' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers;
|
||||
use App\Models\DashboardApp;
|
||||
use App\Models\Group;
|
||||
use App\Models\NavItem;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -21,7 +22,7 @@ class UnifiPagesAccessController extends Controller
|
||||
{
|
||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||
if (! $app) {
|
||||
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
|
||||
return response()->json(['pages' => [], 'users' => [], 'groups' => [], 'roles' => []]);
|
||||
}
|
||||
|
||||
$pages = NavItem::where('app_id', $app->id)
|
||||
@@ -49,16 +50,27 @@ class UnifiPagesAccessController extends Controller
|
||||
->orWhereIn('id', $grantedGroupIds);
|
||||
})->orderBy('name')->get(['id', 'name', 'is_super']);
|
||||
|
||||
// Roles: only ones with at least one grant — more added via searchRoles.
|
||||
$grantedRoleIds = $grants->flatten(1)->where('grantee_type', 'role')->pluck('grantee_id')->unique();
|
||||
$roles = Role::whereIn('id', $grantedRoleIds)->orderBy('label')->get(['id', 'slug', 'label']);
|
||||
|
||||
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(),
|
||||
'pages' => $pages->map(function ($p) use ($grants) {
|
||||
$pageGrants = $grants->get($p->id, collect());
|
||||
$defaultRow = $pageGrants->firstWhere('grantee_type', 'default');
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'label' => $p->label,
|
||||
'route_name' => $p->route_name,
|
||||
'default_allow' => (bool) ($defaultRow?->can_view ?? false),
|
||||
'user_ids' => $pageGrants->where('grantee_type', 'user')->where('can_view', true)->pluck('grantee_id')->values()->all(),
|
||||
'group_ids' => $pageGrants->where('grantee_type', 'group')->where('can_view', true)->pluck('grantee_id')->values()->all(),
|
||||
'role_ids' => $pageGrants->where('grantee_type', 'role')->where('can_view', true)->pluck('grantee_id')->values()->all(),
|
||||
];
|
||||
})->values(),
|
||||
'users' => $users,
|
||||
'groups' => $groups,
|
||||
'roles' => $roles,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -106,6 +118,26 @@ class UnifiPagesAccessController extends Controller
|
||||
return response()->json(['groups' => $groups]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeahead-style search for roles to add to the access matrix.
|
||||
* An empty query returns every role (the role list is small).
|
||||
*/
|
||||
public function searchRoles(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if (strlen($q) < 1) {
|
||||
return response()->json(['roles' => Role::orderBy('label')->get(['id', 'slug', 'label'])]);
|
||||
}
|
||||
|
||||
$roles = Role::where('label', 'like', '%' . $q . '%')
|
||||
->orWhere('slug', 'like', '%' . $q . '%')
|
||||
->orderBy('label')
|
||||
->limit(20)
|
||||
->get(['id', 'slug', 'label']);
|
||||
|
||||
return response()->json(['roles' => $roles]);
|
||||
}
|
||||
|
||||
public function update(Request $request, NavItem $navItem)
|
||||
{
|
||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||
@@ -114,28 +146,62 @@ class UnifiPagesAccessController extends Controller
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'user_ids' => 'present|array',
|
||||
'user_ids.*' => 'integer|exists:users,id',
|
||||
'group_ids' => 'present|array',
|
||||
'group_ids.*' => 'integer|exists:groups,id',
|
||||
'default_allow' => 'boolean',
|
||||
'user_ids' => 'present|array',
|
||||
'user_ids.*' => 'integer|exists:users,id',
|
||||
'group_ids' => 'present|array',
|
||||
'group_ids.*' => 'integer|exists:groups,id',
|
||||
'role_ids' => 'array',
|
||||
'role_ids.*' => 'integer|exists:roles,id',
|
||||
]);
|
||||
|
||||
$grantedBy = $request->user()?->id;
|
||||
|
||||
DB::transaction(function () use ($navItem, $data, $grantedBy) {
|
||||
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
|
||||
// Upsert the default row (one per nav_item). firstOrCreate can't
|
||||
// match on grantee_id=NULL reliably in MySQL, so look it up first.
|
||||
$default = UnifiPageGrant::where('nav_item_id', $navItem->id)
|
||||
->where('grantee_type', 'default')
|
||||
->first();
|
||||
if ($default) {
|
||||
$default->update(['can_view' => (bool) ($data['default_allow'] ?? false)]);
|
||||
} else {
|
||||
UnifiPageGrant::create([
|
||||
'nav_item_id' => $navItem->id,
|
||||
'grantee_type' => 'default',
|
||||
'grantee_id' => null,
|
||||
'can_view' => (bool) ($data['default_allow'] ?? false),
|
||||
'granted_by_user_id' => $grantedBy,
|
||||
]);
|
||||
}
|
||||
|
||||
$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);
|
||||
$this->syncGrantsOfType($navItem->id, 'user', $data['user_ids'] ?? [], $grantedBy);
|
||||
$this->syncGrantsOfType($navItem->id, 'group', $data['group_ids'] ?? [], $grantedBy);
|
||||
$this->syncGrantsOfType($navItem->id, 'role', $data['role_ids'] ?? [], $grantedBy);
|
||||
});
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function syncGrantsOfType(int $navItemId, string $type, array $ids, ?int $actorId): void
|
||||
{
|
||||
UnifiPageGrant::where('nav_item_id', $navItemId)
|
||||
->where('grantee_type', $type)
|
||||
->whereNotIn('grantee_id', $ids ?: [0])
|
||||
->delete();
|
||||
|
||||
foreach ($ids as $id) {
|
||||
UnifiPageGrant::updateOrCreate(
|
||||
[
|
||||
'nav_item_id' => $navItemId,
|
||||
'grantee_type' => $type,
|
||||
'grantee_id' => $id,
|
||||
],
|
||||
[
|
||||
'can_view' => true,
|
||||
'granted_by_user_id' => $actorId,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ use Inertia\Inertia;
|
||||
|
||||
class UnifiSettingsController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
public function edit(?string $tab = null)
|
||||
{
|
||||
return Inertia::render('Unifi/Settings', [
|
||||
'activeTab' => $tab,
|
||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
|
||||
'site' => Setting::get('unifi.site', 'default'),
|
||||
|
||||
@@ -149,8 +149,18 @@ class WifiController extends Controller
|
||||
$name = $networksById[$nconfId]['name'] ?? null;
|
||||
}
|
||||
|
||||
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
|
||||
// Match in priority order:
|
||||
// 1. by current unifi_id (already-synced row)
|
||||
// 2. by name within this wlan (catches rotation: passphrase
|
||||
// changed → synthetic id changed → row identity unchanged)
|
||||
// 3. by passphrase among held rows (legacy fallback for
|
||||
// cases where name wasn't ingested)
|
||||
$record = UnifiPpsk::where('unifi_id', $uid)->first()
|
||||
?? ($name
|
||||
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->first()
|
||||
: null)
|
||||
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('x_passphrase', $pass)
|
||||
->where('state', 'held')
|
||||
@@ -174,8 +184,8 @@ class WifiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Only mark as held when we have confirmed live IDs —
|
||||
// never wipe on an empty API response (prevents false-holds on API failures)
|
||||
// Mark non-matching active rows as held — but ONLY if there's no
|
||||
// other active row with the same name we just reconnected.
|
||||
if (! empty($liveIds)) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
@@ -184,6 +194,47 @@ class WifiController extends Controller
|
||||
->update(['state' => 'held', 'unifi_id' => null]);
|
||||
}
|
||||
|
||||
// For each active row, salvage any rotate_password / schedule
|
||||
// settings from the held tombstones with the same name BEFORE
|
||||
// we prune them. Otherwise a row that had rotate=on loses the
|
||||
// flag every time a rotation changes its synthetic id.
|
||||
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
->whereNotNull('name')
|
||||
->get();
|
||||
foreach ($activeRows as $active) {
|
||||
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->where('name', $active->name)
|
||||
->where(fn ($q) => $q
|
||||
->where('rotate_password', true)
|
||||
->orWhereNotNull('schedule'))
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
if (! $heldWithSettings) continue;
|
||||
|
||||
$patch = [];
|
||||
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
|
||||
$patch['rotate_password'] = true;
|
||||
}
|
||||
if ($heldWithSettings->schedule && ! $active->schedule) {
|
||||
$patch['schedule'] = $heldWithSettings->schedule;
|
||||
}
|
||||
if ($patch) $active->update($patch);
|
||||
}
|
||||
|
||||
// Prune obsolete held rows: any held row whose name matches an
|
||||
// active row in the same wlan is a stale tombstone — its
|
||||
// settings have been salvaged above, and its data has been
|
||||
// superseded by the active one.
|
||||
$activeNames = $activeRows->pluck('name')->filter()->unique();
|
||||
if ($activeNames->isNotEmpty()) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->whereIn('name', $activeNames)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->orderBy('name')
|
||||
@@ -293,21 +344,20 @@ class WifiController extends Controller
|
||||
if (! empty($unifiUpdate)) {
|
||||
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
|
||||
// Embedded PPSK update path — modify the WLAN's embedded array.
|
||||
// Match by name (reliable across drift).
|
||||
$newPass = $unifiUpdate['x_passphrase'];
|
||||
$oldPass = $record->x_passphrase;
|
||||
$unifi->updateEmbeddedPpsk($record->wlan_id, $oldPass, $newPass);
|
||||
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
|
||||
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
|
||||
|
||||
// Also update sibling WLANs (banded SSID — same name
|
||||
// on 2.4 and 5GHz are separate wlanconf rows).
|
||||
foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) {
|
||||
// Also update grouped WLAN siblings (user-defined
|
||||
// SSID groups, falling back to same-name).
|
||||
foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
|
||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||
->where('name', $record->name)
|
||||
->where('state', 'active')
|
||||
->first();
|
||||
$siblingOldPass = $sibling?->x_passphrase ?? $oldPass;
|
||||
try {
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass);
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
|
||||
if ($sibling) {
|
||||
$sibling->update([
|
||||
'x_passphrase' => $newPass,
|
||||
@@ -315,7 +365,11 @@ class WifiController extends Controller
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('unifi.ppsk_sibling_update_failed', [
|
||||
// PPSK absent on this band is fine — just
|
||||
// means it isn't mirrored. Anything else
|
||||
// gets warning-logged.
|
||||
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
|
||||
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
51
src/Http/Middleware/EnforceUnifiPageGrant.php
Normal file
51
src/Http/Middleware/EnforceUnifiPageGrant.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Middleware;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use Closure;
|
||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||
use Dashboard\Unifi\UnifiServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Per-page access for unifi pages. Attached dynamically by the
|
||||
* RouteMatched listener in UnifiServiceProvider — the check cannot
|
||||
* live in the listener because RouteMatched fires before session/auth
|
||||
* middleware, when request->user() is still null.
|
||||
*/
|
||||
class EnforceUnifiPageGrant
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$routeName = $request->route()?->getName();
|
||||
|
||||
if (! $routeName || ! $user || $user->is_super_admin) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$item = NavItem::where('route_name', $routeName)->first();
|
||||
if ($item) {
|
||||
// Permission holders keep access — grants extend, never revoke.
|
||||
$holdsPermission = $item->required_permission
|
||||
&& UnifiServiceProvider::realPermissionKeysFor($user)->contains($item->required_permission);
|
||||
|
||||
if (! $holdsPermission && ! UnifiPageGrant::userCanAccess($user, $item)) {
|
||||
// 404 instead of 403 — don't leak that the page exists.
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable) {
|
||||
// unifi_page_grants may not exist yet on first install — fail
|
||||
// open in that narrow window (permission middleware still
|
||||
// guards the route).
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,16 @@ class UnifiPageGrant extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'nav_item_id',
|
||||
'grantee_type',
|
||||
'grantee_id',
|
||||
'grantee_type', // default | user | group | role
|
||||
'grantee_id', // null for grantee_type=default
|
||||
'can_view',
|
||||
'granted_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'can_view' => 'boolean',
|
||||
];
|
||||
|
||||
public function navItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NavItem::class);
|
||||
@@ -29,30 +34,32 @@ class UnifiPageGrant extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff $user is allowed to access $navItem under strict allowlist
|
||||
* semantics:
|
||||
* * super-admins (the model-level flag) always pass
|
||||
* * otherwise the user must be a direct grantee, OR be in a group
|
||||
* that is a grantee
|
||||
*
|
||||
* A page with NO grants saved is only visible to super-admins —
|
||||
* the admin must explicitly authorize everyone else via the
|
||||
* Access tab.
|
||||
* Grant check with a per-page default fallback. Passes if the user
|
||||
* matches an explicit user/group/role grant; otherwise falls back
|
||||
* to the page's `default` row (deny by default — no row or
|
||||
* can_view=false denies). Permission-based access is checked
|
||||
* separately by the caller — grants extend, never revoke.
|
||||
*/
|
||||
public static function userCanAccess(User $user, NavItem $navItem): bool
|
||||
{
|
||||
if ($user->is_super_admin) return true;
|
||||
|
||||
$groupIds = $user->groups()->pluck('groups.id');
|
||||
$roleIds = $user->roles()->pluck('roles.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);
|
||||
});
|
||||
$hasExplicit = static::where('nav_item_id', $navItem->id)
|
||||
->where('can_view', true)
|
||||
->where(function ($q) use ($user, $groupIds, $roleIds) {
|
||||
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
|
||||
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
|
||||
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds));
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($hasExplicit) return true;
|
||||
|
||||
return (bool) static::where('nav_item_id', $navItem->id)
|
||||
->where('grantee_type', 'default')
|
||||
->value('can_view');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,88 @@ class UnifiApiClient
|
||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every other WLAN that should rotate/update together with this
|
||||
* one. Authoritative source: the user-defined "SSID groups" setting
|
||||
* (unifi.ssid_groups) from the WiFi Networks page, which lets the
|
||||
* operator manually couple WLANs that may have different SSID names.
|
||||
*
|
||||
* Falls back to same-SSID-name siblings for installs that haven't
|
||||
* configured groups yet.
|
||||
*
|
||||
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
|
||||
*/
|
||||
public function getGroupedWlans(string $wlanId): array
|
||||
{
|
||||
$groupsJson = Setting::get('unifi.ssid_groups', '{}');
|
||||
$groups = json_decode($groupsJson, true);
|
||||
if (is_array($groups)) {
|
||||
foreach ($groups as $wlanIds) {
|
||||
if (! is_array($wlanIds)) continue;
|
||||
if (in_array($wlanId, $wlanIds, true)) {
|
||||
return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return $this->getWlanSiblings($wlanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an embedded PPSK has the expected passphrase right now.
|
||||
* Used after an update to confirm the change actually applied —
|
||||
* UniFi sometimes 200s an update that didn't stick (cluster sync
|
||||
* race, hot-restart in progress, etc.).
|
||||
*
|
||||
* Returns ['ok' => true] on a clean match, or
|
||||
* ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch']
|
||||
* with optional 'error' on fetch failures.
|
||||
*/
|
||||
public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array
|
||||
{
|
||||
try {
|
||||
$entries = $this->getPpskEntries($wlanId);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$networkconfId = $this->findNetworkconfIdByName($name);
|
||||
|
||||
foreach ($entries as $e) {
|
||||
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||
$entryNetId = $e['networkconf_id'] ?? null;
|
||||
$entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId)
|
||||
|| ($entryName !== null && $entryName === $name);
|
||||
if (! $entryMatches) continue;
|
||||
|
||||
$entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||
return $entryPass === $expectedPassphrase
|
||||
? ['ok' => true]
|
||||
: ['ok' => false, 'reason' => 'mismatch'];
|
||||
}
|
||||
return ['ok' => false, 'reason' => 'not_found'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a networkconf (VLAN/network) by its display name. Embedded
|
||||
* PPSKs on this controller use networkconf_id as their stable
|
||||
* identifier — the human "name" the operator sees is actually the
|
||||
* network's name.
|
||||
*/
|
||||
private function findNetworkconfIdByName(string $name): ?string
|
||||
{
|
||||
try {
|
||||
$networks = $this->getNetworkConfs();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
foreach ($networks as $n) {
|
||||
if (($n['name'] ?? null) === $name) {
|
||||
return $n['_id'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sibling WLAN configs — same SSID name, different _id. UniFi
|
||||
* splits a "banded" SSID (band-steering disabled) into one wlanconf
|
||||
@@ -538,7 +620,7 @@ class UnifiApiClient
|
||||
* no controller-side ID. Only changes the entry's passphrase; name
|
||||
* isn't separately addressable on embedded PPSKs.
|
||||
*/
|
||||
public function updateEmbeddedPpsk(string $wlanId, string $oldPassphrase, string $newPassphrase): array
|
||||
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
|
||||
{
|
||||
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
|
||||
$wlan = $wlanResp[0] ?? $wlanResp;
|
||||
@@ -548,26 +630,66 @@ class UnifiApiClient
|
||||
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
||||
}
|
||||
|
||||
$matched = false;
|
||||
foreach ($entries as &$e) {
|
||||
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||
if ($current === $oldPassphrase) {
|
||||
// Preserve whichever field name the controller is using.
|
||||
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
|
||||
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
|
||||
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
|
||||
// If none of the above existed, default to password (most common on embedded).
|
||||
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
|
||||
$e['password'] = $newPassphrase;
|
||||
}
|
||||
$matched = true;
|
||||
break;
|
||||
// Embedded PPSKs on this controller don't carry a name field —
|
||||
// the human label ("GUEST", "3DPrinters", …) is the *network's*
|
||||
// name, and each entry references it via networkconf_id. So when
|
||||
// the caller passes a name, first resolve it to a networkconf_id
|
||||
// and match on that. Falls back to entry-level name (other
|
||||
// controller versions DO put a name on the entry) and finally
|
||||
// to current passphrase.
|
||||
$applyUpdate = function (array &$e) use ($newPassphrase) {
|
||||
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
|
||||
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
|
||||
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
|
||||
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
|
||||
$e['password'] = $newPassphrase;
|
||||
}
|
||||
};
|
||||
|
||||
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
|
||||
|
||||
$matched = false;
|
||||
if ($networkconfId !== null) {
|
||||
foreach ($entries as &$e) {
|
||||
if (($e['networkconf_id'] ?? null) === $networkconfId) {
|
||||
$applyUpdate($e);
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($e);
|
||||
}
|
||||
|
||||
if (! $matched && $name !== null && $name !== '') {
|
||||
foreach ($entries as &$e) {
|
||||
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||
if ($entryName === $name) {
|
||||
$applyUpdate($e);
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($e);
|
||||
}
|
||||
|
||||
if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') {
|
||||
foreach ($entries as &$e) {
|
||||
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||
if ($current === $oldPassphrase) {
|
||||
$applyUpdate($e);
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($e);
|
||||
}
|
||||
unset($e);
|
||||
|
||||
if (! $matched) {
|
||||
throw new \RuntimeException('Embedded PPSK not found by current passphrase.');
|
||||
throw new \RuntimeException(
|
||||
'Embedded PPSK not found' .
|
||||
($name !== null ? " for network \"{$name}\"" : '') .
|
||||
' or by current passphrase.'
|
||||
);
|
||||
}
|
||||
|
||||
// UniFi REST expects the full WLAN object on PUT — send what we
|
||||
|
||||
@@ -181,7 +181,11 @@ class WebhookCheckService
|
||||
$prev = DeviceState::where('device_mac', $mac)->first();
|
||||
if (! $prev) continue;
|
||||
|
||||
// Skip planned reboots — these are intentional, not alerts
|
||||
// Skip planned reboots — these are intentional, not alerts.
|
||||
// Two layers: a global suppression window set by RebootAllAps
|
||||
// (Setting, survives any cache driver), plus the per-MAC
|
||||
// cache keys for finer granularity.
|
||||
if ($this->inGlobalRebootSuppression()) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
||||
|
||||
if ($comingOnline) {
|
||||
@@ -509,6 +513,8 @@ class WebhookCheckService
|
||||
private function checkReboot($aps, array $filter): array
|
||||
{
|
||||
$alerts = [];
|
||||
if ($this->inGlobalRebootSuppression()) return $alerts;
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
||||
@@ -584,6 +590,24 @@ class WebhookCheckService
|
||||
return self::buildPlatformPayload($url, $message, $fullPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a fleet reboot in progress right now? RebootAllAps stamps a
|
||||
* suppression-until timestamp as a Setting; while that timestamp
|
||||
* is in the future, we skip all device-offline / device-online /
|
||||
* unexpected-reboot alerts to avoid flooding webhooks during the
|
||||
* known maintenance window.
|
||||
*/
|
||||
private function inGlobalRebootSuppression(): bool
|
||||
{
|
||||
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
|
||||
if (! $until) return false;
|
||||
try {
|
||||
return \Carbon\Carbon::parse($until)->isFuture();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public/static helper so the test-webhook endpoint produces the
|
||||
* same per-platform payload shape that real events do.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Dashboard\Unifi;
|
||||
|
||||
use App\Models\DashboardApp;
|
||||
use App\Models\NavItem;
|
||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||
use Illuminate\Routing\Events\RouteMatched;
|
||||
@@ -25,34 +24,34 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||
$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.
|
||||
// Tell the shell's nav sidebar which unifi nav items the user
|
||||
// can see: pages whose required_permission the user holds through
|
||||
// groups, plus pages granted via the Settings → Access tab. The
|
||||
// shell also folds these grants back into User::can()/allPermissions
|
||||
// so the route-level permission middleware passes for grantees.
|
||||
try {
|
||||
app(\App\Support\NavVisibilityRegistry::class)->register(
|
||||
'unifi.',
|
||||
fn (\App\Models\User $user) => $this->visibleUnifiNavItemIdsFor($user),
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Shell may not have the registry yet (older shell version).
|
||||
// Sidebar will fall back to legacy permission filter.
|
||||
}
|
||||
|
||||
// Per-page enforcement for unifi pages. Settings stays on its
|
||||
// permission:unifi.settings route middleware (the Access tab
|
||||
// itself lives there and must not be able to lock itself out).
|
||||
// The user-dependent check lives in middleware appended to the
|
||||
// matched route: RouteMatched fires before the session/auth
|
||||
// middleware run, so request->user() is null here and any check
|
||||
// at this point silently fails open.
|
||||
Event::listen(RouteMatched::class, function (RouteMatched $event) {
|
||||
$routeName = $event->route->getName();
|
||||
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
||||
if (str_starts_with($routeName, 'unifi.settings')) 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)) {
|
||||
// 404 instead of 403 — don't leak that the page
|
||||
// exists. The Access tab is the only way in.
|
||||
abort(404);
|
||||
}
|
||||
} 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.
|
||||
}
|
||||
$event->route->middleware(\Dashboard\Unifi\Http\Middleware\EnforceUnifiPageGrant::class);
|
||||
});
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
@@ -69,4 +68,58 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
], 'unifi-config');
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every unifi nav_item_id this user is allowed to see. */
|
||||
protected function visibleUnifiNavItemIdsFor(\App\Models\User $user): \Illuminate\Support\Collection
|
||||
{
|
||||
$ids = collect();
|
||||
|
||||
// Pages whose required_permission the user already holds through
|
||||
// groups keep showing, independent of the access-grant matrix.
|
||||
$permittedIds = NavItem::where('route_name', 'like', 'unifi.%')
|
||||
->whereIn('required_permission', static::realPermissionKeysFor($user))
|
||||
->pluck('id');
|
||||
$ids = $ids->merge($permittedIds);
|
||||
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('unifi_page_grants')) {
|
||||
$groupIds = $user->groups()->pluck('groups.id');
|
||||
$roleIds = $user->roles()->pluck('roles.id');
|
||||
|
||||
$grantedIds = UnifiPageGrant::query()
|
||||
->where('can_view', true)
|
||||
->where(function ($q) use ($user, $groupIds, $roleIds) {
|
||||
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
|
||||
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
|
||||
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds))
|
||||
// Default-allow row makes the page visible to everyone.
|
||||
->orWhere('grantee_type', 'default');
|
||||
})
|
||||
->pluck('nav_item_id');
|
||||
$ids = $ids->merge($grantedIds);
|
||||
}
|
||||
|
||||
return $ids->unique()->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Group + direct permissions only. Deliberately NOT allPermissions():
|
||||
* the shell folds page-grant permissions back into allPermissions/can,
|
||||
* so using it here would turn a single granted page into every page
|
||||
* carrying the same permission.
|
||||
*/
|
||||
public static function realPermissionKeysFor(\App\Models\User $user): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($user->is_super_admin) {
|
||||
return \App\Models\Permission::pluck('key');
|
||||
}
|
||||
|
||||
$direct = $user->directPermissions()->get();
|
||||
|
||||
return \App\Models\Permission::whereHas('groups', fn ($q) => $q->whereIn('group_id', $user->groups()->pluck('groups.id')))
|
||||
->pluck('key')
|
||||
->merge($direct->where('granted', true)->pluck('permission_key'))
|
||||
->diff($direct->where('granted', false)->pluck('permission_key'))
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,16 +69,20 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
Route::middleware('permission:unifi.settings')->group(function () {
|
||||
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
|
||||
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
|
||||
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
|
||||
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
|
||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||
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.
|
||||
// operators assign per-page user/group/role grants plus the
|
||||
// per-page "everyone else" default row (deny by default).
|
||||
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::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
|
||||
Route::get('/settings/pages-access/roles/search', [UnifiPagesAccessController::class, 'searchRoles'])->name('settings.pages-access.roles.search');
|
||||
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user